commit b4fdf18d074c7b7fe3ebf6b6c045021f8a8f6cf6 Author: root Date: Thu Aug 27 21:52:21 2026 -0400 add more design improvement diff --git a/healthcare-api-foundation_4.yaml b/healthcare-api-foundation_4.yaml new file mode 100644 index 0000000..356d27b --- /dev/null +++ b/healthcare-api-foundation_4.yaml @@ -0,0 +1,1778 @@ +openapi: 3.1.0 +info: + title: Professional Platform Healthcare API + version: 0.2.0-openemr-informed + summary: Clean-room healthcare identity and encounter API. + description: | + A proprietary healthcare API informed by established EHR workflows and public + interoperability standards. This contract does not copy OpenEMR source code, + database structures, or API routes. + + This foundation intentionally covers patients, practitioners, and encounters + only. Clinical records, diagnoses, allergies, medications, prescriptions, + appointments, insurance, claims, and billing require separate threat models + and domain contracts. + + Every operation is tenant scoped. Cross-tenant identifiers return 404. + Mutable resources use ETags and require If-Match. Creation and commands use + Idempotency-Key. Responses containing patient data must not be cached. + contact: + name: Healthcare API Team +servers: + - url: https://api.example.com/api/v1 + description: Production + - url: https://sandbox-api.example.com/api/v1 + description: Sandbox +tags: + - name: Healthcare Patients + - name: Healthcare Practitioners + - name: Healthcare Encounters + - name: Healthcare Locations + - name: Healthcare Appointments + - name: Healthcare Allergies + - name: Healthcare Conditions + - name: Healthcare Medications + - name: Healthcare Clinical Records + +security: + - bearerAuth: [] + +paths: + /healthcare/patients: + get: + tags: [Healthcare Patients] + operationId: listHealthcarePatients + summary: List patients using minimum-necessary projections + description: | + Search results are protected health information. By default, this operation + returns only the summary projection. Access is audited. Free-text searching + across clinical content is prohibited. + x-permission: healthcare.patients.read + x-audit-event: healthcare.patient.listed + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/PageSize' + - name: patientNumber + in: query + schema: {type: string, maxLength: 64} + - name: familyName + in: query + schema: {type: string, maxLength: 120} + - name: birthDate + in: query + schema: {type: string, format: date} + - name: status + in: query + schema: {$ref: '#/components/schemas/PatientStatus'} + responses: + '200': + description: Patient summaries. + headers: + Cache-Control: {$ref: '#/components/headers/NoStore'} + X-Request-Id: {$ref: '#/components/headers/RequestId'} + content: + application/json: + schema: {$ref: '#/components/schemas/PatientListResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '422': {$ref: '#/components/responses/ValidationError'} + post: + tags: [Healthcare Patients] + operationId: createHealthcarePatient + summary: Create a patient + description: | + The service performs deterministic duplicate screening within the tenant. + A suspected match returns PATIENT_POSSIBLE_DUPLICATE and requires an + authorized human to resolve it. Names alone never trigger automatic merging. + x-permission: healthcare.patients.create + x-audit-event: healthcare.patient.created + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/PatientCreate'} + responses: + '201': + description: Patient created. + headers: + Location: {$ref: '#/components/headers/Location'} + ETag: {$ref: '#/components/headers/ETag'} + Cache-Control: {$ref: '#/components/headers/NoStore'} + X-Request-Id: {$ref: '#/components/headers/RequestId'} + content: + application/json: + schema: {$ref: '#/components/schemas/PatientResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/patients/{patientId}: + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PatientId' + get: + tags: [Healthcare Patients] + operationId: getHealthcarePatient + summary: Retrieve a patient + x-permission: healthcare.patients.read + x-audit-event: healthcare.patient.read + responses: + '200': + description: Patient record. + headers: + ETag: {$ref: '#/components/headers/ETag'} + Cache-Control: {$ref: '#/components/headers/NoStore'} + X-Request-Id: {$ref: '#/components/headers/RequestId'} + content: + application/json: + schema: {$ref: '#/components/schemas/PatientResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Healthcare Patients] + operationId: updateHealthcarePatient + summary: Update patient demographics + description: Patient number, organization, status, and archival fields are not patchable. + x-permission: healthcare.patients.update + x-audit-event: healthcare.patient.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/merge-patch+json: + schema: {$ref: '#/components/schemas/PatientPatch'} + responses: + '200': + description: Patient updated. + headers: + ETag: {$ref: '#/components/headers/ETag'} + Cache-Control: {$ref: '#/components/headers/NoStore'} + X-Request-Id: {$ref: '#/components/headers/RequestId'} + content: + application/json: + schema: {$ref: '#/components/schemas/PatientResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/patients/{patientId}/archive: + post: + tags: [Healthcare Patients] + operationId: archiveHealthcarePatient + summary: Archive a duplicate or erroneous patient shell + description: | + Archival never deletes clinical history. A patient with encounters may only + be archived after an authorized reconciliation workflow confirms the target. + x-permission: healthcare.patients.archive + x-audit-event: healthcare.patient.archived + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PatientId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReasonCommand'} + responses: + '200': + description: Patient archived. + headers: + ETag: {$ref: '#/components/headers/ETag'} + Cache-Control: {$ref: '#/components/headers/NoStore'} + content: + application/json: + schema: {$ref: '#/components/schemas/PatientResponse'} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/patients/{patientId}/restore: + post: + tags: [Healthcare Patients] + operationId: restoreHealthcarePatient + summary: Restore an archived patient + x-permission: healthcare.patients.archive + x-audit-event: healthcare.patient.restored + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PatientId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '200': + description: Patient restored. + headers: + ETag: {$ref: '#/components/headers/ETag'} + Cache-Control: {$ref: '#/components/headers/NoStore'} + content: + application/json: + schema: {$ref: '#/components/schemas/PatientResponse'} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + + /healthcare/practitioners: + get: + tags: [Healthcare Practitioners] + operationId: listHealthcarePractitioners + summary: List practitioners + x-permission: healthcare.practitioners.read + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/PageSize' + - name: status + in: query + schema: {$ref: '#/components/schemas/PractitionerStatus'} + responses: + '200': + description: Practitioners. + content: + application/json: + schema: {$ref: '#/components/schemas/PractitionerListResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [Healthcare Practitioners] + operationId: createHealthcarePractitioner + summary: Create a practitioner assignment + description: | + The referenced user must have an active organization membership. Credential + records remain authoritative in the shared professional credential registry. + x-permission: healthcare.practitioners.manage + x-audit-event: healthcare.practitioner.created + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/PractitionerCreate'} + responses: + '201': + description: Practitioner assignment created. + headers: + Location: {$ref: '#/components/headers/Location'} + ETag: {$ref: '#/components/headers/ETag'} + content: + application/json: + schema: {$ref: '#/components/schemas/PractitionerResponse'} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/practitioners/{practitionerId}: + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PractitionerId' + get: + tags: [Healthcare Practitioners] + operationId: getHealthcarePractitioner + summary: Retrieve a practitioner + x-permission: healthcare.practitioners.read + responses: + '200': + description: Practitioner. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: + application/json: + schema: {$ref: '#/components/schemas/PractitionerResponse'} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Healthcare Practitioners] + operationId: updateHealthcarePractitioner + summary: Update practitioner specialty or status + description: userId and professionalProfileId are immutable after creation. + x-permission: healthcare.practitioners.manage + x-audit-event: healthcare.practitioner.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/merge-patch+json: + schema: {$ref: '#/components/schemas/PractitionerPatch'} + responses: + '200': + description: Practitioner updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: + application/json: + schema: {$ref: '#/components/schemas/PractitionerResponse'} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/encounters: + get: + tags: [Healthcare Encounters] + operationId: listHealthcareEncounters + summary: List encounters + x-permission: healthcare.encounters.read + x-audit-event: healthcare.encounter.listed + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/PageSize' + - name: patientId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: practitionerId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: status + in: query + schema: {$ref: '#/components/schemas/EncounterStatus'} + - name: from + in: query + schema: {type: string, format: date-time} + - name: to + in: query + schema: {type: string, format: date-time} + responses: + '200': + description: Encounter summaries. + headers: {Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: + application/json: + schema: {$ref: '#/components/schemas/EncounterListResponse'} + '422': {$ref: '#/components/responses/ValidationError'} + post: + tags: [Healthcare Encounters] + operationId: createHealthcareEncounter + summary: Create a planned encounter + description: | + Patient and practitioner must be active in the same tenant. Clinical narrative + is not accepted by this endpoint. + x-permission: healthcare.encounters.create + x-audit-event: healthcare.encounter.created + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/EncounterCreate'} + responses: + '201': + description: Encounter created. + headers: + Location: {$ref: '#/components/headers/Location'} + ETag: {$ref: '#/components/headers/ETag'} + Cache-Control: {$ref: '#/components/headers/NoStore'} + content: + application/json: + schema: {$ref: '#/components/schemas/EncounterResponse'} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/encounters/{encounterId}: + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/EncounterId' + get: + tags: [Healthcare Encounters] + operationId: getHealthcareEncounter + summary: Retrieve an encounter + x-permission: healthcare.encounters.read + x-audit-event: healthcare.encounter.read + responses: + '200': + description: Encounter. + headers: + ETag: {$ref: '#/components/headers/ETag'} + Cache-Control: {$ref: '#/components/headers/NoStore'} + content: + application/json: + schema: {$ref: '#/components/schemas/EncounterResponse'} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Healthcare Encounters] + operationId: updateHealthcareEncounter + summary: Update a planned encounter + description: Only planned encounters are patchable; status changes use commands. + x-permission: healthcare.encounters.update + x-audit-event: healthcare.encounter.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/merge-patch+json: + schema: {$ref: '#/components/schemas/EncounterPatch'} + responses: + '200': + description: Encounter updated. + headers: + ETag: {$ref: '#/components/headers/ETag'} + Cache-Control: {$ref: '#/components/headers/NoStore'} + content: + application/json: + schema: {$ref: '#/components/schemas/EncounterResponse'} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/encounters/{encounterId}/start: + post: + tags: [Healthcare Encounters] + operationId: startHealthcareEncounter + summary: Start a planned encounter + x-permission: healthcare.encounters.conduct + x-audit-event: healthcare.encounter.started + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/EncounterId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/StartEncounterCommand'} + responses: + '200': + description: Encounter started. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: + application/json: + schema: {$ref: '#/components/schemas/EncounterResponse'} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + + /healthcare/encounters/{encounterId}/finish: + post: + tags: [Healthcare Encounters] + operationId: finishHealthcareEncounter + summary: Finish an in-progress encounter + description: Finishing an encounter does not sign associated clinical records. + x-permission: healthcare.encounters.conduct + x-audit-event: healthcare.encounter.finished + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/EncounterId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/FinishEncounterCommand'} + responses: + '200': + description: Encounter finished. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: + application/json: + schema: {$ref: '#/components/schemas/EncounterResponse'} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/encounters/{encounterId}/cancel: + post: + tags: [Healthcare Encounters] + operationId: cancelHealthcareEncounter + summary: Cancel a planned encounter + x-permission: healthcare.encounters.update + x-audit-event: healthcare.encounter.cancelled + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/EncounterId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReasonCommand'} + responses: + '200': + description: Encounter cancelled. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: + application/json: + schema: {$ref: '#/components/schemas/EncounterResponse'} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + + /healthcare/locations: + get: + tags: [Healthcare Locations] + operationId: listHealthcareLocations + summary: List care locations + x-permission: healthcare.locations.read + parameters: &tenantListParameters + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/PageSize' + responses: + '200': + description: Care locations. + content: {application/json: {schema: {$ref: '#/components/schemas/LocationListResponse'}}} + post: + tags: [Healthcare Locations] + operationId: createHealthcareLocation + summary: Create a care location + x-permission: healthcare.locations.manage + x-audit-event: healthcare.location.created + parameters: &tenantCreateParameters + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LocationCreate'}}} + responses: + '201': + description: Location created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/LocationResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/locations/{locationId}: + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/LocationId' + get: + tags: [Healthcare Locations] + operationId: getHealthcareLocation + summary: Retrieve a care location + x-permission: healthcare.locations.read + responses: + '200': + description: Location. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/LocationResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Healthcare Locations] + operationId: updateHealthcareLocation + summary: Update a care location + x-permission: healthcare.locations.manage + x-audit-event: healthcare.location.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/LocationPatch'}}} + responses: + '200': + description: Location updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/LocationResponse'}}} + '412': {$ref: '#/components/responses/PreconditionFailed'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/appointments: + get: + tags: [Healthcare Appointments] + operationId: listHealthcareAppointments + summary: List appointments + x-permission: healthcare.appointments.read + x-audit-event: healthcare.appointment.listed + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/PageSize' + - name: patientId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: practitionerId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: from + in: query + schema: {type: string, format: date-time} + - name: to + in: query + schema: {type: string, format: date-time} + - name: status + in: query + schema: {$ref: '#/components/schemas/AppointmentStatus'} + responses: + '200': + description: Appointments. + headers: {Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/AppointmentListResponse'}}} + post: + tags: [Healthcare Appointments] + operationId: createHealthcareAppointment + summary: Book an appointment + description: Scheduling conflicts return APPOINTMENT_CONFLICT; the server never silently double-books. + x-permission: healthcare.appointments.create + x-audit-event: healthcare.appointment.created + parameters: *tenantCreateParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AppointmentCreate'}}} + responses: + '201': + description: Appointment booked. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/AppointmentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/appointments/{appointmentId}: + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/AppointmentId' + get: + tags: [Healthcare Appointments] + operationId: getHealthcareAppointment + summary: Retrieve an appointment + x-permission: healthcare.appointments.read + x-audit-event: healthcare.appointment.read + responses: + '200': + description: Appointment. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/AppointmentResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Healthcare Appointments] + operationId: updateHealthcareAppointment + summary: Reschedule or update a booked appointment + description: Status changes use commands; fulfilled appointments are immutable. + x-permission: healthcare.appointments.update + x-audit-event: healthcare.appointment.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/AppointmentPatch'}}} + responses: + '200': + description: Appointment updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/AppointmentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/appointments/{appointmentId}/{command}: + post: + tags: [Healthcare Appointments] + operationId: commandHealthcareAppointment + summary: Confirm, check in, fulfil, cancel, or mark an appointment as no-show + description: | + Allowed transitions: booked to confirmed/cancelled; confirmed to checked_in/cancelled/no_show; + checked_in to fulfilled. Fulfilment requires a linked encounter. + x-permission: healthcare.appointments.update + x-audit-event: healthcare.appointment.commanded + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/AppointmentId' + - name: command + in: path + required: true + schema: {type: string, enum: [confirm, check-in, fulfil, cancel, no-show]} + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalReasonCommand'}}} + responses: + '200': + description: Appointment transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/AppointmentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + + /healthcare/patients/{patientId}/allergies: + get: + tags: [Healthcare Allergies] + operationId: listPatientAllergies + summary: List recorded allergies and intolerances + x-permission: healthcare.allergies.read + x-audit-event: healthcare.allergy.listed + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PatientId' + responses: + '200': + description: Allergy records. + headers: {Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/AllergyListResponse'}}} + post: + tags: [Healthcare Allergies] + operationId: createPatientAllergy + summary: Record an allergy or intolerance + x-permission: healthcare.allergies.create + x-audit-event: healthcare.allergy.created + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PatientId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AllergyCreate'}}} + responses: + '201': + description: Allergy recorded. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/AllergyResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/allergies/{allergyId}: + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/AllergyId' + get: + tags: [Healthcare Allergies] + operationId: getPatientAllergy + summary: Retrieve an allergy + x-permission: healthcare.allergies.read + x-audit-event: healthcare.allergy.read + responses: + '200': + description: Allergy. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/AllergyResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Healthcare Allergies] + operationId: updatePatientAllergy + summary: Update verification or clinical status + description: Substance identity is immutable; incorrect entries become entered_in_error. + x-permission: healthcare.allergies.update + x-audit-event: healthcare.allergy.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/AllergyPatch'}}} + responses: + '200': + description: Allergy updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/AllergyResponse'}}} + '412': {$ref: '#/components/responses/PreconditionFailed'} + + /healthcare/patients/{patientId}/conditions: + get: + tags: [Healthcare Conditions] + operationId: listPatientConditions + summary: List problems, diagnoses, and health concerns + x-permission: healthcare.conditions.read + x-audit-event: healthcare.condition.listed + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PatientId' + - name: category + in: query + schema: {$ref: '#/components/schemas/ConditionCategory'} + responses: + '200': + description: Conditions. + headers: {Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ConditionListResponse'}}} + post: + tags: [Healthcare Conditions] + operationId: createPatientCondition + summary: Record a problem, diagnosis, or health concern + x-permission: healthcare.conditions.create + x-audit-event: healthcare.condition.created + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PatientId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ConditionCreate'}}} + responses: + '201': + description: Condition recorded. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ConditionResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/conditions/{conditionId}: + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ConditionId' + get: + tags: [Healthcare Conditions] + operationId: getPatientCondition + summary: Retrieve a condition + x-permission: healthcare.conditions.read + x-audit-event: healthcare.condition.read + responses: + '200': + description: Condition. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ConditionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Healthcare Conditions] + operationId: updatePatientCondition + summary: Update condition lifecycle or verification + x-permission: healthcare.conditions.update + x-audit-event: healthcare.condition.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/ConditionPatch'}}} + responses: + '200': + description: Condition updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ConditionResponse'}}} + '412': {$ref: '#/components/responses/PreconditionFailed'} + + /healthcare/patients/{patientId}/medications: + get: + tags: [Healthcare Medications] + operationId: listPatientMedications + summary: List medication statements and orders + x-permission: healthcare.medications.read + x-audit-event: healthcare.medication.listed + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PatientId' + responses: + '200': + description: Medications. + headers: {Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/MedicationListResponse'}}} + post: + tags: [Healthcare Medications] + operationId: createPatientMedication + summary: Record a medication statement + description: This operation records medication history; it does not prescribe or dispense. + x-permission: healthcare.medications.create + x-audit-event: healthcare.medication.created + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PatientId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/MedicationCreate'}}} + responses: + '201': + description: Medication statement recorded. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/MedicationResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/medications/{medicationId}: + patch: + tags: [Healthcare Medications] + operationId: updatePatientMedication + summary: Update a medication statement lifecycle + x-permission: healthcare.medications.update + x-audit-event: healthcare.medication.updated + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/MedicationId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/MedicationPatch'}}} + responses: + '200': + description: Medication updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/MedicationResponse'}}} + '412': {$ref: '#/components/responses/PreconditionFailed'} + + /healthcare/encounters/{encounterId}/clinical-records: + get: + tags: [Healthcare Clinical Records] + operationId: listEncounterClinicalRecords + summary: List clinical records for an encounter + x-permission: healthcare.clinical_records.read + x-audit-event: healthcare.clinical_record.listed + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/EncounterId' + responses: + '200': + description: Clinical record summaries; narrative content is excluded. + headers: {Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ClinicalRecordListResponse'}}} + post: + tags: [Healthcare Clinical Records] + operationId: createEncounterClinicalRecord + summary: Create a draft clinical record + x-permission: healthcare.clinical_records.create + x-audit-event: healthcare.clinical_record.created + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/EncounterId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ClinicalRecordCreate'}}} + responses: + '201': + description: Draft record created with version 1. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ClinicalRecordResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/clinical-records/{recordId}: + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ClinicalRecordId' + get: + tags: [Healthcare Clinical Records] + operationId: getClinicalRecord + summary: Retrieve a clinical record and current content version + x-permission: healthcare.clinical_records.read + x-audit-event: healthcare.clinical_record.read + responses: + '200': + description: Clinical record. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ClinicalRecordResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Healthcare Clinical Records] + operationId: updateDraftClinicalRecord + summary: Create a new version of a draft clinical record + description: Signed records reject PATCH; use the amendment command. + x-permission: healthcare.clinical_records.update + x-audit-event: healthcare.clinical_record.version_created + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/ClinicalRecordPatch'}}} + responses: + '200': + description: New draft version created. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ClinicalRecordResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + + /healthcare/clinical-records/{recordId}/sign: + post: + tags: [Healthcare Clinical Records] + operationId: signClinicalRecord + summary: Sign and freeze the current clinical record version + description: Revalidates active practitioner status and credential authority at execution time. + x-permission: healthcare.clinical_records.sign + x-audit-event: healthcare.clinical_record.signed + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ClinicalRecordId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SignClinicalRecordCommand'}}} + responses: + '200': + description: Record signed and immutable. + headers: {ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ClinicalRecordResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/clinical-records/{recordId}/amend: + post: + tags: [Healthcare Clinical Records] + operationId: amendClinicalRecord + summary: Amend a signed clinical record without overwriting history + x-permission: healthcare.clinical_records.amend + x-audit-event: healthcare.clinical_record.amended + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ClinicalRecordId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AmendClinicalRecordCommand'}}} + responses: + '201': + description: Signed amendment version created with provenance links. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}, Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ClinicalRecordResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '412': {$ref: '#/components/responses/PreconditionFailed'} + '422': {$ref: '#/components/responses/ValidationError'} + + /healthcare/clinical-records/{recordId}/history: + get: + tags: [Healthcare Clinical Records] + operationId: getClinicalRecordHistory + summary: Retrieve immutable version and amendment history + x-permission: healthcare.clinical_records.history + x-audit-event: healthcare.clinical_record.history_read + parameters: + - $ref: '#/components/parameters/OrganizationId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ClinicalRecordId' + responses: + '200': + description: Version history. + headers: {Cache-Control: {$ref: '#/components/headers/NoStore'}} + content: {application/json: {schema: {$ref: '#/components/schemas/ClinicalRecordHistoryResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + OrganizationId: + name: X-Organization-Id + in: header + required: true + schema: {$ref: '#/components/schemas/Uuid'} + RequestId: + name: X-Request-Id + in: header + required: false + schema: {type: string, maxLength: 128} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string, minLength: 16, maxLength: 128} + IfMatch: + name: If-Match + in: header + required: true + schema: {type: string, pattern: '^"[1-9][0-9]*"$'} + Cursor: + name: cursor + in: query + schema: {type: string, maxLength: 512} + PageSize: + name: pageSize + in: query + schema: {type: integer, minimum: 1, maximum: 100, default: 25} + PatientId: + name: patientId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + PractitionerId: + name: practitionerId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + EncounterId: + name: encounterId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + LocationId: + name: locationId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + AppointmentId: + name: appointmentId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + AllergyId: + name: allergyId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + ConditionId: + name: conditionId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + MedicationId: + name: medicationId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + ClinicalRecordId: + name: recordId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + headers: + ETag: + schema: {type: string, pattern: '^"[1-9][0-9]*"$'} + Location: + schema: {type: string, format: uri-reference} + RequestId: + schema: {type: string} + NoStore: + schema: {type: string, const: 'private, no-store'} + responses: + Unauthorized: + description: Authentication is missing or invalid. + content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}} + Forbidden: + description: The caller lacks permission or professional authority. + content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}} + NotFound: + description: Resource not found in the active tenant. + content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}} + Conflict: + description: State conflict, uniqueness conflict, or possible duplicate. + content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}} + PreconditionFailed: + description: If-Match does not match the current version. + content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}} + ValidationError: + description: Request validation failed. + content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}} + schemas: + Uuid: + type: string + format: uuid + PatientStatus: + type: string + enum: [active, inactive, deceased, archived] + PractitionerStatus: + type: string + enum: [active, suspended, inactive] + EncounterStatus: + type: string + enum: [planned, in_progress, finished, cancelled, entered_in_error] + AppointmentStatus: + type: string + enum: [booked, confirmed, checked_in, fulfilled, cancelled, no_show, entered_in_error] + ClinicalStatus: + type: string + enum: [active, inactive, resolved, entered_in_error] + VerificationStatus: + type: string + enum: [unconfirmed, provisional, differential, confirmed, refuted, entered_in_error] + ConditionCategory: + type: string + enum: [problem_list, encounter_diagnosis, health_concern] + CodeableConcept: + type: object + additionalProperties: false + required: [system, code, display] + properties: + system: {type: string, format: uri, maxLength: 500} + code: {type: string, minLength: 1, maxLength: 100} + display: {type: string, minLength: 1, maxLength: 300} + text: {type: [string, 'null'], maxLength: 300} + AdministrativeGender: + type: string + enum: [female, male, other, unknown] + description: Administrative classification only; not a clinical assertion. + HumanName: + type: object + additionalProperties: false + required: [given, family] + properties: + prefix: {type: [string, 'null'], maxLength: 40} + given: + type: array + minItems: 1 + maxItems: 5 + items: {type: string, minLength: 1, maxLength: 120} + family: {type: string, minLength: 1, maxLength: 120} + suffix: {type: [string, 'null'], maxLength: 40} + ContactPoint: + type: object + additionalProperties: false + required: [system, value, use] + properties: + system: {type: string, enum: [phone, email]} + value: {type: string, minLength: 3, maxLength: 254} + use: {type: string, enum: [home, work, mobile, temporary]} + isPrimary: {type: boolean, default: false} + Address: + type: object + additionalProperties: false + required: [use, line1, city, countryCode] + properties: + use: {type: string, enum: [home, work, temporary]} + line1: {type: string, minLength: 1, maxLength: 200} + line2: {type: [string, 'null'], maxLength: 200} + city: {type: string, minLength: 1, maxLength: 120} + region: {type: [string, 'null'], maxLength: 120} + postalCode: {type: [string, 'null'], maxLength: 32} + countryCode: {type: string, pattern: '^[A-Z]{2}$'} + isPrimary: {type: boolean, default: false} + PatientCreate: + type: object + additionalProperties: false + required: [name, birthDate] + properties: + patientNumber: + type: string + maxLength: 64 + description: Optional client-supplied number; generated when omitted. + name: {$ref: '#/components/schemas/HumanName'} + birthDate: {type: string, format: date} + administrativeGender: {$ref: '#/components/schemas/AdministrativeGender'} + sexAtBirth: {type: [string, 'null'], maxLength: 80} + genderIdentity: {type: [string, 'null'], maxLength: 120} + contacts: + type: array + maxItems: 10 + items: {$ref: '#/components/schemas/ContactPoint'} + addresses: + type: array + maxItems: 10 + items: {$ref: '#/components/schemas/Address'} + PatientPatch: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {$ref: '#/components/schemas/HumanName'} + birthDate: {type: string, format: date} + administrativeGender: {$ref: '#/components/schemas/AdministrativeGender'} + sexAtBirth: {type: [string, 'null'], maxLength: 80} + genderIdentity: {type: [string, 'null'], maxLength: 120} + contacts: + type: array + maxItems: 10 + items: {$ref: '#/components/schemas/ContactPoint'} + addresses: + type: array + maxItems: 10 + items: {$ref: '#/components/schemas/Address'} + Patient: + allOf: + - $ref: '#/components/schemas/PatientCreate' + - type: object + required: [id, organizationId, patientNumber, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + patientNumber: {type: string} + status: {$ref: '#/components/schemas/PatientStatus'} + archivedAt: {type: [string, 'null'], format: date-time} + version: {type: integer, minimum: 1} + createdAt: {type: string, format: date-time} + updatedAt: {type: string, format: date-time} + PatientSummary: + type: object + additionalProperties: false + required: [id, patientNumber, name, birthDate, status] + properties: + id: {$ref: '#/components/schemas/Uuid'} + patientNumber: {type: string} + name: {$ref: '#/components/schemas/HumanName'} + birthDate: {type: string, format: date} + status: {$ref: '#/components/schemas/PatientStatus'} + PatientResponse: + type: object + additionalProperties: false + required: [data] + properties: {data: {$ref: '#/components/schemas/Patient'}} + PatientListResponse: + type: object + additionalProperties: false + required: [data, page] + properties: + data: {type: array, items: {$ref: '#/components/schemas/PatientSummary'}} + page: {$ref: '#/components/schemas/CursorPage'} + PractitionerCreate: + type: object + additionalProperties: false + required: [userId, professionalProfileId, specialtyCode] + properties: + userId: {$ref: '#/components/schemas/Uuid'} + professionalProfileId: {$ref: '#/components/schemas/Uuid'} + specialtyCode: {type: string, minLength: 1, maxLength: 64} + PractitionerPatch: + type: object + additionalProperties: false + minProperties: 1 + properties: + specialtyCode: {type: string, minLength: 1, maxLength: 64} + status: {$ref: '#/components/schemas/PractitionerStatus'} + Practitioner: + allOf: + - $ref: '#/components/schemas/PractitionerCreate' + - type: object + required: [id, organizationId, status, credentialState, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/PractitionerStatus'} + credentialState: + type: string + enum: [verified, expiring, expired, incomplete, suspended] + readOnly: true + version: {type: integer, minimum: 1} + createdAt: {type: string, format: date-time} + updatedAt: {type: string, format: date-time} + PractitionerResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Practitioner'}} + PractitionerListResponse: + type: object + required: [data, page] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Practitioner'}} + page: {$ref: '#/components/schemas/CursorPage'} + EncounterCreate: + type: object + additionalProperties: false + required: [patientId, practitionerId, encounterType] + properties: + patientId: {$ref: '#/components/schemas/Uuid'} + practitionerId: {$ref: '#/components/schemas/Uuid'} + appointmentId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + encounterType: {type: string, enum: [ambulatory, emergency, inpatient, virtual, home]} + scheduledStart: {type: [string, 'null'], format: date-time} + reasonForVisit: {type: [string, 'null'], maxLength: 500} + EncounterPatch: + type: object + additionalProperties: false + minProperties: 1 + properties: + practitionerId: {$ref: '#/components/schemas/Uuid'} + appointmentId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + encounterType: {type: string, enum: [ambulatory, emergency, inpatient, virtual, home]} + scheduledStart: {type: [string, 'null'], format: date-time} + reasonForVisit: {type: [string, 'null'], maxLength: 500} + Encounter: + allOf: + - $ref: '#/components/schemas/EncounterCreate' + - type: object + required: [id, organizationId, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EncounterStatus'} + startedAt: {type: [string, 'null'], format: date-time} + endedAt: {type: [string, 'null'], format: date-time} + cancellationReason: {type: [string, 'null'], maxLength: 500} + version: {type: integer, minimum: 1} + createdAt: {type: string, format: date-time} + updatedAt: {type: string, format: date-time} + EncounterResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Encounter'}} + EncounterListResponse: + type: object + required: [data, page] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Encounter'}} + page: {$ref: '#/components/schemas/CursorPage'} + LocationCreate: + type: object + additionalProperties: false + required: [name, type, timezone, status] + properties: + name: {type: string, minLength: 1, maxLength: 160} + type: {type: string, enum: [clinic, hospital, office, virtual]} + timezone: {type: string, minLength: 1, maxLength: 64} + status: {type: string, enum: [active, suspended, inactive]} + address: {oneOf: [{$ref: '#/components/schemas/Address'}, {type: 'null'}]} + LocationPatch: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 160} + timezone: {type: string, minLength: 1, maxLength: 64} + status: {type: string, enum: [active, suspended, inactive]} + address: {oneOf: [{$ref: '#/components/schemas/Address'}, {type: 'null'}]} + HealthcareLocation: + allOf: + - $ref: '#/components/schemas/LocationCreate' + - type: object + required: [id, organizationId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + createdAt: {type: string, format: date-time} + updatedAt: {type: string, format: date-time} + LocationResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/HealthcareLocation'}} + LocationListResponse: + type: object + required: [data, page] + properties: + data: {type: array, items: {$ref: '#/components/schemas/HealthcareLocation'}} + page: {$ref: '#/components/schemas/CursorPage'} + AppointmentCreate: + type: object + additionalProperties: false + required: [patientId, practitionerId, locationId, appointmentType, startsAt, endsAt] + properties: + patientId: {$ref: '#/components/schemas/Uuid'} + practitionerId: {$ref: '#/components/schemas/Uuid'} + locationId: {$ref: '#/components/schemas/Uuid'} + appointmentType: {type: string, minLength: 1, maxLength: 64} + startsAt: {type: string, format: date-time} + endsAt: {type: string, format: date-time} + reason: {type: [string, 'null'], maxLength: 500} + AppointmentPatch: + type: object + additionalProperties: false + minProperties: 1 + properties: + practitionerId: {$ref: '#/components/schemas/Uuid'} + locationId: {$ref: '#/components/schemas/Uuid'} + appointmentType: {type: string, minLength: 1, maxLength: 64} + startsAt: {type: string, format: date-time} + endsAt: {type: string, format: date-time} + reason: {type: [string, 'null'], maxLength: 500} + Appointment: + allOf: + - $ref: '#/components/schemas/AppointmentCreate' + - type: object + required: [id, organizationId, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/AppointmentStatus'} + encounterId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + cancellationReason: {type: [string, 'null'], maxLength: 500} + version: {type: integer, minimum: 1} + createdAt: {type: string, format: date-time} + updatedAt: {type: string, format: date-time} + AppointmentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Appointment'}} + AppointmentListResponse: + type: object + required: [data, page] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Appointment'}} + page: {$ref: '#/components/schemas/CursorPage'} + AllergyCreate: + type: object + additionalProperties: false + required: [substance, category, criticality, recordedByPractitionerId] + properties: + substance: {$ref: '#/components/schemas/CodeableConcept'} + category: {type: string, enum: [food, medication, environment, biologic]} + criticality: {type: string, enum: [low, high, unable_to_assess]} + verificationStatus: {$ref: '#/components/schemas/VerificationStatus'} + reaction: {type: [string, 'null'], maxLength: 500} + recordedByPractitionerId: {$ref: '#/components/schemas/Uuid'} + AllergyPatch: + type: object + additionalProperties: false + minProperties: 1 + properties: + clinicalStatus: {$ref: '#/components/schemas/ClinicalStatus'} + verificationStatus: {$ref: '#/components/schemas/VerificationStatus'} + criticality: {type: string, enum: [low, high, unable_to_assess]} + reaction: {type: [string, 'null'], maxLength: 500} + Allergy: + allOf: + - $ref: '#/components/schemas/AllergyCreate' + - type: object + required: [id, organizationId, patientId, clinicalStatus, version, recordedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + patientId: {$ref: '#/components/schemas/Uuid'} + clinicalStatus: {$ref: '#/components/schemas/ClinicalStatus'} + version: {type: integer, minimum: 1} + recordedAt: {type: string, format: date-time} + AllergyResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Allergy'}} + AllergyListResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/Allergy'}}} + ConditionCreate: + type: object + additionalProperties: false + required: [code, category, recordedByPractitionerId] + properties: + code: {$ref: '#/components/schemas/CodeableConcept'} + category: {$ref: '#/components/schemas/ConditionCategory'} + encounterId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + onsetAt: {type: [string, 'null'], format: date-time} + note: {type: [string, 'null'], maxLength: 1000} + recordedByPractitionerId: {$ref: '#/components/schemas/Uuid'} + ConditionPatch: + type: object + additionalProperties: false + minProperties: 1 + properties: + clinicalStatus: {$ref: '#/components/schemas/ClinicalStatus'} + verificationStatus: {$ref: '#/components/schemas/VerificationStatus'} + abatementAt: {type: [string, 'null'], format: date-time} + note: {type: [string, 'null'], maxLength: 1000} + Condition: + allOf: + - $ref: '#/components/schemas/ConditionCreate' + - type: object + required: [id, organizationId, patientId, clinicalStatus, verificationStatus, version, recordedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + patientId: {$ref: '#/components/schemas/Uuid'} + clinicalStatus: {$ref: '#/components/schemas/ClinicalStatus'} + verificationStatus: {$ref: '#/components/schemas/VerificationStatus'} + abatementAt: {type: [string, 'null'], format: date-time} + version: {type: integer, minimum: 1} + recordedAt: {type: string, format: date-time} + ConditionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Condition'}} + ConditionListResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/Condition'}}} + MedicationCreate: + type: object + additionalProperties: false + required: [medication, source, recordedByPractitionerId] + properties: + medication: {$ref: '#/components/schemas/CodeableConcept'} + source: {type: string, enum: [patient_reported, external_record, clinician_recorded]} + doseText: {type: [string, 'null'], maxLength: 300} + route: {type: [string, 'null'], maxLength: 120} + frequency: {type: [string, 'null'], maxLength: 120} + startedAt: {type: [string, 'null'], format: date-time} + recordedByPractitionerId: {$ref: '#/components/schemas/Uuid'} + MedicationPatch: + type: object + additionalProperties: false + minProperties: 1 + properties: + status: {type: string, enum: [active, completed, stopped, entered_in_error]} + doseText: {type: [string, 'null'], maxLength: 300} + route: {type: [string, 'null'], maxLength: 120} + frequency: {type: [string, 'null'], maxLength: 120} + endedAt: {type: [string, 'null'], format: date-time} + Medication: + allOf: + - $ref: '#/components/schemas/MedicationCreate' + - type: object + required: [id, organizationId, patientId, status, version, recordedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + patientId: {$ref: '#/components/schemas/Uuid'} + status: {type: string, enum: [active, completed, stopped, entered_in_error]} + endedAt: {type: [string, 'null'], format: date-time} + version: {type: integer, minimum: 1} + recordedAt: {type: string, format: date-time} + MedicationResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Medication'}} + MedicationListResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/Medication'}}} + ClinicalRecordCreate: + type: object + additionalProperties: false + required: [recordType, sensitivity, authorPractitionerId, content] + properties: + recordType: {type: string, enum: [progress_note, consultation_note, discharge_summary, procedure_note]} + sensitivity: {type: string, enum: [normal, restricted, highly_restricted]} + authorPractitionerId: {$ref: '#/components/schemas/Uuid'} + content: {type: string, minLength: 1, maxLength: 100000} + contentFormat: {type: string, enum: [text_markdown, structured_json], default: text_markdown} + ClinicalRecordPatch: + type: object + additionalProperties: false + minProperties: 1 + properties: + content: {type: string, minLength: 1, maxLength: 100000} + sensitivity: {type: string, enum: [normal, restricted, highly_restricted]} + ClinicalRecord: + type: object + additionalProperties: false + required: [id, organizationId, patientId, encounterId, recordType, sensitivity, status, authorPractitionerId, currentVersion, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + patientId: {$ref: '#/components/schemas/Uuid'} + encounterId: {$ref: '#/components/schemas/Uuid'} + recordType: {type: string} + sensitivity: {type: string, enum: [normal, restricted, highly_restricted]} + status: {type: string, enum: [draft, signed, amended, entered_in_error]} + authorPractitionerId: {$ref: '#/components/schemas/Uuid'} + signedByPractitionerId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + signedAt: {type: [string, 'null'], format: date-time} + currentVersion: {$ref: '#/components/schemas/ClinicalRecordVersion'} + version: {type: integer, minimum: 1} + createdAt: {type: string, format: date-time} + updatedAt: {type: string, format: date-time} + ClinicalRecordVersion: + type: object + additionalProperties: false + required: [id, versionNumber, content, contentFormat, contentHash, createdByPractitionerId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + content: {type: string, maxLength: 100000} + contentFormat: {type: string, enum: [text_markdown, structured_json]} + contentHash: {type: string, pattern: '^[a-f0-9]{64}$'} + createdByPractitionerId: {$ref: '#/components/schemas/Uuid'} + createdAt: {type: string, format: date-time} + ClinicalRecordResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/ClinicalRecord'}} + ClinicalRecordSummary: + type: object + required: [id, recordType, sensitivity, status, authorPractitionerId, version, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + recordType: {type: string} + sensitivity: {type: string} + status: {type: string} + authorPractitionerId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer} + updatedAt: {type: string, format: date-time} + ClinicalRecordListResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/ClinicalRecordSummary'}}} + SignClinicalRecordCommand: + type: object + additionalProperties: false + required: [practitionerId, attestation] + properties: + practitionerId: {$ref: '#/components/schemas/Uuid'} + attestation: {type: string, minLength: 10, maxLength: 500} + AmendClinicalRecordCommand: + type: object + additionalProperties: false + required: [practitionerId, amendmentType, reason, content] + properties: + practitionerId: {$ref: '#/components/schemas/Uuid'} + amendmentType: {type: string, enum: [correction, addendum, clarification]} + reason: {type: string, minLength: 3, maxLength: 1000} + content: {type: string, minLength: 1, maxLength: 100000} + ClinicalRecordHistoryResponse: + type: object + required: [recordId, versions, amendments] + properties: + recordId: {$ref: '#/components/schemas/Uuid'} + versions: {type: array, items: {$ref: '#/components/schemas/ClinicalRecordVersion'}} + amendments: + type: array + items: + type: object + required: [id, sourceVersionId, resultVersionId, practitionerId, amendmentType, reason, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + sourceVersionId: {$ref: '#/components/schemas/Uuid'} + resultVersionId: {$ref: '#/components/schemas/Uuid'} + practitionerId: {$ref: '#/components/schemas/Uuid'} + amendmentType: {type: string, enum: [correction, addendum, clarification]} + reason: {type: string} + createdAt: {type: string, format: date-time} + StartEncounterCommand: + type: object + additionalProperties: false + properties: + startedAt: {type: string, format: date-time} + FinishEncounterCommand: + type: object + additionalProperties: false + properties: + endedAt: {type: string, format: date-time} + ReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 500} + OptionalReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 500} + CursorPage: + type: object + additionalProperties: false + required: [hasMore] + properties: + nextCursor: {type: [string, 'null']} + hasMore: {type: boolean} + FieldError: + type: object + additionalProperties: false + required: [field, code, message] + properties: + field: {type: string} + code: {type: string} + message: {type: string} + Problem: + type: object + additionalProperties: false + 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} + code: {type: string} + requestId: {type: string} + errors: {type: array, items: {$ref: '#/components/schemas/FieldError'}} diff --git a/mermaid-diagram (1).png b/mermaid-diagram (1).png new file mode 100644 index 0000000..c98f319 Binary files /dev/null and b/mermaid-diagram (1).png differ diff --git a/mermaid-diagram.png b/mermaid-diagram.png new file mode 100644 index 0000000..5b5a860 Binary files /dev/null and b/mermaid-diagram.png differ diff --git a/professional-platform-openapi_1.yaml b/professional-platform-openapi_1.yaml new file mode 100644 index 0000000..f3f6dd3 --- /dev/null +++ b/professional-platform-openapi_1.yaml @@ -0,0 +1,5041 @@ +openapi: 3.1.0 +info: + title: Professional Management Platform API + version: 1.0.0-milestone.4 + summary: Platform access, engineering projects, project collaboration, and task management. + description: | + Executable API contract for Milestones 1 through 4 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 + - name: Engineering Projects + - name: Engineering Project Members + - name: Engineering Tasks + +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' + '404': + $ref: '#/components/responses/NotFound' + '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 + description: | + Rejected when the member is the last active organization Owner or manages any + active engineering project, has active project participation, or is assigned open + engineering tasks. Those responsibilities must be reassigned or ended first. + 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' + '404': + $ref: '#/components/responses/NotFound' + '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' + + /engineering/projects: + get: + tags: [Engineering Projects] + operationId: listEngineeringProjects + summary: List engineering projects + description: | + Archived projects are excluded unless `status=archived` is requested explicitly. + Permission scope is enforced in the query: `assigned` resolves through active project + membership or the project-manager pointer; `organization` resolves across the tenant. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: clientId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectStatus' + - name: discipline + in: query + schema: + $ref: '#/components/schemas/EngineeringDiscipline' + - name: projectManagerUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: q + in: query + description: Case-insensitive search across project number, project name, and client name. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Supported deterministic sort. Null date values are always placed last. + schema: + type: string + enum: + - projectNumber + - -projectNumber + - name + - -name + - startDate + - -startDate + - expectedCompletionDate + - -expectedCompletionDate + - createdAt + - -createdAt + default: -createdAt + responses: + '200': + description: Engineering projects returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectCollectionResponse' + '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 Projects] + operationId: createEngineeringProject + summary: Create an engineering project in draft status + description: | + `projectNumber` is immutable and unique case-insensitively within the organization. + The referenced client must be active. A supplied project manager must have an active + membership in the same organization. `projectManagerUserId` is the sole project-manager + authority and is not duplicated as a project-member role. + x-required-profession: engineering + x-required-permissions: [engineering.projects.create] + x-audit-action: engineering.projects.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectRequest' + responses: + '201': + description: Engineering project 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/EngineeringProjectResponse' + '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/projects/{projectId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Projects] + operationId: getEngineeringProject + summary: Get an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Engineering project returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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 Projects] + operationId: updateEngineeringProject + summary: Update editable engineering project fields + description: | + `projectNumber`, `status`, completion fields, and archive fields are not patchable. + `clientId` may change only while the project is `draft` and has no dependent records. + Changing `projectManagerUserId` changes assigned-scope access and is audited. It does + not create a duplicate `project_manager` project-member role. Open tasks assigned to + the outgoing manager must first be reassigned unless that user remains an active member. + x-required-profession: engineering + x-required-permissions: [engineering.projects.update] + x-audit-action: engineering.projects.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectRequest' + responses: + '200': + description: Engineering project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/activate: + post: + tags: [Engineering Projects] + operationId: activateEngineeringProject + summary: Activate a draft engineering project + description: | + Transition: `draft → active`. The client and project manager must both be active. + When `startDate` is absent from both the project and request, the server uses the + current date in the organization's configured time zone. + x-required-profession: engineering + x-required-permissions: [engineering.projects.activate] + x-audit-action: engineering.projects.activate + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEngineeringProjectRequest' + responses: + '200': + description: Engineering project activated or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/close: + post: + tags: [Engineering Projects] + operationId: closeEngineeringProject + summary: Close an active engineering project + description: | + Transition: `active → closed`. When `completedDate` is omitted, the server uses + the current date in the organization's configured time zone. The completed date + cannot precede the project start date. Every task must already be `completed` or + `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.close] + x-audit-action: engineering.projects.close + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CloseEngineeringProjectRequest' + responses: + '200': + description: Engineering project closed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/archive: + post: + tags: [Engineering Projects] + operationId: archiveEngineeringProject + summary: Archive a draft or closed engineering project + description: | + Transition: `draft|closed → archived`. Active projects must be closed first. + The prior status is retained so restore is deterministic. Related records and + audit history are never physically deleted. Every task must already be `completed` + or `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.archive + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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 project archived or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/restore: + post: + tags: [Engineering Projects] + operationId: restoreEngineeringProject + summary: Restore an archived engineering project + description: | + Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`. + Restore never reactivates a project implicitly. The referenced client must be active. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.restore + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering project restored or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/summary: + get: + tags: [Engineering Projects] + operationId: getEngineeringProjectSummary + summary: Get the engineering project dashboard summary + description: | + Returns a purpose-built read model. Counts are permission-filtered and include + only records visible to the caller. Modules not yet enabled return zero counts, + not omitted fields, preserving the response shape. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + responses: + '200': + description: Project summary returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectDashboardResponse' + '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' + + /engineering/projects/{projectId}/members: + get: + tags: [Engineering Project Members] + operationId: listEngineeringProjectMembers + summary: List temporal project-member records + description: By default, only active participation records are returned. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + - name: projectRole + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + - name: userId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: sort + in: query + schema: + type: string + enum: [joinedAt, -joinedAt, name, -name] + default: name + responses: + '200': + description: Project-member records returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberCollectionResponse' + '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 Project Members] + operationId: addEngineeringProjectMember + summary: Add an active organization member to a project + description: | + The project must be `draft` or `active`. The user must have an active organization + membership. Rejoining after departure creates a new temporal row. Only one active + row may exist for a user in a project. Project-manager assignment is controlled by + `projectManagerUserId`, not by this endpoint. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.add + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectMemberRequest' + responses: + '201': + description: Project member added. + 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/EngineeringProjectMemberResponse' + '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/projects/{projectId}/members/{memberId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ProjectMemberId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Project Members] + operationId: getEngineeringProjectMember + summary: Get a project-member record + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Project-member record returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: updateEngineeringProjectMember + summary: Change the participation role of an active project member + description: Only `projectRole` is patchable in v1. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectMemberRequest' + responses: + '200': + description: Participation role updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: endEngineeringProjectMembership + summary: End a user's project participation + description: | + Sets `leftAt`; it never deletes history. Repeating the command with the same + idempotency key replays the original 204 response. Open tasks assigned to the + user must be reassigned or unassigned first. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.remove + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Project participation ended or idempotent result replayed. + 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/tasks: + get: + tags: [Engineering Tasks] + operationId: listEngineeringTasks + summary: List engineering tasks + description: | + Permission scope is enforced per task. Assigned scope resolves when the caller is + the task assignee, an active member of the parent project, or its project manager. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskStatus' + - name: priority + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskPriority' + - name: assignedToUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: assignmentStatus + in: query + schema: + type: string + enum: [assigned, unassigned, any] + default: any + - name: dueBefore + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: dueAfter + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: q + in: query + description: Case-insensitive search across task title and description. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Null due dates are always placed last. + schema: + type: string + enum: [createdAt, -createdAt, dueAt, -dueAt, priority, -priority] + default: -createdAt + responses: + '200': + description: Engineering tasks returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskCollectionResponse' + '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 Tasks] + operationId: createEngineeringTask + summary: Create a task in todo status + description: | + The project must be `draft` or `active`. A supplied assignee must be the project + manager or an active project member and must retain an active organization membership. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringTaskRequest' + responses: + '201': + description: Engineering task 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/EngineeringTaskResponse' + '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/tasks/{taskId}: + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Tasks] + operationId: getEngineeringTask + summary: Get an engineering task + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + responses: + '200': + description: Engineering task returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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 Tasks] + operationId: updateEngineeringTask + summary: Update mutable task fields + description: | + `projectId`, status, creator, and terminal metadata are immutable through PATCH. + Assignment changes revalidate active organization and project participation. + Completed and cancelled tasks must be reopened before they can be edited. The parent + project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringTaskRequest' + responses: + '200': + description: Engineering task updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/start: + post: + tags: [Engineering Tasks] + operationId: startEngineeringTask + summary: Start a todo task + description: 'Transition: `todo → in_progress`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.start + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering task started or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/complete: + post: + tags: [Engineering Tasks] + operationId: completeEngineeringTask + summary: Complete a todo or in-progress task + description: 'Transition: `todo|in_progress → completed`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.complete + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteEngineeringTaskRequest' + responses: + '200': + description: Engineering task completed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/reopen: + post: + tags: [Engineering Tasks] + operationId: reopenEngineeringTask + summary: Reopen a completed or cancelled task + description: | + Transition: `completed|cancelled → todo`. Completion and cancellation metadata + plus any prior start metadata are cleared, while their prior values remain available + through audit history. The parent project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.reopen + parameters: + - $ref: '#/components/parameters/TaskId' + - $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 task reopened or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/cancel: + post: + tags: [Engineering Tasks] + operationId: cancelEngineeringTask + summary: Cancel a todo or in-progress task + description: 'Transition: `todo|in_progress → cancelled`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.cancel + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CancelEngineeringTaskRequest' + responses: + '200': + description: Engineering task cancelled or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/batch/assign: + post: + tags: [Engineering Tasks] + operationId: batchAssignEngineeringTasks + summary: Assign multiple tasks + description: | + Every item carries its expected version and is independently tenant-, permission-, + scope-, project-, assignee-, and state-validated. Atomic mode rolls back all items + on any failure. Partial mode commits valid items and returns per-item failures. + Only `todo` and `in_progress` tasks may be assigned, and the assignee must be an + active participant or project manager for every affected project. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_assign + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchAssignEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/tasks/batch/complete: + post: + tags: [Engineering Tasks] + operationId: batchCompleteEngineeringTasks + summary: Complete multiple tasks + description: | + Every item carries its expected version and is independently authorized and + state-validated. Atomic and partial modes follow the same semantics as batch assign. + Only `todo` and `in_progress` tasks may be completed. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_complete + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCompleteEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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' + +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' + ProjectId: + name: projectId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + ProjectMemberId: + name: memberId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + TaskId: + name: taskId + 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] + EngineeringProjectRestorableStatus: + type: string + enum: [draft, closed] + EngineeringDiscipline: + type: string + enum: + - civil + - structural + - mechanical + - electrical + - geotechnical + - environmental + - transportation + - water_resources + - surveying + - multidisciplinary + - other + EngineeringProjectMemberRole: + type: string + enum: [engineer, designer, reviewer, inspector, viewer, contractor] + description: Project manager is intentionally excluded; `projectManagerUserId` is authoritative. + EngineeringProjectMemberStatus: + type: string + enum: [active, left] + description: Derived from whether `leftAt` is null. + EngineeringTaskStatus: + type: string + enum: [todo, in_progress, completed, cancelled] + EngineeringTaskPriority: + type: string + enum: [low, medium, high, urgent] + BatchExecutionMode: + type: string + enum: [atomic, partial] + + 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' + + EngineeringProject: + type: object + additionalProperties: false + required: + - id + - organizationId + - clientId + - projectNumber + - name + - description + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - archivedAt + - archivedByUserId + - archivedFromStatus + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + description: Immutable, organization-unique human project reference. + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + archivedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + archivedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + archivedFromStatus: + oneOf: + - $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + enum: [active, closed] + required: [status] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: closed + required: [status] + then: + properties: + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + enum: [draft, active] + required: [status] + then: + properties: + completedDate: + type: 'null' + - if: + properties: + status: + const: archived + required: [status] + then: + properties: + archivedAt: + $ref: '#/components/schemas/Timestamp' + archivedByUserId: + $ref: '#/components/schemas/Uuid' + archivedFromStatus: + $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + else: + properties: + archivedAt: + type: 'null' + archivedByUserId: + type: 'null' + archivedFromStatus: + type: 'null' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: closed + required: [status, archivedFromStatus] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: draft + required: [status, archivedFromStatus] + then: + properties: + completedDate: + type: 'null' + description: Expected and completed dates may not precede the start date. + CreateEngineeringProjectRequest: + type: object + additionalProperties: false + required: [clientId, projectNumber, name, discipline] + properties: + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: Expected completion date may not precede start date. + UpdateEngineeringProjectRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + clientId: + $ref: '#/components/schemas/Uuid' + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: The resulting dates and manager assignment must satisfy the project's current state rules. + ActivateEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + startDate: + $ref: '#/components/schemas/Date' + CloseEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + completedDate: + $ref: '#/components/schemas/Date' + reason: + type: string + maxLength: 500 + EngineeringProjectResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProject' + EngineeringProjectCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProject' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringProjectSummary: + type: object + additionalProperties: false + required: + - id + - clientId + - projectNumber + - name + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - version + - createdAt + - updatedAt + 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: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $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' + + EngineeringClientSummary: + type: object + additionalProperties: false + required: [id, clientType, displayName, legalName, status] + properties: + id: + $ref: '#/components/schemas/Uuid' + clientType: + $ref: '#/components/schemas/EngineeringClientType' + displayName: + type: string + legalName: + type: [string, 'null'] + status: + $ref: '#/components/schemas/EngineeringClientStatus' + EngineeringProjectActivitySummary: + type: object + additionalProperties: false + required: + - projectMemberCount + - phaseCount + - siteCount + - openTaskCount + - designCount + - designsUnderReviewCount + - inspectionCount + - upcomingInspectionCount + - documentCount + - lastActivityAt + properties: + projectMemberCount: + type: integer + minimum: 0 + description: Active participation rows; the separate project-manager pointer is not double-counted. + phaseCount: + type: integer + minimum: 0 + siteCount: + type: integer + minimum: 0 + openTaskCount: + type: integer + minimum: 0 + description: Tasks in todo or in-progress status. + designCount: + type: integer + minimum: 0 + designsUnderReviewCount: + type: integer + minimum: 0 + inspectionCount: + type: integer + minimum: 0 + upcomingInspectionCount: + type: integer + minimum: 0 + documentCount: + type: integer + minimum: 0 + lastActivityAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + EngineeringProjectDashboard: + type: object + additionalProperties: false + required: [project, client, projectManager, activity] + properties: + project: + $ref: '#/components/schemas/EngineeringProject' + client: + $ref: '#/components/schemas/EngineeringClientSummary' + projectManager: + oneOf: + - $ref: '#/components/schemas/UserSummary' + - type: 'null' + activity: + $ref: '#/components/schemas/EngineeringProjectActivitySummary' + EngineeringProjectDashboardResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectDashboard' + + EngineeringProjectMember: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - user + - projectRole + - status + - joinedAt + - leftAt + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + user: + $ref: '#/components/schemas/UserSummary' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + status: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + joinedAt: + $ref: '#/components/schemas/Timestamp' + leftAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: active + required: [status] + then: + properties: + leftAt: + type: 'null' + - if: + properties: + status: + const: left + required: [status] + then: + properties: + leftAt: + $ref: '#/components/schemas/Timestamp' + CreateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [userId, projectRole] + properties: + userId: + $ref: '#/components/schemas/Uuid' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + UpdateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [projectRole] + properties: + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + EngineeringProjectMemberResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectMember' + EngineeringProjectMemberCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProjectMember' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTask: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - title + - description + - status + - priority + - createdByUserId + - assignedToUserId + - dueAt + - startedAt + - startedByUserId + - completedAt + - completedByUserId + - cancelledAt + - cancelledByUserId + - cancellationReason + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + createdByUserId: + $ref: '#/components/schemas/Uuid' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + completedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + completedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancelledAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + cancelledByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancellationReason: + type: [string, 'null'] + maxLength: 500 + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: todo + required: [status] + then: + properties: + startedAt: {type: 'null'} + startedByUserId: {type: 'null'} + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: in_progress + required: [status] + then: + properties: + startedAt: + $ref: '#/components/schemas/Timestamp' + startedByUserId: + $ref: '#/components/schemas/Uuid' + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: completed + required: [status] + then: + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + completedByUserId: + $ref: '#/components/schemas/Uuid' + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: cancelled + required: [status] + then: + properties: + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: + $ref: '#/components/schemas/Timestamp' + cancelledByUserId: + $ref: '#/components/schemas/Uuid' + description: Terminal and start metadata are controlled exclusively by task commands. + CreateEngineeringTaskRequest: + type: object + additionalProperties: false + required: [projectId, title] + properties: + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + allOf: + - $ref: '#/components/schemas/EngineeringTaskPriority' + default: medium + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + UpdateEngineeringTaskRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + CompleteEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + description: A supplied completion time cannot be in the future or precede task creation. + CancelEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + reason: + type: string + maxLength: 500 + EngineeringTaskResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTask' + EngineeringTaskCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringTask' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTaskBatchItem: + type: object + additionalProperties: false + required: [id, version] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + BatchAssignEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, assigneeUserId, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + assigneeUserId: + $ref: '#/components/schemas/Uuid' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected even when their supplied versions differ. + BatchCompleteEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + completedAt: + $ref: '#/components/schemas/Timestamp' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected; completedAt follows the single-task completion rules. + EngineeringTaskBatchSuccess: + type: object + additionalProperties: false + required: [id, version, status, assignedToUserId] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + EngineeringTaskBatchFailure: + type: object + additionalProperties: false + required: [id, code, message, currentVersion] + properties: + id: + $ref: '#/components/schemas/Uuid' + code: + type: string + pattern: '^[A-Z][A-Z0-9_]+$' + message: + type: string + maxLength: 500 + currentVersion: + type: [integer, 'null'] + minimum: 1 + EngineeringTaskBatchResult: + type: object + additionalProperties: false + required: [mode, succeeded, failed] + properties: + mode: + $ref: '#/components/schemas/BatchExecutionMode' + succeeded: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchSuccess' + failed: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchFailure' + EngineeringTaskBatchResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTaskBatchResult' + +security: + - bearerAuth: [] diff --git a/professional-platform-openapi_10.yaml b/professional-platform-openapi_10.yaml new file mode 100644 index 0000000..fa295cc --- /dev/null +++ b/professional-platform-openapi_10.yaml @@ -0,0 +1,8778 @@ +openapi: 3.1.0 +info: + title: Professional Management Platform API + version: 1.0.0-milestone.10 + summary: Engineering delivery control with shared invoicing, payments, and financial provenance. + description: | + Executable API contract for Milestones 1 through 4 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 + - name: Engineering Projects + - name: Engineering Project Members + - name: Engineering Tasks + - name: Engineering Sites + - name: Documents + - name: Engineering Project Documents + - name: Engineering Designs + - name: Engineering Design Assignments + - name: Engineering Design Versions + - name: Engineering Design Reviews + - name: Engineering Inspections + - name: Engineering Inspection Documents + - name: Engineering Inspection Findings + - name: Engineering Inspection Follow-ups + - name: Engineering Specifications + - name: Engineering Specification Documents + - name: Engineering Project Phases + - name: Engineering Time Entries + - name: Engineering Project Budgets + - name: Billing Accounts + - name: Engineering Billing Links + - name: Invoices + - name: Invoice Items + - name: Payments + +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' + '404': + $ref: '#/components/responses/NotFound' + '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 + description: | + Rejected when the member is the last active organization Owner or manages any + active engineering project, has active project participation, or is assigned open + engineering tasks. Those responsibilities must be reassigned or ended first. + 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' + '404': + $ref: '#/components/responses/NotFound' + '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' + + /engineering/projects: + get: + tags: [Engineering Projects] + operationId: listEngineeringProjects + summary: List engineering projects + description: | + Archived projects are excluded unless `status=archived` is requested explicitly. + Permission scope is enforced in the query: `assigned` resolves through active project + membership or the project-manager pointer; `organization` resolves across the tenant. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: clientId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectStatus' + - name: discipline + in: query + schema: + $ref: '#/components/schemas/EngineeringDiscipline' + - name: projectManagerUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: q + in: query + description: Case-insensitive search across project number, project name, and client name. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Supported deterministic sort. Null date values are always placed last. + schema: + type: string + enum: + - projectNumber + - -projectNumber + - name + - -name + - startDate + - -startDate + - expectedCompletionDate + - -expectedCompletionDate + - createdAt + - -createdAt + default: -createdAt + responses: + '200': + description: Engineering projects returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectCollectionResponse' + '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 Projects] + operationId: createEngineeringProject + summary: Create an engineering project in draft status + description: | + `projectNumber` is immutable and unique case-insensitively within the organization. + The referenced client must be active. A supplied project manager must have an active + membership in the same organization. `projectManagerUserId` is the sole project-manager + authority and is not duplicated as a project-member role. + x-required-profession: engineering + x-required-permissions: [engineering.projects.create] + x-audit-action: engineering.projects.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectRequest' + responses: + '201': + description: Engineering project 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/EngineeringProjectResponse' + '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/projects/{projectId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Projects] + operationId: getEngineeringProject + summary: Get an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Engineering project returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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 Projects] + operationId: updateEngineeringProject + summary: Update editable engineering project fields + description: | + `projectNumber`, `status`, completion fields, and archive fields are not patchable. + `clientId` may change only while the project is `draft` and has no dependent records. + Changing `projectManagerUserId` changes assigned-scope access and is audited. It does + not create a duplicate `project_manager` project-member role. Open tasks assigned to + the outgoing manager must first be reassigned unless that user remains an active member. + x-required-profession: engineering + x-required-permissions: [engineering.projects.update] + x-audit-action: engineering.projects.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectRequest' + responses: + '200': + description: Engineering project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/activate: + post: + tags: [Engineering Projects] + operationId: activateEngineeringProject + summary: Activate a draft engineering project + description: | + Transition: `draft → active`. The client and project manager must both be active. + When `startDate` is absent from both the project and request, the server uses the + current date in the organization's configured time zone. + x-required-profession: engineering + x-required-permissions: [engineering.projects.activate] + x-audit-action: engineering.projects.activate + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEngineeringProjectRequest' + responses: + '200': + description: Engineering project activated or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/close: + post: + tags: [Engineering Projects] + operationId: closeEngineeringProject + summary: Close an active engineering project + description: | + Transition: `active → closed`. When `completedDate` is omitted, the server uses + the current date in the organization's configured time zone. The completed date + cannot precede the project start date. Every task must already be `completed` or + `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.close] + x-audit-action: engineering.projects.close + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CloseEngineeringProjectRequest' + responses: + '200': + description: Engineering project closed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/archive: + post: + tags: [Engineering Projects] + operationId: archiveEngineeringProject + summary: Archive a draft or closed engineering project + description: | + Transition: `draft|closed → archived`. Active projects must be closed first. + The prior status is retained so restore is deterministic. Related records and + audit history are never physically deleted. Every task must already be `completed` + or `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.archive + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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 project archived or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/restore: + post: + tags: [Engineering Projects] + operationId: restoreEngineeringProject + summary: Restore an archived engineering project + description: | + Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`. + Restore never reactivates a project implicitly. The referenced client must be active. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.restore + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering project restored or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/summary: + get: + tags: [Engineering Projects] + operationId: getEngineeringProjectSummary + summary: Get the engineering project dashboard summary + description: | + Returns a purpose-built read model. Counts are permission-filtered and include + only records visible to the caller. Modules not yet enabled return zero counts, + not omitted fields, preserving the response shape. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + responses: + '200': + description: Project summary returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectDashboardResponse' + '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' + + /engineering/projects/{projectId}/members: + get: + tags: [Engineering Project Members] + operationId: listEngineeringProjectMembers + summary: List temporal project-member records + description: By default, only active participation records are returned. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + - name: projectRole + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + - name: userId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: sort + in: query + schema: + type: string + enum: [joinedAt, -joinedAt, name, -name] + default: name + responses: + '200': + description: Project-member records returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberCollectionResponse' + '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 Project Members] + operationId: addEngineeringProjectMember + summary: Add an active organization member to a project + description: | + The project must be `draft` or `active`. The user must have an active organization + membership. Rejoining after departure creates a new temporal row. Only one active + row may exist for a user in a project. Project-manager assignment is controlled by + `projectManagerUserId`, not by this endpoint. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.add + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectMemberRequest' + responses: + '201': + description: Project member added. + 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/EngineeringProjectMemberResponse' + '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/projects/{projectId}/members/{memberId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ProjectMemberId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Project Members] + operationId: getEngineeringProjectMember + summary: Get a project-member record + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Project-member record returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: updateEngineeringProjectMember + summary: Change the participation role of an active project member + description: Only `projectRole` is patchable in v1. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectMemberRequest' + responses: + '200': + description: Participation role updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: endEngineeringProjectMembership + summary: End a user's project participation + description: | + Sets `leftAt`; it never deletes history. Repeating the command with the same + idempotency key replays the original 204 response. Open tasks assigned to the + user must be reassigned or unassigned first. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.remove + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Project participation ended or idempotent result replayed. + 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/tasks: + get: + tags: [Engineering Tasks] + operationId: listEngineeringTasks + summary: List engineering tasks + description: | + Permission scope is enforced per task. Assigned scope resolves when the caller is + the task assignee, an active member of the parent project, or its project manager. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskStatus' + - name: priority + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskPriority' + - name: assignedToUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: assignmentStatus + in: query + schema: + type: string + enum: [assigned, unassigned, any] + default: any + - name: dueBefore + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: dueAfter + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: q + in: query + description: Case-insensitive search across task title and description. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Null due dates are always placed last. + schema: + type: string + enum: [createdAt, -createdAt, dueAt, -dueAt, priority, -priority] + default: -createdAt + responses: + '200': + description: Engineering tasks returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskCollectionResponse' + '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 Tasks] + operationId: createEngineeringTask + summary: Create a task in todo status + description: | + The project must be `draft` or `active`. A supplied assignee must be the project + manager or an active project member and must retain an active organization membership. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringTaskRequest' + responses: + '201': + description: Engineering task 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/EngineeringTaskResponse' + '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/tasks/{taskId}: + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Tasks] + operationId: getEngineeringTask + summary: Get an engineering task + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + responses: + '200': + description: Engineering task returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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 Tasks] + operationId: updateEngineeringTask + summary: Update mutable task fields + description: | + `projectId`, status, creator, and terminal metadata are immutable through PATCH. + Assignment changes revalidate active organization and project participation. + Completed and cancelled tasks must be reopened before they can be edited. The parent + project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringTaskRequest' + responses: + '200': + description: Engineering task updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/start: + post: + tags: [Engineering Tasks] + operationId: startEngineeringTask + summary: Start a todo task + description: 'Transition: `todo → in_progress`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.start + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering task started or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/complete: + post: + tags: [Engineering Tasks] + operationId: completeEngineeringTask + summary: Complete a todo or in-progress task + description: 'Transition: `todo|in_progress → completed`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.complete + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteEngineeringTaskRequest' + responses: + '200': + description: Engineering task completed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/reopen: + post: + tags: [Engineering Tasks] + operationId: reopenEngineeringTask + summary: Reopen a completed or cancelled task + description: | + Transition: `completed|cancelled → todo`. Completion and cancellation metadata + plus any prior start metadata are cleared, while their prior values remain available + through audit history. The parent project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.reopen + parameters: + - $ref: '#/components/parameters/TaskId' + - $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 task reopened or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/cancel: + post: + tags: [Engineering Tasks] + operationId: cancelEngineeringTask + summary: Cancel a todo or in-progress task + description: 'Transition: `todo|in_progress → cancelled`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.cancel + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CancelEngineeringTaskRequest' + responses: + '200': + description: Engineering task cancelled or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/batch/assign: + post: + tags: [Engineering Tasks] + operationId: batchAssignEngineeringTasks + summary: Assign multiple tasks + description: | + Every item carries its expected version and is independently tenant-, permission-, + scope-, project-, assignee-, and state-validated. Atomic mode rolls back all items + on any failure. Partial mode commits valid items and returns per-item failures. + Only `todo` and `in_progress` tasks may be assigned, and the assignee must be an + active participant or project manager for every affected project. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_assign + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchAssignEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/tasks/batch/complete: + post: + tags: [Engineering Tasks] + operationId: batchCompleteEngineeringTasks + summary: Complete multiple tasks + description: | + Every item carries its expected version and is independently authorized and + state-validated. Atomic and partial modes follow the same semantics as batch assign. + Only `todo` and `in_progress` tasks may be completed. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_complete + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCompleteEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/sites: + get: + tags: [Engineering Sites] + operationId: listEngineeringSites + summary: List engineering sites across the active organization + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Sites visible to the caller. + headers: {X-Request-Id: {$ref: '#/components/headers/RequestId'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + + /engineering/projects/{projectId}/sites: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Sites] + operationId: listEngineeringProjectSites + summary: List sites for one project + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: Project sites. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Sites] + operationId: createEngineeringProjectSite + summary: Create a site within a project + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringSiteRequest'}}} + responses: + '201': + description: Site created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/sites/{siteId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SiteId' + get: + tags: [Engineering Sites] + operationId: getEngineeringSite + summary: Retrieve an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + responses: + '200': + description: Site. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Sites] + operationId: updateEngineeringSite + summary: Update an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringSiteRequest'}}} + responses: + '200': + description: Site updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents: + get: + tags: [Documents] + operationId: listDocuments + summary: List document metadata + description: Quarantined and infected versions are excluded unless the caller has documents.security_review. + x-required-permissions: [documents.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: classification + in: query + schema: {$ref: '#/components/schemas/DocumentClassification'} + - name: categoryId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Document metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCollectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + + /documents/upload-url: + post: + tags: [Documents] + operationId: createDocumentUploadUrl + summary: Initialize a single-part document upload + description: Creates quarantined document and version metadata, then returns a short-lived signed PUT URL. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: getDocument + summary: Retrieve document metadata + x-required-permissions: [documents.read] + responses: + '200': + description: Document metadata. No storage key or unsigned object URL is exposed. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Documents] + operationId: updateDocumentMetadata + summary: Update mutable document metadata + description: Classification cannot be weakened below the linked domain record's required classification. + x-required-permissions: [documents.manage] + x-audit-action: document.metadata_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateDocumentRequest'}}} + responses: + '200': + description: Document updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/complete-upload: + post: + tags: [Documents] + operationId: completeDocumentUpload + summary: Verify a single-part upload and enqueue malware inspection + description: Completion changes uploadStatus to completed and scanStatus to pending; it never makes the file downloadable. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteDocumentUploadRequest'}}} + responses: + '202': + description: Object verified and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: listDocumentVersions + summary: List immutable document versions + x-required-permissions: [documents.read] + responses: + '200': + description: Version metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Documents] + operationId: initializeNewDocumentVersion + summary: Initialize a new single-part version upload + description: The current version pointer changes only after upload verification and a clean scan. + x-required-permissions: [documents.upload] + x-audit-action: document.version_upload_initialized + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentVersionRequest'}}} + responses: + '201': + description: Version upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/download-url: + post: + tags: [Documents] + operationId: createDocumentDownloadUrl + summary: Create a short-lived download URL for a clean version + description: Infected, pending, failed, or quarantined versions are never downloadable. + x-required-permissions: [documents.download] + x-audit-action: document.download_authorized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateDocumentDownloadRequest'}}} + responses: + '200': + description: Short-lived download authorization. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentDownloadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /documents/multipart-uploads: + post: + tags: [Documents] + operationId: initializeMultipartDocumentUpload + summary: Initialize a multipart document upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Multipart upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeMultipartUploadResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/parts: + post: + tags: [Documents] + operationId: createMultipartPartUploadUrls + summary: Create signed URLs for selected multipart parts + x-required-permissions: [documents.upload] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsRequest'}}} + responses: + '200': + description: Signed part URLs. + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/complete: + post: + tags: [Documents] + operationId: completeMultipartDocumentUpload + summary: Assemble multipart upload and enqueue malware inspection + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteMultipartUploadRequest'}}} + responses: + '202': + description: Multipart object assembled and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}: + delete: + tags: [Documents] + operationId: abortMultipartDocumentUpload + summary: Abort an unfinished multipart upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_aborted + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Upload aborted; staged object parts are scheduled for cleanup.} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Documents] + operationId: listEngineeringProjectDocuments + summary: List active project-document links + x-required-profession: engineering + x-required-permissions: [engineering.documents.read] + responses: + '200': + description: Project documents filtered by document authorization and scan state. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Documents] + operationId: linkEngineeringProjectDocument + summary: Link a clean shared document to a project + description: Pending, failed, or infected versions cannot be linked as the active project document. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringProjectDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/project-documents/{documentLinkId}: + delete: + tags: [Engineering Project Documents] + operationId: unlinkEngineeringProjectDocument + summary: Temporally unlink a document from a project + description: Sets unlinkedAt; it does not delete the shared document or its versions. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/designs: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Designs] + operationId: listEngineeringProjectDesigns + summary: List designs for a project + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringDesignStatus'} + - name: discipline + in: query + schema: {type: string, minLength: 1, maxLength: 100} + responses: + '200': + description: Project designs. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Designs] + operationId: createEngineeringDesign + summary: Create a draft design + description: Atomically creates version 1 and the required owner/preparer assignments. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignRequest'}}} + responses: + '201': + description: Draft design and initial version created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Designs] + operationId: getEngineeringDesign + summary: Retrieve a design + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Designs] + operationId: updateEngineeringDesign + summary: Update editable design metadata + description: Only draft or changes_requested designs are editable; status changes use commands. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringDesignRequest'}}} + responses: + '200': + description: Design updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/submit-review: + post: + tags: [Engineering Designs] + operationId: submitEngineeringDesignForReview + summary: Submit the current version for review + description: Requires a clean primary drawing and at least one active reviewer assignment. + x-required-profession: engineering + x-required-permissions: [engineering.designs.submit] + x-audit-action: engineering.design.submitted_for_review + parameters: &designCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalDesignReasonCommand'}}} + responses: &designCommandResponses + '200': + description: Design transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/request-changes: + post: + tags: [Engineering Designs] + operationId: requestEngineeringDesignChanges + summary: Return a design to changes requested + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.changes_requested + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/approve: + post: + tags: [Engineering Designs] + operationId: approveEngineeringDesign + summary: Professionally approve the current design version + description: Revalidates current credential, discipline, scope-of-practice, and approval policy. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.approved + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ApproveDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/reject: + post: + tags: [Engineering Designs] + operationId: rejectEngineeringDesign + summary: Reject the current design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.rejected + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/revise: + post: + tags: [Engineering Designs] + operationId: reviseRejectedEngineeringDesign + summary: Reopen a rejected design as draft with a new version + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.revised + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/cancel: + post: + tags: [Engineering Designs] + operationId: cancelEngineeringDesign + summary: Cancel a draft design + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.cancelled + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/withdraw: + post: + tags: [Engineering Designs] + operationId: withdrawEngineeringDesign + summary: Withdraw a design from review + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.withdrawn + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/supersede: + post: + tags: [Engineering Designs] + operationId: supersedeEngineeringDesign + summary: Supersede an approved design + description: Requires the replacement to be a different approved design in the same project and discipline. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.superseded + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SupersedeDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/assignments: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Assignments] + operationId: listEngineeringDesignAssignments + summary: List current and historical design assignments + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - name: activeOnly + in: query + schema: {type: boolean, default: true} + responses: + '200': + description: Assignments. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Assignments] + operationId: assignEngineeringDesignParticipant + summary: Assign a member to a design role + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AssignEngineeringDesignRequest'}}} + responses: + '201': + description: Assignment created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/unassign: + post: + tags: [Engineering Design Assignments] + operationId: unassignEngineeringDesignParticipant + summary: End an active design assignment + description: Sets unassignedAt. The final active owner or required reviewer cannot be removed while workflow depends on that role. + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_ended + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/UnassignEngineeringDesignRequest'}}} + responses: + '204': {description: Assignment ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersions + summary: List immutable logical design versions + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design versions. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: createEngineeringDesignVersion + summary: Create the next logical design version + description: Allowed only in draft or changes_requested. Version numbers are allocated transactionally. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignVersionRequest'}}} + responses: + '201': + description: Design version created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/design-versions/{designVersionId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignVersionId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersionDocuments + summary: List documents linked to a design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Version documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: linkEngineeringDesignVersionDocument + summary: Link a clean document to an editable design version + description: Only scan-clean documents may be linked; a version may have only one active primary_drawing. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringDesignVersionDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/design-version-documents/{documentLinkId}: + delete: + tags: [Engineering Design Versions] + operationId: unlinkEngineeringDesignVersionDocument + summary: Temporally unlink a document from an editable design version + description: Submitted, approved, rejected, or superseded version evidence cannot be unlinked. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/reviews: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Reviews] + operationId: listEngineeringDesignReviews + summary: List review recommendations + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Reviews. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Reviews] + operationId: recordEngineeringDesignReview + summary: Record a reviewer recommendation for the submitted version + description: A recommendation is immutable and never directly changes design status. + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.review_recorded + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignReviewRequest'}}} + responses: + '201': + description: Review recommendation recorded. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/inspections: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Inspections] + operationId: listEngineeringProjectInspections + summary: List inspections for a project + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: siteId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringInspectionStatus'} + responses: + '200': + description: Project inspections. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspections] + operationId: createEngineeringInspection + summary: Create a draft inspection + description: Site and inspector must belong to the same project and active organization context. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionRequest'}}} + responses: + '201': + description: Draft inspection created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspections] + operationId: getEngineeringInspection + summary: Retrieve an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Inspections] + operationId: updateEngineeringInspection + summary: Update editable inspection metadata + description: Draft and scheduled inspections are editable; lifecycle fields use commands. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringInspectionRequest'}}} + responses: + '200': + description: Inspection updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}/schedule: + post: + tags: [Engineering Inspections] + operationId: scheduleEngineeringInspection + summary: Schedule a draft inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.scheduled + parameters: &inspectionCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ScheduleInspectionCommand'}}} + responses: &inspectionCommandResponses + '200': + description: Inspection transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}/start: + post: + tags: [Engineering Inspections] + operationId: startEngineeringInspection + summary: Start a scheduled inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.started + parameters: *inspectionCommandParameters + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/StartInspectionCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/complete: + post: + tags: [Engineering Inspections] + operationId: completeEngineeringInspection + summary: Complete an in-progress inspection + description: Outcome is mandatory. Passed outcomes are rejected while major or critical findings remain open. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.completed + parameters: *inspectionCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteInspectionCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/cancel: + post: + tags: [Engineering Inspections] + operationId: cancelEngineeringInspection + summary: Cancel a draft or scheduled inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.cancelled + parameters: *inspectionCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InspectionReasonCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Documents] + operationId: listEngineeringInspectionDocuments + summary: List active inspection-document links + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Documents] + operationId: linkEngineeringInspectionDocument + summary: Link a scan-clean document to an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringInspectionDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-documents/{documentLinkId}: + delete: + tags: [Engineering Inspection Documents] + operationId: unlinkEngineeringInspectionDocument + summary: Temporally unlink an inspection document + description: Completed inspection evidence cannot be unlinked through ordinary workflow. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/inspections/{inspectionId}/findings: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Findings] + operationId: listEngineeringInspectionFindings + summary: List findings for an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection findings. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Findings] + operationId: createEngineeringInspectionFinding + summary: Record a finding during an in-progress inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.finding_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionFindingRequest'}}} + responses: + '201': + description: Finding recorded. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FindingId' + get: + tags: [Engineering Inspection Findings] + operationId: getEngineeringInspectionFinding + summary: Retrieve an inspection finding + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Finding. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Inspection Findings] + operationId: updateEngineeringInspectionFinding + summary: Update finding description, severity, owner, or target date + description: Resolved and accepted-risk findings are immutable except through explicit reopen policy added later. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.finding_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringInspectionFindingRequest'}}} + responses: + '200': + description: Finding updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}/start-remediation: + post: + tags: [Engineering Inspection Findings] + operationId: startEngineeringFindingRemediation + summary: Start corrective work for an open finding + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.finding_remediation_started + parameters: &findingCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FindingId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: &findingCommandResponses + '200': + description: Finding transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}/resolve: + post: + tags: [Engineering Inspection Findings] + operationId: resolveEngineeringInspectionFinding + summary: Independently verify and resolve a remediated finding + description: Verifier must differ from remediation owner unless an explicit privileged override is audited. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.verify_findings] + x-audit-action: engineering.inspection.finding_resolved + parameters: *findingCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ResolveEngineeringFindingCommand'}}} + responses: *findingCommandResponses + + /engineering/inspection-findings/{findingId}/accept-risk: + post: + tags: [Engineering Inspection Findings] + operationId: acceptEngineeringInspectionFindingRisk + summary: Accept the documented risk of an unresolved finding + description: Major and critical acceptance requires privileged authority and a review date. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.accept_risk] + x-audit-action: engineering.inspection.finding_risk_accepted + parameters: *findingCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AcceptEngineeringFindingRiskCommand'}}} + responses: *findingCommandResponses + + /engineering/inspections/{inspectionId}/followups: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Follow-ups] + operationId: listEngineeringInspectionFollowups + summary: List follow-up actions + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Follow-ups. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Follow-ups] + operationId: createEngineeringInspectionFollowup + summary: Create a corrective-task or follow-up-inspection relationship + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.followup_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionFollowupRequest'}}} + responses: + '201': + description: Follow-up created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-followups/{followupId}/{command}: + post: + tags: [Engineering Inspection Follow-ups] + operationId: commandEngineeringInspectionFollowup + summary: Start, complete, or cancel a follow-up + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.followup_commanded + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FollowupId' + - name: command + in: path + required: true + schema: {type: string, enum: [start, complete, cancel]} + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalInspectionReasonCommand'}}} + responses: + '200': + description: Follow-up transitioned. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/specifications: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Specifications] + operationId: listEngineeringProjectSpecifications + summary: List specifications for a project + x-required-profession: engineering + x-required-permissions: [engineering.specifications.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringSpecificationStatus'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Project specifications. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Specifications] + operationId: createEngineeringSpecification + summary: Create a draft specification + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringSpecificationRequest'}}} + responses: + '201': + description: Draft specification created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specifications/{specificationId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SpecificationId' + get: + tags: [Engineering Specifications] + operationId: getEngineeringSpecification + summary: Retrieve a specification + x-required-profession: engineering + x-required-permissions: [engineering.specifications.read] + responses: + '200': + description: Specification. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Specifications] + operationId: updateEngineeringSpecification + summary: Update draft specification metadata + description: Active, superseded and archived specifications reject PATCH; lifecycle changes use commands. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringSpecificationRequest'}}} + responses: + '200': + description: Specification updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specifications/{specificationId}/activate: + post: + tags: [Engineering Specifications] + operationId: activateEngineeringSpecification + summary: Activate a draft specification + description: Requires exactly one active, scan-clean primary document. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.activate] + x-audit-action: engineering.specification.activated + parameters: &specificationCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SpecificationId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalSpecificationReasonCommand'}}} + responses: &specificationCommandResponses + '200': + description: Specification transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specifications/{specificationId}/supersede: + post: + tags: [Engineering Specifications] + operationId: supersedeEngineeringSpecification + summary: Supersede an active specification + description: Replacement must be a different active specification in the same project. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.activate] + x-audit-action: engineering.specification.superseded + parameters: *specificationCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SupersedeSpecificationCommand'}}} + responses: *specificationCommandResponses + + /engineering/specifications/{specificationId}/archive: + post: + tags: [Engineering Specifications] + operationId: archiveEngineeringSpecification + summary: Archive a draft or active specification + description: Stores archivedFromStatus; active specifications referenced by open work may be blocked. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.archived + parameters: *specificationCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SpecificationReasonCommand'}}} + responses: *specificationCommandResponses + + /engineering/specifications/{specificationId}/restore: + post: + tags: [Engineering Specifications] + operationId: restoreEngineeringSpecification + summary: Restore an archived specification to its prior status + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.restored + parameters: *specificationCommandParameters + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalSpecificationReasonCommand'}}} + responses: *specificationCommandResponses + + /engineering/specifications/{specificationId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SpecificationId' + get: + tags: [Engineering Specification Documents] + operationId: listEngineeringSpecificationDocuments + summary: List current and historical specification-document links + x-required-profession: engineering + x-required-permissions: [engineering.specifications.read] + parameters: + - name: activeOnly + in: query + schema: {type: boolean, default: true} + responses: + '200': + description: Specification documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Specification Documents] + operationId: linkEngineeringSpecificationDocument + summary: Link a scan-clean document to a draft specification + description: A draft may have only one active primary link; active or terminal evidence is immutable. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringSpecificationDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specification-documents/{documentLinkId}: + delete: + tags: [Engineering Specification Documents] + operationId: unlinkEngineeringSpecificationDocument + summary: Temporally unlink a document from a draft specification + description: Sets unlinkedAt; it never deletes the shared document or active/terminal evidence. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/phases: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Phases] + operationId: listEngineeringProjectPhases + summary: List ordered project phases + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + responses: + '200': {description: Ordered phases., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseCollectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Phases] + operationId: createEngineeringProjectPhase + summary: Add a planned phase + x-required-profession: engineering + x-required-permissions: [engineering.projects.manage] + x-audit-action: engineering.project.phase_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringProjectPhaseRequest'}}}} + responses: + '201': + description: Phase created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/phases/{phaseId}: + patch: + tags: [Engineering Project Phases] + operationId: updateEngineeringProjectPhase + summary: Update phase metadata + description: Completion and ordering use commands; planned/active/cancelled may be managed while the phase is otherwise editable. + x-required-profession: engineering + x-required-permissions: [engineering.projects.manage] + x-audit-action: engineering.project.phase_updated + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/PhaseId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringProjectPhaseRequest'}}}} + responses: + '200': + description: Phase updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/phases/{phaseId}/complete: + post: + tags: [Engineering Project Phases] + operationId: completeEngineeringProjectPhase + summary: Complete an active phase + x-required-profession: engineering + x-required-permissions: [engineering.projects.manage] + x-audit-action: engineering.project.phase_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/PhaseId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '200': + description: Phase completed. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + + /engineering/projects/{projectId}/phases/reorder: + post: + tags: [Engineering Project Phases] + operationId: reorderEngineeringProjectPhases + summary: Transactionally reorder every phase in a project + x-required-profession: engineering + x-required-permissions: [engineering.projects.manage] + x-audit-action: engineering.project.phases_reordered + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReorderEngineeringProjectPhasesRequest'}}}} + responses: + '200': {description: Phases reordered., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseCollectionResponse'}}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/time-entries: + get: + tags: [Engineering Time Entries] + operationId: listEngineeringTimeEntries + summary: List time entries + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: userId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: fromDate + in: query + schema: {$ref: '#/components/schemas/Date'} + - name: toDate + in: query + schema: {$ref: '#/components/schemas/Date'} + - name: billable + in: query + schema: {type: boolean} + responses: + '200': {description: Time entries., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryCollectionResponse'}}}} + post: + tags: [Engineering Time Entries] + operationId: createEngineeringTimeEntry + summary: Create a time entry + description: At most one work-item reference is allowed and it must belong to the same project. + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.create] + x-audit-action: engineering.time_entry.created + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringTimeEntryRequest'}}}} + responses: + '201': + description: Time entry created with immutable billing snapshot. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/time-entries/{timeEntryId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/TimeEntryId' + get: + tags: [Engineering Time Entries] + operationId: getEngineeringTimeEntry + summary: Retrieve a time entry + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.read] + responses: + '200': + description: Time entry. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Time Entries] + operationId: updateEngineeringTimeEntry + summary: Update an uninvoiced time entry + description: Once referenced by issued billing, financial snapshot fields and duration are immutable. + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.update] + x-audit-action: engineering.time_entry.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringTimeEntryRequest'}}}} + responses: + '200': + description: Time entry updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/time-entries/batch/submit: + post: + tags: [Engineering Time Entries] + operationId: batchCreateEngineeringTimeEntries + summary: Create up to 100 time entries synchronously + description: Atomic mode rolls back all entries; partial mode returns per-item results. + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.create] + x-audit-action: engineering.time_entries.batch_created + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BatchCreateEngineeringTimeEntriesRequest'}}}} + responses: + '200': {description: Batch processed., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryBatchResponse'}}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/budgets: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Budgets] + operationId: listEngineeringProjectBudgets + summary: List project budgets + x-required-profession: engineering + x-required-permissions: [engineering.budgets.read] + responses: + '200': {description: Budgets., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetCollectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Budgets] + operationId: createEngineeringProjectBudget + summary: Create a draft project budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.manage] + x-audit-action: engineering.budget.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringProjectBudgetRequest'}}}} + responses: + '201': + description: Draft budget created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/budgets/{budgetId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BudgetId' + get: + tags: [Engineering Project Budgets] + operationId: getEngineeringProjectBudget + summary: Retrieve a project budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.read] + responses: + '200': + description: Budget. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Project Budgets] + operationId: updateEngineeringProjectBudget + summary: Update a draft budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.manage] + x-audit-action: engineering.budget.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringProjectBudgetRequest'}}}} + responses: + '200': + description: Budget updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + + /engineering/budgets/{budgetId}/approve: + post: + tags: [Engineering Project Budgets] + operationId: approveEngineeringProjectBudget + summary: Approve and freeze a draft budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.approve] + x-audit-action: engineering.budget.approved + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BudgetId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ApproveEngineeringBudgetCommand'}}}} + responses: + '200': + description: Budget approved and frozen. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/budgets/{budgetId}/items: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BudgetId' + get: + tags: [Engineering Project Budgets] + operationId: listEngineeringProjectBudgetItems + summary: List budget items + x-required-profession: engineering + x-required-permissions: [engineering.budgets.read] + responses: + '200': {description: Budget items., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetItemCollectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Budgets] + operationId: createEngineeringProjectBudgetItem + summary: Add an item to a draft budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.manage] + x-audit-action: engineering.budget.item_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringProjectBudgetItemRequest'}}}} + responses: + '201': + description: Budget item created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetItemResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/budgets/{budgetId}/projection: + get: + tags: [Engineering Project Budgets] + operationId: getEngineeringProjectBudgetProjection + summary: Retrieve derived allocations, commitments, actuals, and variance + description: Projection values are derived from authoritative records and are not independently mutable. + x-required-profession: engineering + x-required-permissions: [engineering.budgets.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BudgetId' + responses: + '200': {description: Derived budget projection., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringBudgetProjectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + + /billing-accounts: + get: + tags: [Billing Accounts] + operationId: listBillingAccounts + summary: List bill-to identities + x-required-permissions: [billing.accounts.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/BillingAccountStatus'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': {description: Billing accounts., content: {application/json: {schema: {$ref: '#/components/schemas/BillingAccountCollectionResponse'}}}} + post: + tags: [Billing Accounts] + operationId: createBillingAccount + summary: Create a bill-to identity + x-required-permissions: [billing.accounts.manage] + x-audit-action: billing.account.created + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateBillingAccountRequest'}}}} + responses: + '201': + description: Billing account created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/BillingAccountResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /billing-accounts/{billingAccountId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BillingAccountId' + get: + tags: [Billing Accounts] + operationId: getBillingAccount + summary: Retrieve a billing account + x-required-permissions: [billing.accounts.read] + responses: + '200': + description: Billing account. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/BillingAccountResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Billing Accounts] + operationId: updateBillingAccount + summary: Update a bill-to identity + description: Issued invoice snapshots never change when this resource changes. + x-required-permissions: [billing.accounts.manage] + x-audit-action: billing.account.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateBillingAccountRequest'}}}} + responses: + '200': + description: Billing account updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/BillingAccountResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + + /engineering/clients/{clientId}/billing-account-links: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ClientId' + get: + tags: [Engineering Billing Links] + operationId: listEngineeringClientBillingAccountLinks + summary: List current and historical client billing-account links + x-required-profession: engineering + x-required-permissions: [engineering.billing.read] + responses: + '200': {description: Links., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringClientBillingAccountLinkCollectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Billing Links] + operationId: linkEngineeringClientBillingAccount + summary: Link the client to its active billing account + description: Creating a new active link atomically ends any prior active client link. + x-required-profession: engineering + x-required-permissions: [engineering.billing.manage] + x-audit-action: engineering.client.billing_account_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringClientBillingAccountRequest'}}}} + responses: + '201': + description: Billing account linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringClientBillingAccountLinkResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/client-billing-account-links/{billingAccountLinkId}: + delete: + tags: [Engineering Billing Links] + operationId: unlinkEngineeringClientBillingAccount + summary: End an active client billing-account link + description: Existing invoices retain their billing account and bill-to snapshots. + x-required-profession: engineering + x-required-permissions: [engineering.billing.manage] + x-audit-action: engineering.client.billing_account_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BillingAccountLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /invoices: + get: + tags: [Invoices] + operationId: listInvoices + summary: List invoices + x-required-permissions: [billing.invoices.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: billingAccountId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: status + in: query + schema: {$ref: '#/components/schemas/InvoiceStatus'} + responses: + '200': {description: Invoices., content: {application/json: {schema: {$ref: '#/components/schemas/InvoiceCollectionResponse'}}}} + post: + tags: [Invoices] + operationId: createInvoice + summary: Create a draft invoice + description: Bill-to fields are snapshotted from the selected billing account. + x-required-permissions: [billing.invoices.manage] + x-audit-action: billing.invoice.created + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateInvoiceRequest'}}}} + responses: + '201': + description: Draft invoice created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/InvoiceResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /invoices/{invoiceId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InvoiceId' + get: + tags: [Invoices] + operationId: getInvoice + summary: Retrieve an invoice and totals + x-required-permissions: [billing.invoices.read] + responses: + '200': + description: Invoice. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/InvoiceResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Invoices] + operationId: updateDraftInvoice + summary: Update a draft invoice + description: Issued, paid, overdue and void invoices reject PATCH. + x-required-permissions: [billing.invoices.manage] + x-audit-action: billing.invoice.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateInvoiceRequest'}}}} + responses: + '200': + description: Draft invoice updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/InvoiceResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + + /invoices/{invoiceId}/issue: + post: + tags: [Invoices] + operationId: issueInvoice + summary: Issue and freeze a draft invoice + description: Requires at least one item, balanced derived totals, and profession-owned work context. + x-required-permissions: [billing.invoices.issue] + x-audit-action: billing.invoice.issued + parameters: &invoiceCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InvoiceId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/IssueInvoiceCommand'}}}} + responses: &invoiceCommandResponses + '200': + description: Invoice transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/InvoiceResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /invoices/{invoiceId}/void: + post: + tags: [Invoices] + operationId: voidInvoice + summary: Void an unpaid issued or overdue invoice + x-required-permissions: [billing.invoices.void] + x-audit-action: billing.invoice.voided + parameters: *invoiceCommandParameters + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/VoidInvoiceCommand'}}}} + responses: *invoiceCommandResponses + + /invoices/{invoiceId}/items: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InvoiceId' + get: + tags: [Invoice Items] + operationId: listInvoiceItems + summary: List invoice items + x-required-permissions: [billing.invoices.read] + responses: + '200': {description: Invoice items., content: {application/json: {schema: {$ref: '#/components/schemas/InvoiceItemCollectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Invoice Items] + operationId: createInvoiceItem + summary: Add a calculated item to a draft invoice + description: totalAmountMinor equals rounded quantity times unitPriceMinor plus taxAmountMinor. + x-required-permissions: [billing.invoices.manage] + x-audit-action: billing.invoice.item_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateInvoiceItemRequest'}}}} + responses: + '201': + description: Item created and invoice totals recalculated. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/InvoiceItemResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /invoice-items/{invoiceItemId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InvoiceItemId' + patch: + tags: [Invoice Items] + operationId: updateDraftInvoiceItem + summary: Update an item on a draft invoice + x-required-permissions: [billing.invoices.manage] + x-audit-action: billing.invoice.item_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateInvoiceItemRequest'}}}} + responses: + '200': + description: Item and invoice totals updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/InvoiceItemResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + delete: + tags: [Invoice Items] + operationId: deleteDraftInvoiceItem + summary: Delete an item from a draft invoice + x-required-permissions: [billing.invoices.manage] + x-audit-action: billing.invoice.item_deleted + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Draft item deleted and invoice totals recalculated.} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + + /engineering/invoices/{invoiceId}/projects: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InvoiceId' + get: + tags: [Engineering Billing Links] + operationId: listEngineeringInvoiceProjects + summary: List explicit engineering project context for an invoice + x-required-profession: engineering + x-required-permissions: [engineering.billing.read] + responses: + '200': {description: Invoice-project links., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInvoiceProjectCollectionResponse'}}}} + post: + tags: [Engineering Billing Links] + operationId: linkEngineeringInvoiceProject + summary: Link a draft invoice to an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.billing.manage] + x-audit-action: engineering.invoice.project_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringInvoiceProjectRequest'}}}} + responses: + '201': + description: Project linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInvoiceProjectResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/invoice-projects/{invoiceProjectLinkId}: + delete: + tags: [Engineering Billing Links] + operationId: unlinkEngineeringInvoiceProject + summary: Remove project context from a draft invoice + description: Issued invoice provenance is immutable. + x-required-profession: engineering + x-required-permissions: [engineering.billing.manage] + x-audit-action: engineering.invoice.project_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InvoiceProjectLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Draft project link removed.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/invoice-items/{invoiceItemId}/time-entries: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InvoiceItemId' + get: + tags: [Engineering Billing Links] + operationId: listEngineeringInvoiceItemTimeEntries + summary: List time-entry provenance for an invoice item + x-required-profession: engineering + x-required-permissions: [engineering.billing.read] + responses: + '200': {description: Time-entry links., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInvoiceItemTimeEntryCollectionResponse'}}}} + post: + tags: [Engineering Billing Links] + operationId: linkEngineeringInvoiceItemTimeEntries + summary: Link billable time entries to a draft invoice item + description: Entries must share project and currency context and cannot be linked to another issued invoice. + x-required-profession: engineering + x-required-permissions: [engineering.billing.manage] + x-audit-action: engineering.invoice_item.time_entries_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringInvoiceItemTimeEntriesRequest'}}}} + responses: + '201': {description: Time entries linked., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInvoiceItemTimeEntryCollectionResponse'}}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/invoice-items/{invoiceItemId}/time-entries/{timeEntryId}: + delete: + tags: [Engineering Billing Links] + operationId: unlinkEngineeringInvoiceItemTimeEntry + summary: Remove time-entry provenance from a draft invoice item + description: Issued invoice provenance is immutable. + x-required-profession: engineering + x-required-permissions: [engineering.billing.manage] + x-audit-action: engineering.invoice_item.time_entry_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InvoiceItemId' + - $ref: '#/components/parameters/TimeEntryId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Draft time-entry link removed.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /invoices/{invoiceId}/payments: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InvoiceId' + get: + tags: [Payments] + operationId: listInvoicePayments + summary: List invoice payments + x-required-permissions: [billing.payments.read] + responses: + '200': {description: Payments., content: {application/json: {schema: {$ref: '#/components/schemas/PaymentCollectionResponse'}}}} + post: + tags: [Payments] + operationId: recordInvoicePayment + summary: Record or initialize an invoice payment + description: Currency must equal invoice currency; successful total cannot exceed invoice balance. + x-required-permissions: [billing.payments.manage] + x-audit-action: billing.payment.recorded + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/RecordInvoicePaymentRequest'}}}} + responses: + '201': + description: Payment recorded and invoice balance/status recalculated. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/PaymentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /payments/{paymentId}: + get: + tags: [Payments] + operationId: getPayment + summary: Retrieve payment and refund totals + x-required-permissions: [billing.payments.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PaymentId' + responses: + '200': {description: Payment., content: {application/json: {schema: {$ref: '#/components/schemas/PaymentResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + + /payments/{paymentId}/refund: + post: + tags: [Payments] + operationId: refundPayment + summary: Create an auditable full or partial refund + description: Successful cumulative refunds cannot exceed the successful payment amount. + x-required-permissions: [billing.payments.refund] + x-audit-action: billing.payment.refund_requested + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/PaymentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/RefundPaymentRequest'}}}} + responses: + '201': + description: Refund created and payment status recalculated. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/PaymentRefundResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + +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' + ProjectId: + name: projectId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + ProjectMemberId: + name: memberId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + TaskId: + name: taskId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + SiteId: + name: siteId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentId: + name: documentId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + UploadId: + name: uploadId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentLinkId: + name: documentLinkId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignId: + name: designId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignVersionId: + name: designVersionId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + InspectionId: + name: inspectionId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + FindingId: + name: findingId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + FollowupId: + name: followupId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + SpecificationId: + name: specificationId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + PhaseId: + name: phaseId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + TimeEntryId: + name: timeEntryId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + BudgetId: + name: budgetId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + BillingAccountId: + name: billingAccountId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + BillingAccountLinkId: + name: billingAccountLinkId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + InvoiceId: + name: invoiceId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + InvoiceItemId: + name: invoiceItemId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + PaymentId: + name: paymentId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + InvoiceProjectLinkId: + name: invoiceProjectLinkId + 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] + EngineeringSite: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, address, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + EngineeringSiteAddress: + type: object + additionalProperties: false + required: [line1, city, countryCode] + properties: + line1: {type: string, minLength: 1, maxLength: 200} + line2: {type: [string, 'null'], maxLength: 200} + city: {type: string, minLength: 1, maxLength: 120} + region: {type: [string, 'null'], maxLength: 120} + postalCode: {type: [string, 'null'], maxLength: 32} + countryCode: {$ref: '#/components/schemas/CountryCode'} + CreateEngineeringSiteRequest: + type: object + additionalProperties: false + required: [name, address] + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + UpdateEngineeringSiteRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + EngineeringSiteResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSite'}} + EngineeringSiteCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringSite'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentClassification: + type: string + enum: [public, internal, confidential, restricted, regulated] + DocumentUploadStatus: + type: string + enum: [initialized, uploading, completed, failed, aborted, expired] + MalwareScanStatus: + type: string + enum: [not_started, pending, scanning, clean, infected, failed] + Document: + type: object + additionalProperties: false + required: [id, organizationId, name, classification, currentVersionId, version, createdByUserId, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersion: {oneOf: [{$ref: '#/components/schemas/DocumentVersion'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + DocumentVersion: + type: object + additionalProperties: false + required: [id, organizationId, documentId, versionNumber, mimeType, sizeBytes, uploadStatus, scanStatus, uploadedByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + hashAlgorithm: {type: string, const: sha256} + uploadStatus: {$ref: '#/components/schemas/DocumentUploadStatus'} + scanStatus: {$ref: '#/components/schemas/MalwareScanStatus'} + scanCompletedAt: {type: [string, 'null'], format: date-time} + available: {type: boolean, readOnly: true, description: True only when uploadStatus is completed and scanStatus is clean.} + uploadedByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadRequest: + type: object + additionalProperties: false + required: [name, classification, mimeType, sizeBytes] + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentVersionRequest: + type: object + additionalProperties: false + required: [mimeType, sizeBytes] + properties: + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + UpdateDocumentRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + CompleteDocumentUploadRequest: + type: object + additionalProperties: false + required: [documentVersionId, contentHash] + properties: + documentVersionId: {$ref: '#/components/schemas/Uuid'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadUrl, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadUrl: {type: string, format: uri} + requiredHeaders: {type: object, additionalProperties: {type: string}} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeDocumentUploadData'}} + InitializeMultipartUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadId, recommendedPartSizeBytes, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadId: {$ref: '#/components/schemas/Uuid'} + recommendedPartSizeBytes: {type: integer, minimum: 5242880} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeMultipartUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeMultipartUploadData'}} + MultipartPartUrlsRequest: + type: object + additionalProperties: false + required: [partNumbers] + properties: + partNumbers: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: {type: integer, minimum: 1, maximum: 10000} + MultipartPartUploadUrl: + type: object + required: [partNumber, uploadUrl, expiresAt] + properties: + partNumber: {type: integer, minimum: 1} + uploadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + MultipartPartUrlsResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/MultipartPartUploadUrl'}}} + CompletedMultipartPart: + type: object + additionalProperties: false + required: [partNumber, etag] + properties: + partNumber: {type: integer, minimum: 1} + etag: {type: string, minLength: 1, maxLength: 200} + CompleteMultipartUploadRequest: + type: object + additionalProperties: false + required: [parts, contentHash] + properties: + parts: + type: array + minItems: 1 + maxItems: 10000 + items: {$ref: '#/components/schemas/CompletedMultipartPart'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + CreateDocumentDownloadRequest: + type: object + additionalProperties: false + properties: + documentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}], description: Defaults to the current clean version.} + DocumentDownloadData: + type: object + required: [downloadUrl, expiresAt] + properties: + downloadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + DocumentDownloadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentDownloadData'}} + DocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Document'}} + DocumentCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Document'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentVersion'}} + DocumentVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/DocumentVersion'}}} + EngineeringProjectDocumentLink: + type: object + additionalProperties: false + required: [id, organizationId, projectId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringProjectDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + EngineeringProjectDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}} + EngineeringProjectDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}}} + EngineeringDesignStatus: + type: string + enum: [draft, under_review, changes_requested, approved, rejected, cancelled, withdrawn, superseded] + EngineeringDesignAssignmentRole: + type: string + enum: [owner, preparer, reviewer, contributor] + EngineeringDesignDocumentRole: + type: string + enum: [primary_drawing, calculation, supporting_document, specification, attachment] + description: Domain-specific registry independent from specification document roles. + EngineeringDesignReviewStatus: + type: string + enum: [approved, changes_requested, rejected] + EngineeringDesign: + type: object + additionalProperties: false + required: [id, organizationId, projectId, designNumber, title, discipline, status, ownerUserId, preparedByUserId, currentVersionId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + status: {$ref: '#/components/schemas/EngineeringDesignStatus'} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + currentVersionId: {$ref: '#/components/schemas/Uuid'} + approvedVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedAt: {type: [string, 'null'], format: date-time} + supersededByDesignId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignRequest: + type: object + additionalProperties: false + required: [designNumber, title, discipline, ownerUserId, preparedByUserId] + properties: + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + UpdateEngineeringDesignRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + EngineeringDesignResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesign'}} + EngineeringDesignCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesign'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + OptionalDesignReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignDecisionCommand: + type: object + additionalProperties: false + required: [designVersionId, reason] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 2000} + ApproveDesignCommand: + type: object + additionalProperties: false + required: [designVersionId, attestation] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + attestation: {type: string, minLength: 10, maxLength: 2000} + SupersedeDesignCommand: + type: object + additionalProperties: false + required: [replacementDesignId, reason] + properties: + replacementDesignId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringDesignAssignment: + type: object + additionalProperties: false + required: [id, organizationId, designId, userId, assignmentRole, assignedByUserId, assignedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + assignedByUserId: {$ref: '#/components/schemas/Uuid'} + assignedAt: {$ref: '#/components/schemas/Timestamp'} + unassignedAt: {type: [string, 'null'], format: date-time} + AssignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [userId, assignmentRole] + properties: + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + UnassignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [assignmentId] + properties: + assignmentId: {$ref: '#/components/schemas/Uuid'} + reason: {type: [string, 'null'], maxLength: 1000} + EngineeringDesignAssignmentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignAssignment'}} + EngineeringDesignAssignmentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignAssignment'}}} + EngineeringDesignVersion: + type: object + additionalProperties: false + required: [id, organizationId, designId, versionNumber, createdByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + changeSummary: {type: [string, 'null'], maxLength: 2000} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignVersionRequest: + type: object + additionalProperties: false + properties: + changeSummary: {type: [string, 'null'], maxLength: 2000} + EngineeringDesignVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersion'}} + EngineeringDesignVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersion'}}} + EngineeringDesignVersionDocument: + type: object + additionalProperties: false + required: [id, organizationId, designVersionId, documentId, documentRole, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringDesignVersionDocumentRequest: + type: object + additionalProperties: false + required: [documentId, documentRole] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + EngineeringDesignVersionDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}} + EngineeringDesignVersionDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}}} + EngineeringDesignReview: + type: object + additionalProperties: false + required: [id, organizationId, designId, designVersionId, reviewerUserId, status, comments, reviewedAt, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + reviewerUserId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + reviewedAt: {$ref: '#/components/schemas/Timestamp'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignReviewRequest: + type: object + additionalProperties: false + required: [designVersionId, status, comments] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + EngineeringDesignReviewResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignReview'}} + EngineeringDesignReviewCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignReview'}}} + EngineeringInspectionStatus: + type: string + enum: [draft, scheduled, in_progress, completed, cancelled] + EngineeringInspectionOutcome: + type: string + enum: [passed, passed_with_observations, followup_required, failed] + EngineeringInspectionFindingSeverity: + type: string + enum: [observation, minor, major, critical] + EngineeringInspectionFindingStatus: + type: string + enum: [open, in_progress, resolved, accepted_risk] + EngineeringInspection: + type: object + additionalProperties: false + required: [id, organizationId, projectId, siteId, inspectionType, inspectorUserId, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100, description: Controlled application registry key.} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringInspectionStatus'} + outcome: {oneOf: [{$ref: '#/components/schemas/EngineeringInspectionOutcome'}, {type: 'null'}]} + scheduledAt: {type: [string, 'null'], format: date-time} + startedAt: {type: [string, 'null'], format: date-time} + performedAt: {type: [string, 'null'], format: date-time} + cancelledAt: {type: [string, 'null'], format: date-time} + summary: {type: [string, 'null'], maxLength: 10000} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringInspectionRequest: + type: object + additionalProperties: false + required: [siteId, inspectionType, inspectorUserId] + properties: + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + summary: {type: [string, 'null'], maxLength: 10000} + UpdateEngineeringInspectionRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + summary: {type: [string, 'null'], maxLength: 10000} + EngineeringInspectionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspection'}} + EngineeringInspectionCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspection'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + ScheduleInspectionCommand: + type: object + additionalProperties: false + required: [scheduledAt] + properties: + scheduledAt: {$ref: '#/components/schemas/Timestamp'} + StartInspectionCommand: + type: object + additionalProperties: false + properties: + startedAt: {$ref: '#/components/schemas/Timestamp'} + CompleteInspectionCommand: + type: object + additionalProperties: false + required: [outcome, summary] + properties: + outcome: {$ref: '#/components/schemas/EngineeringInspectionOutcome'} + performedAt: {$ref: '#/components/schemas/Timestamp'} + summary: {type: string, minLength: 1, maxLength: 10000} + createFollowups: {type: boolean, default: false} + InspectionReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + OptionalInspectionReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringInspectionDocument: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, enum: [evidence, photo, report, certificate, supporting_document]} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringInspectionDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, enum: [evidence, photo, report, certificate, supporting_document]} + EngineeringInspectionDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionDocument'}} + EngineeringInspectionDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionDocument'}}} + EngineeringInspectionFinding: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, severity, description, status, createdByUserId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + status: {$ref: '#/components/schemas/EngineeringInspectionFindingStatus'} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + resolutionSummary: {type: [string, 'null'], maxLength: 10000} + resolvedAt: {type: [string, 'null'], format: date-time} + resolvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + acceptedRiskReason: {type: [string, 'null'], maxLength: 5000} + riskReviewDate: {type: [string, 'null'], format: date} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringInspectionFindingRequest: + type: object + additionalProperties: false + required: [severity, description] + properties: + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + UpdateEngineeringInspectionFindingRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + ResolveEngineeringFindingCommand: + type: object + additionalProperties: false + required: [resolutionSummary] + properties: + resolutionSummary: {type: string, minLength: 3, maxLength: 10000} + evidenceDocumentIds: + type: array + maxItems: 50 + uniqueItems: true + items: {$ref: '#/components/schemas/Uuid'} + privilegedSelfVerificationReason: {type: [string, 'null'], minLength: 10, maxLength: 2000} + AcceptEngineeringFindingRiskCommand: + type: object + additionalProperties: false + required: [reason, reviewDate] + properties: + reason: {type: string, minLength: 10, maxLength: 5000} + reviewDate: {$ref: '#/components/schemas/Date'} + approvingUserId: {$ref: '#/components/schemas/Uuid'} + EngineeringInspectionFindingResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionFinding'}} + EngineeringInspectionFindingCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionFinding'}}} + EngineeringInspectionFollowupType: + type: string + enum: [corrective_task, followup_inspection, both] + EngineeringInspectionFollowupStatus: + type: string + enum: [open, in_progress, completed, cancelled] + EngineeringInspectionFollowup: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, followupType, status, createdByUserId, version, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + followupType: {$ref: '#/components/schemas/EngineeringInspectionFollowupType'} + linkedTaskId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + linkedInspectionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + status: {$ref: '#/components/schemas/EngineeringInspectionFollowupStatus'} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + completedAt: {type: [string, 'null'], format: date-time} + cancelledAt: {type: [string, 'null'], format: date-time} + CreateEngineeringInspectionFollowupRequest: + type: object + additionalProperties: false + required: [followupType] + properties: + followupType: {$ref: '#/components/schemas/EngineeringInspectionFollowupType'} + linkedTaskId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + linkedInspectionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + EngineeringInspectionFollowupResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionFollowup'}} + EngineeringInspectionFollowupCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionFollowup'}}} + EngineeringSpecificationStatus: + type: string + enum: [draft, active, superseded, archived] + EngineeringSpecificationDocumentRole: + type: string + enum: [primary, attachment, supporting_document] + description: Specification-specific registry; independent from design-version document roles. + EngineeringSpecification: + type: object + additionalProperties: false + required: [id, organizationId, projectId, specificationNumber, title, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + specificationNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + status: {$ref: '#/components/schemas/EngineeringSpecificationStatus'} + supersededBySpecificationId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + archivedFromStatus: + oneOf: + - type: string + enum: [draft, active] + - type: 'null' + archivedAt: {type: [string, 'null'], format: date-time} + archivedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringSpecificationRequest: + type: object + additionalProperties: false + required: [specificationNumber, title] + properties: + specificationNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + UpdateEngineeringSpecificationRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + EngineeringSpecificationResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSpecification'}} + EngineeringSpecificationCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringSpecification'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + SpecificationReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + OptionalSpecificationReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + SupersedeSpecificationCommand: + type: object + additionalProperties: false + required: [supersededBySpecificationId, reason] + properties: + supersededBySpecificationId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringSpecificationDocument: + type: object + additionalProperties: false + required: [id, organizationId, specificationId, documentId, documentRole, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + specificationId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringSpecificationDocumentRole'} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringSpecificationDocumentRequest: + type: object + additionalProperties: false + required: [documentId, documentRole] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringSpecificationDocumentRole'} + EngineeringSpecificationDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSpecificationDocument'}} + EngineeringSpecificationDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringSpecificationDocument'}}} + EngineeringProjectPhaseStatus: + type: string + enum: [planned, active, completed, cancelled] + EngineeringProjectPhase: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, sequence, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + sequence: {type: integer, minimum: 1} + status: {$ref: '#/components/schemas/EngineeringProjectPhaseStatus'} + startDate: {type: [string, 'null'], format: date} + endDate: {type: [string, 'null'], format: date} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringProjectPhaseRequest: + type: object + additionalProperties: false + required: [name] + properties: + name: {type: string, minLength: 1, maxLength: 200} + startDate: {type: [string, 'null'], format: date} + endDate: {type: [string, 'null'], format: date} + UpdateEngineeringProjectPhaseRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 200} + startDate: {type: [string, 'null'], format: date} + endDate: {type: [string, 'null'], format: date} + status: {type: string, enum: [planned, active, cancelled]} + ReorderEngineeringProjectPhasesRequest: + type: object + additionalProperties: false + required: [projectVersion, orderedPhaseIds] + properties: + projectVersion: {type: integer, minimum: 1} + orderedPhaseIds: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: {$ref: '#/components/schemas/Uuid'} + EngineeringProjectPhaseResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectPhase'}} + EngineeringProjectPhaseCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectPhase'}}} + EngineeringTimeWorkItem: + oneOf: + - $ref: '#/components/schemas/EngineeringTimePhaseReference' + - $ref: '#/components/schemas/EngineeringTimeTaskReference' + - $ref: '#/components/schemas/EngineeringTimeDesignReference' + - $ref: '#/components/schemas/EngineeringTimeInspectionReference' + discriminator: {propertyName: type} + description: Maps to one project-aware nullable FK column; no unconstrained polymorphic database reference is used. + EngineeringTimePhaseReference: + type: object + additionalProperties: false + required: [type, id] + properties: {type: {type: string, const: phase}, id: {$ref: '#/components/schemas/Uuid'}} + EngineeringTimeTaskReference: + type: object + additionalProperties: false + required: [type, id] + properties: {type: {type: string, const: task}, id: {$ref: '#/components/schemas/Uuid'}} + EngineeringTimeDesignReference: + type: object + additionalProperties: false + required: [type, id] + properties: {type: {type: string, const: design}, id: {$ref: '#/components/schemas/Uuid'}} + EngineeringTimeInspectionReference: + type: object + additionalProperties: false + required: [type, id] + properties: {type: {type: string, const: inspection}, id: {$ref: '#/components/schemas/Uuid'}} + EngineeringTimeEntryInput: + type: object + additionalProperties: false + required: [projectId, userId, workDate, durationMinutes, description, billable] + properties: + projectId: {$ref: '#/components/schemas/Uuid'} + userId: {$ref: '#/components/schemas/Uuid'} + workDate: {$ref: '#/components/schemas/Date'} + durationMinutes: {type: integer, minimum: 1, maximum: 1440} + description: {type: string, minLength: 1, maxLength: 2000} + billable: {type: boolean} + billingRateMinor: {type: [integer, 'null'], minimum: 1} + currencyCode: {oneOf: [{$ref: '#/components/schemas/CurrencyCode'}, {type: 'null'}]} + workItem: {oneOf: [{$ref: '#/components/schemas/EngineeringTimeWorkItem'}, {type: 'null'}]} + allOf: + - if: {properties: {billable: {const: true}}, required: [billable]} + then: {required: [billingRateMinor, currencyCode], properties: {billingRateMinor: {type: integer, minimum: 1}, currencyCode: {$ref: '#/components/schemas/CurrencyCode'}}} + else: {properties: {billingRateMinor: {type: 'null'}, currencyCode: {type: 'null'}}} + CreateEngineeringTimeEntryRequest: + $ref: '#/components/schemas/EngineeringTimeEntryInput' + UpdateEngineeringTimeEntryRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + workDate: {$ref: '#/components/schemas/Date'} + durationMinutes: {type: integer, minimum: 1, maximum: 1440} + description: {type: string, minLength: 1, maxLength: 2000} + billable: {type: boolean} + billingRateMinor: {type: [integer, 'null'], minimum: 1} + currencyCode: {oneOf: [{$ref: '#/components/schemas/CurrencyCode'}, {type: 'null'}]} + workItem: {oneOf: [{$ref: '#/components/schemas/EngineeringTimeWorkItem'}, {type: 'null'}]} + description: The service validates the same billable/rate/currency invariant after merge. + EngineeringTimeEntry: + allOf: + - $ref: '#/components/schemas/EngineeringTimeEntryInput' + - type: object + required: [id, organizationId, version, invoiced, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + invoiced: {type: boolean, readOnly: true} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + EngineeringTimeEntryResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringTimeEntry'}} + EngineeringTimeEntryCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringTimeEntry'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + BatchCreateEngineeringTimeEntriesRequest: + type: object + additionalProperties: false + required: [mode, entries] + properties: + mode: {$ref: '#/components/schemas/BatchExecutionMode'} + entries: {type: array, minItems: 1, maxItems: 100, items: {$ref: '#/components/schemas/EngineeringTimeEntryInput'}} + EngineeringTimeEntryBatchItemResult: + type: object + required: [index, status] + properties: + index: {type: integer, minimum: 0} + status: {type: string, enum: [created, failed]} + data: {oneOf: [{$ref: '#/components/schemas/EngineeringTimeEntry'}, {type: 'null'}]} + problem: {oneOf: [{$ref: '#/components/schemas/Problem'}, {type: 'null'}]} + EngineeringTimeEntryBatchResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringTimeEntryBatchItemResult'}}} + EngineeringBudgetStatus: + type: string + enum: [draft, approved, closed] + EngineeringProjectBudget: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, currencyCode, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + status: {$ref: '#/components/schemas/EngineeringBudgetStatus'} + approvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedAt: {type: [string, 'null'], format: date-time} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringProjectBudgetRequest: + type: object + additionalProperties: false + required: [name, currencyCode] + properties: + name: {type: string, minLength: 1, maxLength: 200} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + UpdateEngineeringProjectBudgetRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: {name: {type: string, minLength: 1, maxLength: 200}} + ApproveEngineeringBudgetCommand: + type: object + additionalProperties: false + required: [attestation] + properties: {attestation: {type: string, minLength: 10, maxLength: 1000}} + EngineeringProjectBudgetResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectBudget'}} + EngineeringProjectBudgetCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectBudget'}}} + EngineeringProjectBudgetItem: + type: object + additionalProperties: false + required: [id, organizationId, budgetId, category, description, allocatedAmountMinor, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + budgetId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + description: {type: string, minLength: 1, maxLength: 1000} + allocatedAmountMinor: {type: integer, minimum: 0} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringProjectBudgetItemRequest: + type: object + additionalProperties: false + required: [category, description, allocatedAmountMinor] + properties: + category: {type: string, minLength: 1, maxLength: 100} + description: {type: string, minLength: 1, maxLength: 1000} + allocatedAmountMinor: {type: integer, minimum: 0} + EngineeringProjectBudgetItemResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectBudgetItem'}} + EngineeringProjectBudgetItemCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectBudgetItem'}}} + EngineeringBudgetProjection: + type: object + additionalProperties: false + required: [budgetId, currencyCode, allocatedAmountMinor, committedAmountMinor, actualAmountMinor, varianceAmountMinor, calculatedAt] + properties: + budgetId: {$ref: '#/components/schemas/Uuid'} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + allocatedAmountMinor: {type: integer, minimum: 0} + committedAmountMinor: {type: integer, minimum: 0} + actualAmountMinor: {type: integer, minimum: 0} + varianceAmountMinor: {type: integer} + calculatedAt: {$ref: '#/components/schemas/Timestamp'} + sourceWatermark: {type: string, description: Reconciliation watermark for authoritative source records.} + EngineeringBudgetProjectionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringBudgetProjection'}} + BillingAccountStatus: + type: string + enum: [active, inactive] + BillingAddress: + type: object + additionalProperties: false + required: [line1, city, countryCode] + properties: + line1: {type: string, minLength: 1, maxLength: 200} + line2: {type: [string, 'null'], maxLength: 200} + city: {type: string, minLength: 1, maxLength: 120} + region: {type: [string, 'null'], maxLength: 120} + postalCode: {type: [string, 'null'], maxLength: 32} + countryCode: {$ref: '#/components/schemas/CountryCode'} + BillingAccountInput: + type: object + additionalProperties: false + required: [displayName] + properties: + displayName: {type: string, minLength: 1, maxLength: 200} + legalName: {type: [string, 'null'], maxLength: 300} + billingEmail: {oneOf: [{$ref: '#/components/schemas/Email'}, {type: 'null'}]} + billingAddress: {oneOf: [{$ref: '#/components/schemas/BillingAddress'}, {type: 'null'}]} + CreateBillingAccountRequest: + $ref: '#/components/schemas/BillingAccountInput' + UpdateBillingAccountRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + displayName: {type: string, minLength: 1, maxLength: 200} + legalName: {type: [string, 'null'], maxLength: 300} + billingEmail: {oneOf: [{$ref: '#/components/schemas/Email'}, {type: 'null'}]} + billingAddress: {oneOf: [{$ref: '#/components/schemas/BillingAddress'}, {type: 'null'}]} + status: {$ref: '#/components/schemas/BillingAccountStatus'} + BillingAccount: + allOf: + - $ref: '#/components/schemas/BillingAccountInput' + - type: object + required: [id, organizationId, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/BillingAccountStatus'} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + BillingAccountResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/BillingAccount'}} + BillingAccountCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/BillingAccount'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + EngineeringClientBillingAccountLink: + type: object + additionalProperties: false + required: [id, organizationId, clientId, billingAccountId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + clientId: {$ref: '#/components/schemas/Uuid'} + billingAccountId: {$ref: '#/components/schemas/Uuid'} + billingAccount: {$ref: '#/components/schemas/BillingAccount'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringClientBillingAccountRequest: + type: object + additionalProperties: false + required: [billingAccountId] + properties: {billingAccountId: {$ref: '#/components/schemas/Uuid'}} + EngineeringClientBillingAccountLinkResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringClientBillingAccountLink'}} + EngineeringClientBillingAccountLinkCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringClientBillingAccountLink'}}} + InvoiceStatus: + type: string + enum: [draft, issued, partially_paid, paid, overdue, void] + Invoice: + type: object + additionalProperties: false + required: [id, organizationId, billingAccountId, billToName, invoiceNumber, status, currencyCode, subtotalMinor, taxTotalMinor, totalMinor, paidAmountMinor, balanceDueMinor, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + billingAccountId: {$ref: '#/components/schemas/Uuid'} + billToName: {type: string, minLength: 1, maxLength: 300} + billToEmail: {oneOf: [{$ref: '#/components/schemas/Email'}, {type: 'null'}]} + billToAddressSnapshot: {oneOf: [{$ref: '#/components/schemas/BillingAddress'}, {type: 'null'}]} + invoiceNumber: {type: string, minLength: 1, maxLength: 64} + status: {$ref: '#/components/schemas/InvoiceStatus'} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + subtotalMinor: {type: integer, minimum: 0, readOnly: true} + taxTotalMinor: {type: integer, minimum: 0, readOnly: true} + totalMinor: {type: integer, minimum: 0, readOnly: true} + paidAmountMinor: {type: integer, minimum: 0, readOnly: true} + balanceDueMinor: {type: integer, minimum: 0, readOnly: true} + issuedAt: {type: [string, 'null'], format: date-time} + dueAt: {type: [string, 'null'], format: date-time} + paidAt: {type: [string, 'null'], format: date-time} + voidedAt: {type: [string, 'null'], format: date-time} + voidReason: {type: [string, 'null'], maxLength: 1000} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateInvoiceRequest: + type: object + additionalProperties: false + required: [billingAccountId, invoiceNumber, currencyCode] + properties: + billingAccountId: {$ref: '#/components/schemas/Uuid'} + invoiceNumber: {type: string, minLength: 1, maxLength: 64} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + dueAt: {type: [string, 'null'], format: date-time} + UpdateInvoiceRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + billingAccountId: {$ref: '#/components/schemas/Uuid'} + invoiceNumber: {type: string, minLength: 1, maxLength: 64} + dueAt: {type: [string, 'null'], format: date-time} + IssueInvoiceCommand: + type: object + additionalProperties: false + required: [issuedAt, dueAt, attestation] + properties: + issuedAt: {$ref: '#/components/schemas/Timestamp'} + dueAt: {$ref: '#/components/schemas/Timestamp'} + attestation: {type: string, minLength: 10, maxLength: 1000} + VoidInvoiceCommand: + type: object + additionalProperties: false + required: [reason] + properties: {reason: {type: string, minLength: 3, maxLength: 1000}} + InvoiceResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Invoice'}} + InvoiceCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Invoice'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + InvoiceItemInput: + type: object + additionalProperties: false + required: [description, quantity, unitPriceMinor, position] + properties: + description: {type: string, minLength: 1, maxLength: 1000} + quantity: {type: number, exclusiveMinimum: 0, multipleOf: 0.001, description: Stored as fixed-precision NUMERIC, never binary floating point.} + unitPriceMinor: {type: integer, minimum: 0} + taxAmountMinor: {type: integer, minimum: 0, default: 0} + taxCode: {type: [string, 'null'], maxLength: 64} + position: {type: integer, minimum: 1} + CreateInvoiceItemRequest: + $ref: '#/components/schemas/InvoiceItemInput' + UpdateInvoiceItemRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + description: {type: string, minLength: 1, maxLength: 1000} + quantity: {type: number, exclusiveMinimum: 0, multipleOf: 0.001} + unitPriceMinor: {type: integer, minimum: 0} + taxAmountMinor: {type: integer, minimum: 0} + taxCode: {type: [string, 'null'], maxLength: 64} + position: {type: integer, minimum: 1} + InvoiceItem: + allOf: + - $ref: '#/components/schemas/InvoiceItemInput' + - type: object + required: [id, organizationId, invoiceId, totalAmountMinor, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + invoiceId: {$ref: '#/components/schemas/Uuid'} + totalAmountMinor: {type: integer, minimum: 0, readOnly: true} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + InvoiceItemResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InvoiceItem'}} + InvoiceItemCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/InvoiceItem'}}} + EngineeringInvoiceProject: + type: object + additionalProperties: false + required: [id, organizationId, invoiceId, projectId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + invoiceId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + LinkEngineeringInvoiceProjectRequest: + type: object + additionalProperties: false + required: [projectId] + properties: {projectId: {$ref: '#/components/schemas/Uuid'}} + EngineeringInvoiceProjectResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInvoiceProject'}} + EngineeringInvoiceProjectCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInvoiceProject'}}} + EngineeringInvoiceItemTimeEntry: + type: object + additionalProperties: false + required: [organizationId, invoiceItemId, timeEntryId] + properties: + organizationId: {$ref: '#/components/schemas/Uuid'} + invoiceItemId: {$ref: '#/components/schemas/Uuid'} + timeEntryId: {$ref: '#/components/schemas/Uuid'} + LinkEngineeringInvoiceItemTimeEntriesRequest: + type: object + additionalProperties: false + required: [timeEntryIds] + properties: + timeEntryIds: {type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: {$ref: '#/components/schemas/Uuid'}} + EngineeringInvoiceItemTimeEntryCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInvoiceItemTimeEntry'}}} + PaymentStatus: + type: string + enum: [pending, succeeded, failed, partially_refunded, refunded, voided] + PaymentMethod: + type: string + enum: [bank_transfer, card, check, cash, other] + Payment: + type: object + additionalProperties: false + required: [id, organizationId, invoiceId, amountMinor, currencyCode, paymentMethod, status, refundedAmountMinor, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + invoiceId: {$ref: '#/components/schemas/Uuid'} + amountMinor: {type: integer, minimum: 1} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + paymentMethod: {$ref: '#/components/schemas/PaymentMethod'} + status: {$ref: '#/components/schemas/PaymentStatus'} + transactionReference: {type: [string, 'null'], maxLength: 255} + refundedAmountMinor: {type: integer, minimum: 0, readOnly: true} + paidAt: {type: [string, 'null'], format: date-time} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + RecordInvoicePaymentRequest: + type: object + additionalProperties: false + required: [amountMinor, currencyCode, paymentMethod, status] + properties: + amountMinor: {type: integer, minimum: 1} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + paymentMethod: {$ref: '#/components/schemas/PaymentMethod'} + status: {type: string, enum: [pending, succeeded]} + transactionReference: {type: [string, 'null'], maxLength: 255} + paidAt: {type: [string, 'null'], format: date-time} + PaymentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Payment'}} + PaymentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/Payment'}}} + PaymentRefundStatus: + type: string + enum: [pending, succeeded, failed] + PaymentRefund: + type: object + additionalProperties: false + required: [id, organizationId, paymentId, amountMinor, status, requestedByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + paymentId: {$ref: '#/components/schemas/Uuid'} + amountMinor: {type: integer, minimum: 1} + status: {$ref: '#/components/schemas/PaymentRefundStatus'} + transactionReference: {type: [string, 'null'], maxLength: 255} + reason: {type: [string, 'null'], maxLength: 1000} + requestedByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + refundedAt: {type: [string, 'null'], format: date-time} + RefundPaymentRequest: + type: object + additionalProperties: false + required: [amountMinor, status] + properties: + amountMinor: {type: integer, minimum: 1} + status: {type: string, enum: [pending, succeeded]} + transactionReference: {type: [string, 'null'], maxLength: 255} + reason: {type: [string, 'null'], maxLength: 1000} + PaymentRefundResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/PaymentRefund'}} + 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] + EngineeringProjectRestorableStatus: + type: string + enum: [draft, closed] + EngineeringDiscipline: + type: string + enum: + - civil + - structural + - mechanical + - electrical + - geotechnical + - environmental + - transportation + - water_resources + - surveying + - multidisciplinary + - other + EngineeringProjectMemberRole: + type: string + enum: [engineer, designer, reviewer, inspector, viewer, contractor] + description: Project manager is intentionally excluded; `projectManagerUserId` is authoritative. + EngineeringProjectMemberStatus: + type: string + enum: [active, left] + description: Derived from whether `leftAt` is null. + EngineeringTaskStatus: + type: string + enum: [todo, in_progress, completed, cancelled] + EngineeringTaskPriority: + type: string + enum: [low, medium, high, urgent] + BatchExecutionMode: + type: string + enum: [atomic, partial] + + 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' + + EngineeringProject: + type: object + additionalProperties: false + required: + - id + - organizationId + - clientId + - projectNumber + - name + - description + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - archivedAt + - archivedByUserId + - archivedFromStatus + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + description: Immutable, organization-unique human project reference. + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + archivedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + archivedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + archivedFromStatus: + oneOf: + - $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + enum: [active, closed] + required: [status] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: closed + required: [status] + then: + properties: + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + enum: [draft, active] + required: [status] + then: + properties: + completedDate: + type: 'null' + - if: + properties: + status: + const: archived + required: [status] + then: + properties: + archivedAt: + $ref: '#/components/schemas/Timestamp' + archivedByUserId: + $ref: '#/components/schemas/Uuid' + archivedFromStatus: + $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + else: + properties: + archivedAt: + type: 'null' + archivedByUserId: + type: 'null' + archivedFromStatus: + type: 'null' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: closed + required: [status, archivedFromStatus] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: draft + required: [status, archivedFromStatus] + then: + properties: + completedDate: + type: 'null' + description: Expected and completed dates may not precede the start date. + CreateEngineeringProjectRequest: + type: object + additionalProperties: false + required: [clientId, projectNumber, name, discipline] + properties: + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: Expected completion date may not precede start date. + UpdateEngineeringProjectRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + clientId: + $ref: '#/components/schemas/Uuid' + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: The resulting dates and manager assignment must satisfy the project's current state rules. + ActivateEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + startDate: + $ref: '#/components/schemas/Date' + CloseEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + completedDate: + $ref: '#/components/schemas/Date' + reason: + type: string + maxLength: 500 + EngineeringProjectResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProject' + EngineeringProjectCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProject' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringProjectSummary: + type: object + additionalProperties: false + required: + - id + - clientId + - projectNumber + - name + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - version + - createdAt + - updatedAt + 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: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $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' + + EngineeringClientSummary: + type: object + additionalProperties: false + required: [id, clientType, displayName, legalName, status] + properties: + id: + $ref: '#/components/schemas/Uuid' + clientType: + $ref: '#/components/schemas/EngineeringClientType' + displayName: + type: string + legalName: + type: [string, 'null'] + status: + $ref: '#/components/schemas/EngineeringClientStatus' + EngineeringProjectActivitySummary: + type: object + additionalProperties: false + required: + - projectMemberCount + - phaseCount + - siteCount + - openTaskCount + - designCount + - designsUnderReviewCount + - inspectionCount + - upcomingInspectionCount + - documentCount + - lastActivityAt + properties: + projectMemberCount: + type: integer + minimum: 0 + description: Active participation rows; the separate project-manager pointer is not double-counted. + phaseCount: + type: integer + minimum: 0 + siteCount: + type: integer + minimum: 0 + openTaskCount: + type: integer + minimum: 0 + description: Tasks in todo or in-progress status. + designCount: + type: integer + minimum: 0 + designsUnderReviewCount: + type: integer + minimum: 0 + inspectionCount: + type: integer + minimum: 0 + upcomingInspectionCount: + type: integer + minimum: 0 + documentCount: + type: integer + minimum: 0 + lastActivityAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + EngineeringProjectDashboard: + type: object + additionalProperties: false + required: [project, client, projectManager, activity] + properties: + project: + $ref: '#/components/schemas/EngineeringProject' + client: + $ref: '#/components/schemas/EngineeringClientSummary' + projectManager: + oneOf: + - $ref: '#/components/schemas/UserSummary' + - type: 'null' + activity: + $ref: '#/components/schemas/EngineeringProjectActivitySummary' + EngineeringProjectDashboardResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectDashboard' + + EngineeringProjectMember: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - user + - projectRole + - status + - joinedAt + - leftAt + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + user: + $ref: '#/components/schemas/UserSummary' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + status: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + joinedAt: + $ref: '#/components/schemas/Timestamp' + leftAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: active + required: [status] + then: + properties: + leftAt: + type: 'null' + - if: + properties: + status: + const: left + required: [status] + then: + properties: + leftAt: + $ref: '#/components/schemas/Timestamp' + CreateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [userId, projectRole] + properties: + userId: + $ref: '#/components/schemas/Uuid' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + UpdateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [projectRole] + properties: + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + EngineeringProjectMemberResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectMember' + EngineeringProjectMemberCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProjectMember' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTask: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - title + - description + - status + - priority + - createdByUserId + - assignedToUserId + - dueAt + - startedAt + - startedByUserId + - completedAt + - completedByUserId + - cancelledAt + - cancelledByUserId + - cancellationReason + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + createdByUserId: + $ref: '#/components/schemas/Uuid' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + completedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + completedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancelledAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + cancelledByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancellationReason: + type: [string, 'null'] + maxLength: 500 + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: todo + required: [status] + then: + properties: + startedAt: {type: 'null'} + startedByUserId: {type: 'null'} + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: in_progress + required: [status] + then: + properties: + startedAt: + $ref: '#/components/schemas/Timestamp' + startedByUserId: + $ref: '#/components/schemas/Uuid' + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: completed + required: [status] + then: + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + completedByUserId: + $ref: '#/components/schemas/Uuid' + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: cancelled + required: [status] + then: + properties: + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: + $ref: '#/components/schemas/Timestamp' + cancelledByUserId: + $ref: '#/components/schemas/Uuid' + description: Terminal and start metadata are controlled exclusively by task commands. + CreateEngineeringTaskRequest: + type: object + additionalProperties: false + required: [projectId, title] + properties: + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + allOf: + - $ref: '#/components/schemas/EngineeringTaskPriority' + default: medium + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + UpdateEngineeringTaskRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + CompleteEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + description: A supplied completion time cannot be in the future or precede task creation. + CancelEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + reason: + type: string + maxLength: 500 + EngineeringTaskResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTask' + EngineeringTaskCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringTask' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTaskBatchItem: + type: object + additionalProperties: false + required: [id, version] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + BatchAssignEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, assigneeUserId, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + assigneeUserId: + $ref: '#/components/schemas/Uuid' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected even when their supplied versions differ. + BatchCompleteEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + completedAt: + $ref: '#/components/schemas/Timestamp' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected; completedAt follows the single-task completion rules. + EngineeringTaskBatchSuccess: + type: object + additionalProperties: false + required: [id, version, status, assignedToUserId] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + EngineeringTaskBatchFailure: + type: object + additionalProperties: false + required: [id, code, message, currentVersion] + properties: + id: + $ref: '#/components/schemas/Uuid' + code: + type: string + pattern: '^[A-Z][A-Z0-9_]+$' + message: + type: string + maxLength: 500 + currentVersion: + type: [integer, 'null'] + minimum: 1 + EngineeringTaskBatchResult: + type: object + additionalProperties: false + required: [mode, succeeded, failed] + properties: + mode: + $ref: '#/components/schemas/BatchExecutionMode' + succeeded: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchSuccess' + failed: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchFailure' + EngineeringTaskBatchResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTaskBatchResult' + +security: + - bearerAuth: [] diff --git a/professional-platform-openapi_2.yaml b/professional-platform-openapi_2.yaml new file mode 100644 index 0000000..6b49d90 --- /dev/null +++ b/professional-platform-openapi_2.yaml @@ -0,0 +1,3010 @@ +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: [] diff --git a/professional-platform-openapi_3.yaml b/professional-platform-openapi_3.yaml new file mode 100644 index 0000000..12ae113 --- /dev/null +++ b/professional-platform-openapi_3.yaml @@ -0,0 +1,3848 @@ +openapi: 3.1.0 +info: + title: Professional Management Platform API + version: 1.0.0-milestone.3 + summary: Platform access, RBAC, engineering clients, and engineering project management. + description: | + Executable API contract for Milestones 1 through 3 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 + - name: Engineering Projects + +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 + description: | + Rejected when the member is the last active organization Owner or manages any + active engineering project. Those responsibilities must be reassigned first. + 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' + + /engineering/projects: + get: + tags: [Engineering Projects] + operationId: listEngineeringProjects + summary: List engineering projects + description: | + Archived projects are excluded unless `status=archived` is requested explicitly. + Permission scope is enforced in the query: `assigned` resolves through active project + membership or the project-manager pointer; `organization` resolves across the tenant. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: clientId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectStatus' + - name: discipline + in: query + schema: + $ref: '#/components/schemas/EngineeringDiscipline' + - name: projectManagerUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: q + in: query + description: Case-insensitive search across project number, project name, and client name. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Supported deterministic sort. Null date values are always placed last. + schema: + type: string + enum: + - projectNumber + - -projectNumber + - name + - -name + - startDate + - -startDate + - expectedCompletionDate + - -expectedCompletionDate + - createdAt + - -createdAt + default: -createdAt + responses: + '200': + description: Engineering projects returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectCollectionResponse' + '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 Projects] + operationId: createEngineeringProject + summary: Create an engineering project in draft status + description: | + `projectNumber` is immutable and unique case-insensitively within the organization. + The referenced client must be active. A supplied project manager must have an active + membership in the same organization. + x-required-profession: engineering + x-required-permissions: [engineering.projects.create] + x-audit-action: engineering.projects.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectRequest' + responses: + '201': + description: Engineering project 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/EngineeringProjectResponse' + '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/projects/{projectId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Projects] + operationId: getEngineeringProject + summary: Get an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Engineering project returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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 Projects] + operationId: updateEngineeringProject + summary: Update editable engineering project fields + description: | + `projectNumber`, `status`, completion fields, and archive fields are not patchable. + `clientId` may change only while the project is `draft` and has no dependent records. + Changing `projectManagerUserId` changes assigned-scope access and is audited. + x-required-profession: engineering + x-required-permissions: [engineering.projects.update] + x-audit-action: engineering.projects.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectRequest' + responses: + '200': + description: Engineering project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/activate: + post: + tags: [Engineering Projects] + operationId: activateEngineeringProject + summary: Activate a draft engineering project + description: | + Transition: `draft → active`. The client and project manager must both be active. + When `startDate` is absent from both the project and request, the server uses the + current date in the organization's configured time zone. + x-required-profession: engineering + x-required-permissions: [engineering.projects.activate] + x-audit-action: engineering.projects.activate + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEngineeringProjectRequest' + responses: + '200': + description: Engineering project activated or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/close: + post: + tags: [Engineering Projects] + operationId: closeEngineeringProject + summary: Close an active engineering project + description: | + Transition: `active → closed`. When `completedDate` is omitted, the server uses + the current date in the organization's configured time zone. The completed date + cannot precede the project start date. + x-required-profession: engineering + x-required-permissions: [engineering.projects.close] + x-audit-action: engineering.projects.close + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CloseEngineeringProjectRequest' + responses: + '200': + description: Engineering project closed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/archive: + post: + tags: [Engineering Projects] + operationId: archiveEngineeringProject + summary: Archive a draft or closed engineering project + description: | + Transition: `draft|closed → archived`. Active projects must be closed first. + The prior status is retained so restore is deterministic. Related records and + audit history are never physically deleted. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.archive + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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 project archived or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/restore: + post: + tags: [Engineering Projects] + operationId: restoreEngineeringProject + summary: Restore an archived engineering project + description: | + Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`. + Restore never reactivates a project implicitly. The referenced client must be active. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.restore + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering project restored or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/summary: + get: + tags: [Engineering Projects] + operationId: getEngineeringProjectSummary + summary: Get the engineering project dashboard summary + description: | + Returns a purpose-built read model. Counts are permission-filtered and include + only records visible to the caller. Modules not yet enabled return zero counts, + not omitted fields, preserving the response shape. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + responses: + '200': + description: Project summary returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectDashboardResponse' + '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' + +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' + ProjectId: + name: projectId + 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] + EngineeringProjectRestorableStatus: + type: string + enum: [draft, closed] + EngineeringDiscipline: + type: string + enum: + - civil + - structural + - mechanical + - electrical + - geotechnical + - environmental + - transportation + - water_resources + - surveying + - multidisciplinary + - other + + 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' + + EngineeringProject: + type: object + additionalProperties: false + required: + - id + - organizationId + - clientId + - projectNumber + - name + - description + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - archivedAt + - archivedByUserId + - archivedFromStatus + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + description: Immutable, organization-unique human project reference. + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + archivedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + archivedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + archivedFromStatus: + oneOf: + - $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + enum: [active, closed] + required: [status] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: closed + required: [status] + then: + properties: + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + enum: [draft, active] + required: [status] + then: + properties: + completedDate: + type: 'null' + - if: + properties: + status: + const: archived + required: [status] + then: + properties: + archivedAt: + $ref: '#/components/schemas/Timestamp' + archivedByUserId: + $ref: '#/components/schemas/Uuid' + archivedFromStatus: + $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + else: + properties: + archivedAt: + type: 'null' + archivedByUserId: + type: 'null' + archivedFromStatus: + type: 'null' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: closed + required: [status, archivedFromStatus] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: draft + required: [status, archivedFromStatus] + then: + properties: + completedDate: + type: 'null' + description: Expected and completed dates may not precede the start date. + CreateEngineeringProjectRequest: + type: object + additionalProperties: false + required: [clientId, projectNumber, name, discipline] + properties: + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: Expected completion date may not precede start date. + UpdateEngineeringProjectRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + clientId: + $ref: '#/components/schemas/Uuid' + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: The resulting dates and manager assignment must satisfy the project's current state rules. + ActivateEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + startDate: + $ref: '#/components/schemas/Date' + CloseEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + completedDate: + $ref: '#/components/schemas/Date' + reason: + type: string + maxLength: 500 + EngineeringProjectResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProject' + EngineeringProjectCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProject' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringProjectSummary: + type: object + additionalProperties: false + required: + - id + - clientId + - projectNumber + - name + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - version + - createdAt + - updatedAt + 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: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $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' + + EngineeringClientSummary: + type: object + additionalProperties: false + required: [id, clientType, displayName, legalName, status] + properties: + id: + $ref: '#/components/schemas/Uuid' + clientType: + $ref: '#/components/schemas/EngineeringClientType' + displayName: + type: string + legalName: + type: [string, 'null'] + status: + $ref: '#/components/schemas/EngineeringClientStatus' + EngineeringProjectActivitySummary: + type: object + additionalProperties: false + required: + - projectMemberCount + - phaseCount + - siteCount + - openTaskCount + - designCount + - designsUnderReviewCount + - inspectionCount + - upcomingInspectionCount + - documentCount + - lastActivityAt + properties: + projectMemberCount: + type: integer + minimum: 0 + phaseCount: + type: integer + minimum: 0 + siteCount: + type: integer + minimum: 0 + openTaskCount: + type: integer + minimum: 0 + designCount: + type: integer + minimum: 0 + designsUnderReviewCount: + type: integer + minimum: 0 + inspectionCount: + type: integer + minimum: 0 + upcomingInspectionCount: + type: integer + minimum: 0 + documentCount: + type: integer + minimum: 0 + lastActivityAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + EngineeringProjectDashboard: + type: object + additionalProperties: false + required: [project, client, projectManager, activity] + properties: + project: + $ref: '#/components/schemas/EngineeringProject' + client: + $ref: '#/components/schemas/EngineeringClientSummary' + projectManager: + oneOf: + - $ref: '#/components/schemas/UserSummary' + - type: 'null' + activity: + $ref: '#/components/schemas/EngineeringProjectActivitySummary' + EngineeringProjectDashboardResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectDashboard' + +security: + - bearerAuth: [] diff --git a/professional-platform-openapi_4.yaml b/professional-platform-openapi_4.yaml new file mode 100644 index 0000000..f3f6dd3 --- /dev/null +++ b/professional-platform-openapi_4.yaml @@ -0,0 +1,5041 @@ +openapi: 3.1.0 +info: + title: Professional Management Platform API + version: 1.0.0-milestone.4 + summary: Platform access, engineering projects, project collaboration, and task management. + description: | + Executable API contract for Milestones 1 through 4 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 + - name: Engineering Projects + - name: Engineering Project Members + - name: Engineering Tasks + +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' + '404': + $ref: '#/components/responses/NotFound' + '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 + description: | + Rejected when the member is the last active organization Owner or manages any + active engineering project, has active project participation, or is assigned open + engineering tasks. Those responsibilities must be reassigned or ended first. + 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' + '404': + $ref: '#/components/responses/NotFound' + '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' + + /engineering/projects: + get: + tags: [Engineering Projects] + operationId: listEngineeringProjects + summary: List engineering projects + description: | + Archived projects are excluded unless `status=archived` is requested explicitly. + Permission scope is enforced in the query: `assigned` resolves through active project + membership or the project-manager pointer; `organization` resolves across the tenant. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: clientId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectStatus' + - name: discipline + in: query + schema: + $ref: '#/components/schemas/EngineeringDiscipline' + - name: projectManagerUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: q + in: query + description: Case-insensitive search across project number, project name, and client name. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Supported deterministic sort. Null date values are always placed last. + schema: + type: string + enum: + - projectNumber + - -projectNumber + - name + - -name + - startDate + - -startDate + - expectedCompletionDate + - -expectedCompletionDate + - createdAt + - -createdAt + default: -createdAt + responses: + '200': + description: Engineering projects returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectCollectionResponse' + '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 Projects] + operationId: createEngineeringProject + summary: Create an engineering project in draft status + description: | + `projectNumber` is immutable and unique case-insensitively within the organization. + The referenced client must be active. A supplied project manager must have an active + membership in the same organization. `projectManagerUserId` is the sole project-manager + authority and is not duplicated as a project-member role. + x-required-profession: engineering + x-required-permissions: [engineering.projects.create] + x-audit-action: engineering.projects.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectRequest' + responses: + '201': + description: Engineering project 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/EngineeringProjectResponse' + '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/projects/{projectId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Projects] + operationId: getEngineeringProject + summary: Get an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Engineering project returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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 Projects] + operationId: updateEngineeringProject + summary: Update editable engineering project fields + description: | + `projectNumber`, `status`, completion fields, and archive fields are not patchable. + `clientId` may change only while the project is `draft` and has no dependent records. + Changing `projectManagerUserId` changes assigned-scope access and is audited. It does + not create a duplicate `project_manager` project-member role. Open tasks assigned to + the outgoing manager must first be reassigned unless that user remains an active member. + x-required-profession: engineering + x-required-permissions: [engineering.projects.update] + x-audit-action: engineering.projects.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectRequest' + responses: + '200': + description: Engineering project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/activate: + post: + tags: [Engineering Projects] + operationId: activateEngineeringProject + summary: Activate a draft engineering project + description: | + Transition: `draft → active`. The client and project manager must both be active. + When `startDate` is absent from both the project and request, the server uses the + current date in the organization's configured time zone. + x-required-profession: engineering + x-required-permissions: [engineering.projects.activate] + x-audit-action: engineering.projects.activate + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEngineeringProjectRequest' + responses: + '200': + description: Engineering project activated or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/close: + post: + tags: [Engineering Projects] + operationId: closeEngineeringProject + summary: Close an active engineering project + description: | + Transition: `active → closed`. When `completedDate` is omitted, the server uses + the current date in the organization's configured time zone. The completed date + cannot precede the project start date. Every task must already be `completed` or + `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.close] + x-audit-action: engineering.projects.close + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CloseEngineeringProjectRequest' + responses: + '200': + description: Engineering project closed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/archive: + post: + tags: [Engineering Projects] + operationId: archiveEngineeringProject + summary: Archive a draft or closed engineering project + description: | + Transition: `draft|closed → archived`. Active projects must be closed first. + The prior status is retained so restore is deterministic. Related records and + audit history are never physically deleted. Every task must already be `completed` + or `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.archive + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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 project archived or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/restore: + post: + tags: [Engineering Projects] + operationId: restoreEngineeringProject + summary: Restore an archived engineering project + description: | + Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`. + Restore never reactivates a project implicitly. The referenced client must be active. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.restore + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering project restored or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/summary: + get: + tags: [Engineering Projects] + operationId: getEngineeringProjectSummary + summary: Get the engineering project dashboard summary + description: | + Returns a purpose-built read model. Counts are permission-filtered and include + only records visible to the caller. Modules not yet enabled return zero counts, + not omitted fields, preserving the response shape. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + responses: + '200': + description: Project summary returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectDashboardResponse' + '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' + + /engineering/projects/{projectId}/members: + get: + tags: [Engineering Project Members] + operationId: listEngineeringProjectMembers + summary: List temporal project-member records + description: By default, only active participation records are returned. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + - name: projectRole + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + - name: userId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: sort + in: query + schema: + type: string + enum: [joinedAt, -joinedAt, name, -name] + default: name + responses: + '200': + description: Project-member records returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberCollectionResponse' + '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 Project Members] + operationId: addEngineeringProjectMember + summary: Add an active organization member to a project + description: | + The project must be `draft` or `active`. The user must have an active organization + membership. Rejoining after departure creates a new temporal row. Only one active + row may exist for a user in a project. Project-manager assignment is controlled by + `projectManagerUserId`, not by this endpoint. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.add + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectMemberRequest' + responses: + '201': + description: Project member added. + 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/EngineeringProjectMemberResponse' + '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/projects/{projectId}/members/{memberId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ProjectMemberId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Project Members] + operationId: getEngineeringProjectMember + summary: Get a project-member record + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Project-member record returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: updateEngineeringProjectMember + summary: Change the participation role of an active project member + description: Only `projectRole` is patchable in v1. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectMemberRequest' + responses: + '200': + description: Participation role updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: endEngineeringProjectMembership + summary: End a user's project participation + description: | + Sets `leftAt`; it never deletes history. Repeating the command with the same + idempotency key replays the original 204 response. Open tasks assigned to the + user must be reassigned or unassigned first. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.remove + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Project participation ended or idempotent result replayed. + 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/tasks: + get: + tags: [Engineering Tasks] + operationId: listEngineeringTasks + summary: List engineering tasks + description: | + Permission scope is enforced per task. Assigned scope resolves when the caller is + the task assignee, an active member of the parent project, or its project manager. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskStatus' + - name: priority + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskPriority' + - name: assignedToUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: assignmentStatus + in: query + schema: + type: string + enum: [assigned, unassigned, any] + default: any + - name: dueBefore + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: dueAfter + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: q + in: query + description: Case-insensitive search across task title and description. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Null due dates are always placed last. + schema: + type: string + enum: [createdAt, -createdAt, dueAt, -dueAt, priority, -priority] + default: -createdAt + responses: + '200': + description: Engineering tasks returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskCollectionResponse' + '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 Tasks] + operationId: createEngineeringTask + summary: Create a task in todo status + description: | + The project must be `draft` or `active`. A supplied assignee must be the project + manager or an active project member and must retain an active organization membership. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringTaskRequest' + responses: + '201': + description: Engineering task 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/EngineeringTaskResponse' + '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/tasks/{taskId}: + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Tasks] + operationId: getEngineeringTask + summary: Get an engineering task + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + responses: + '200': + description: Engineering task returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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 Tasks] + operationId: updateEngineeringTask + summary: Update mutable task fields + description: | + `projectId`, status, creator, and terminal metadata are immutable through PATCH. + Assignment changes revalidate active organization and project participation. + Completed and cancelled tasks must be reopened before they can be edited. The parent + project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringTaskRequest' + responses: + '200': + description: Engineering task updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/start: + post: + tags: [Engineering Tasks] + operationId: startEngineeringTask + summary: Start a todo task + description: 'Transition: `todo → in_progress`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.start + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering task started or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/complete: + post: + tags: [Engineering Tasks] + operationId: completeEngineeringTask + summary: Complete a todo or in-progress task + description: 'Transition: `todo|in_progress → completed`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.complete + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteEngineeringTaskRequest' + responses: + '200': + description: Engineering task completed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/reopen: + post: + tags: [Engineering Tasks] + operationId: reopenEngineeringTask + summary: Reopen a completed or cancelled task + description: | + Transition: `completed|cancelled → todo`. Completion and cancellation metadata + plus any prior start metadata are cleared, while their prior values remain available + through audit history. The parent project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.reopen + parameters: + - $ref: '#/components/parameters/TaskId' + - $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 task reopened or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/cancel: + post: + tags: [Engineering Tasks] + operationId: cancelEngineeringTask + summary: Cancel a todo or in-progress task + description: 'Transition: `todo|in_progress → cancelled`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.cancel + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CancelEngineeringTaskRequest' + responses: + '200': + description: Engineering task cancelled or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/batch/assign: + post: + tags: [Engineering Tasks] + operationId: batchAssignEngineeringTasks + summary: Assign multiple tasks + description: | + Every item carries its expected version and is independently tenant-, permission-, + scope-, project-, assignee-, and state-validated. Atomic mode rolls back all items + on any failure. Partial mode commits valid items and returns per-item failures. + Only `todo` and `in_progress` tasks may be assigned, and the assignee must be an + active participant or project manager for every affected project. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_assign + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchAssignEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/tasks/batch/complete: + post: + tags: [Engineering Tasks] + operationId: batchCompleteEngineeringTasks + summary: Complete multiple tasks + description: | + Every item carries its expected version and is independently authorized and + state-validated. Atomic and partial modes follow the same semantics as batch assign. + Only `todo` and `in_progress` tasks may be completed. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_complete + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCompleteEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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' + +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' + ProjectId: + name: projectId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + ProjectMemberId: + name: memberId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + TaskId: + name: taskId + 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] + EngineeringProjectRestorableStatus: + type: string + enum: [draft, closed] + EngineeringDiscipline: + type: string + enum: + - civil + - structural + - mechanical + - electrical + - geotechnical + - environmental + - transportation + - water_resources + - surveying + - multidisciplinary + - other + EngineeringProjectMemberRole: + type: string + enum: [engineer, designer, reviewer, inspector, viewer, contractor] + description: Project manager is intentionally excluded; `projectManagerUserId` is authoritative. + EngineeringProjectMemberStatus: + type: string + enum: [active, left] + description: Derived from whether `leftAt` is null. + EngineeringTaskStatus: + type: string + enum: [todo, in_progress, completed, cancelled] + EngineeringTaskPriority: + type: string + enum: [low, medium, high, urgent] + BatchExecutionMode: + type: string + enum: [atomic, partial] + + 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' + + EngineeringProject: + type: object + additionalProperties: false + required: + - id + - organizationId + - clientId + - projectNumber + - name + - description + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - archivedAt + - archivedByUserId + - archivedFromStatus + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + description: Immutable, organization-unique human project reference. + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + archivedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + archivedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + archivedFromStatus: + oneOf: + - $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + enum: [active, closed] + required: [status] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: closed + required: [status] + then: + properties: + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + enum: [draft, active] + required: [status] + then: + properties: + completedDate: + type: 'null' + - if: + properties: + status: + const: archived + required: [status] + then: + properties: + archivedAt: + $ref: '#/components/schemas/Timestamp' + archivedByUserId: + $ref: '#/components/schemas/Uuid' + archivedFromStatus: + $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + else: + properties: + archivedAt: + type: 'null' + archivedByUserId: + type: 'null' + archivedFromStatus: + type: 'null' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: closed + required: [status, archivedFromStatus] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: draft + required: [status, archivedFromStatus] + then: + properties: + completedDate: + type: 'null' + description: Expected and completed dates may not precede the start date. + CreateEngineeringProjectRequest: + type: object + additionalProperties: false + required: [clientId, projectNumber, name, discipline] + properties: + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: Expected completion date may not precede start date. + UpdateEngineeringProjectRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + clientId: + $ref: '#/components/schemas/Uuid' + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: The resulting dates and manager assignment must satisfy the project's current state rules. + ActivateEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + startDate: + $ref: '#/components/schemas/Date' + CloseEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + completedDate: + $ref: '#/components/schemas/Date' + reason: + type: string + maxLength: 500 + EngineeringProjectResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProject' + EngineeringProjectCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProject' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringProjectSummary: + type: object + additionalProperties: false + required: + - id + - clientId + - projectNumber + - name + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - version + - createdAt + - updatedAt + 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: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $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' + + EngineeringClientSummary: + type: object + additionalProperties: false + required: [id, clientType, displayName, legalName, status] + properties: + id: + $ref: '#/components/schemas/Uuid' + clientType: + $ref: '#/components/schemas/EngineeringClientType' + displayName: + type: string + legalName: + type: [string, 'null'] + status: + $ref: '#/components/schemas/EngineeringClientStatus' + EngineeringProjectActivitySummary: + type: object + additionalProperties: false + required: + - projectMemberCount + - phaseCount + - siteCount + - openTaskCount + - designCount + - designsUnderReviewCount + - inspectionCount + - upcomingInspectionCount + - documentCount + - lastActivityAt + properties: + projectMemberCount: + type: integer + minimum: 0 + description: Active participation rows; the separate project-manager pointer is not double-counted. + phaseCount: + type: integer + minimum: 0 + siteCount: + type: integer + minimum: 0 + openTaskCount: + type: integer + minimum: 0 + description: Tasks in todo or in-progress status. + designCount: + type: integer + minimum: 0 + designsUnderReviewCount: + type: integer + minimum: 0 + inspectionCount: + type: integer + minimum: 0 + upcomingInspectionCount: + type: integer + minimum: 0 + documentCount: + type: integer + minimum: 0 + lastActivityAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + EngineeringProjectDashboard: + type: object + additionalProperties: false + required: [project, client, projectManager, activity] + properties: + project: + $ref: '#/components/schemas/EngineeringProject' + client: + $ref: '#/components/schemas/EngineeringClientSummary' + projectManager: + oneOf: + - $ref: '#/components/schemas/UserSummary' + - type: 'null' + activity: + $ref: '#/components/schemas/EngineeringProjectActivitySummary' + EngineeringProjectDashboardResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectDashboard' + + EngineeringProjectMember: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - user + - projectRole + - status + - joinedAt + - leftAt + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + user: + $ref: '#/components/schemas/UserSummary' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + status: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + joinedAt: + $ref: '#/components/schemas/Timestamp' + leftAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: active + required: [status] + then: + properties: + leftAt: + type: 'null' + - if: + properties: + status: + const: left + required: [status] + then: + properties: + leftAt: + $ref: '#/components/schemas/Timestamp' + CreateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [userId, projectRole] + properties: + userId: + $ref: '#/components/schemas/Uuid' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + UpdateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [projectRole] + properties: + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + EngineeringProjectMemberResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectMember' + EngineeringProjectMemberCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProjectMember' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTask: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - title + - description + - status + - priority + - createdByUserId + - assignedToUserId + - dueAt + - startedAt + - startedByUserId + - completedAt + - completedByUserId + - cancelledAt + - cancelledByUserId + - cancellationReason + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + createdByUserId: + $ref: '#/components/schemas/Uuid' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + completedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + completedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancelledAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + cancelledByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancellationReason: + type: [string, 'null'] + maxLength: 500 + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: todo + required: [status] + then: + properties: + startedAt: {type: 'null'} + startedByUserId: {type: 'null'} + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: in_progress + required: [status] + then: + properties: + startedAt: + $ref: '#/components/schemas/Timestamp' + startedByUserId: + $ref: '#/components/schemas/Uuid' + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: completed + required: [status] + then: + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + completedByUserId: + $ref: '#/components/schemas/Uuid' + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: cancelled + required: [status] + then: + properties: + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: + $ref: '#/components/schemas/Timestamp' + cancelledByUserId: + $ref: '#/components/schemas/Uuid' + description: Terminal and start metadata are controlled exclusively by task commands. + CreateEngineeringTaskRequest: + type: object + additionalProperties: false + required: [projectId, title] + properties: + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + allOf: + - $ref: '#/components/schemas/EngineeringTaskPriority' + default: medium + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + UpdateEngineeringTaskRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + CompleteEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + description: A supplied completion time cannot be in the future or precede task creation. + CancelEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + reason: + type: string + maxLength: 500 + EngineeringTaskResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTask' + EngineeringTaskCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringTask' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTaskBatchItem: + type: object + additionalProperties: false + required: [id, version] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + BatchAssignEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, assigneeUserId, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + assigneeUserId: + $ref: '#/components/schemas/Uuid' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected even when their supplied versions differ. + BatchCompleteEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + completedAt: + $ref: '#/components/schemas/Timestamp' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected; completedAt follows the single-task completion rules. + EngineeringTaskBatchSuccess: + type: object + additionalProperties: false + required: [id, version, status, assignedToUserId] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + EngineeringTaskBatchFailure: + type: object + additionalProperties: false + required: [id, code, message, currentVersion] + properties: + id: + $ref: '#/components/schemas/Uuid' + code: + type: string + pattern: '^[A-Z][A-Z0-9_]+$' + message: + type: string + maxLength: 500 + currentVersion: + type: [integer, 'null'] + minimum: 1 + EngineeringTaskBatchResult: + type: object + additionalProperties: false + required: [mode, succeeded, failed] + properties: + mode: + $ref: '#/components/schemas/BatchExecutionMode' + succeeded: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchSuccess' + failed: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchFailure' + EngineeringTaskBatchResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTaskBatchResult' + +security: + - bearerAuth: [] diff --git a/professional-platform-openapi_5.yaml b/professional-platform-openapi_5.yaml new file mode 100644 index 0000000..eeccfcd --- /dev/null +++ b/professional-platform-openapi_5.yaml @@ -0,0 +1,5737 @@ +openapi: 3.1.0 +info: + title: Professional Management Platform API + version: 1.0.0-milestone.5 + summary: Platform access, engineering collaboration, sites, and governed documents. + description: | + Executable API contract for Milestones 1 through 4 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 + - name: Engineering Projects + - name: Engineering Project Members + - name: Engineering Tasks + - name: Engineering Sites + - name: Documents + - name: Engineering Project Documents + +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' + '404': + $ref: '#/components/responses/NotFound' + '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 + description: | + Rejected when the member is the last active organization Owner or manages any + active engineering project, has active project participation, or is assigned open + engineering tasks. Those responsibilities must be reassigned or ended first. + 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' + '404': + $ref: '#/components/responses/NotFound' + '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' + + /engineering/projects: + get: + tags: [Engineering Projects] + operationId: listEngineeringProjects + summary: List engineering projects + description: | + Archived projects are excluded unless `status=archived` is requested explicitly. + Permission scope is enforced in the query: `assigned` resolves through active project + membership or the project-manager pointer; `organization` resolves across the tenant. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: clientId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectStatus' + - name: discipline + in: query + schema: + $ref: '#/components/schemas/EngineeringDiscipline' + - name: projectManagerUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: q + in: query + description: Case-insensitive search across project number, project name, and client name. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Supported deterministic sort. Null date values are always placed last. + schema: + type: string + enum: + - projectNumber + - -projectNumber + - name + - -name + - startDate + - -startDate + - expectedCompletionDate + - -expectedCompletionDate + - createdAt + - -createdAt + default: -createdAt + responses: + '200': + description: Engineering projects returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectCollectionResponse' + '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 Projects] + operationId: createEngineeringProject + summary: Create an engineering project in draft status + description: | + `projectNumber` is immutable and unique case-insensitively within the organization. + The referenced client must be active. A supplied project manager must have an active + membership in the same organization. `projectManagerUserId` is the sole project-manager + authority and is not duplicated as a project-member role. + x-required-profession: engineering + x-required-permissions: [engineering.projects.create] + x-audit-action: engineering.projects.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectRequest' + responses: + '201': + description: Engineering project 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/EngineeringProjectResponse' + '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/projects/{projectId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Projects] + operationId: getEngineeringProject + summary: Get an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Engineering project returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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 Projects] + operationId: updateEngineeringProject + summary: Update editable engineering project fields + description: | + `projectNumber`, `status`, completion fields, and archive fields are not patchable. + `clientId` may change only while the project is `draft` and has no dependent records. + Changing `projectManagerUserId` changes assigned-scope access and is audited. It does + not create a duplicate `project_manager` project-member role. Open tasks assigned to + the outgoing manager must first be reassigned unless that user remains an active member. + x-required-profession: engineering + x-required-permissions: [engineering.projects.update] + x-audit-action: engineering.projects.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectRequest' + responses: + '200': + description: Engineering project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/activate: + post: + tags: [Engineering Projects] + operationId: activateEngineeringProject + summary: Activate a draft engineering project + description: | + Transition: `draft → active`. The client and project manager must both be active. + When `startDate` is absent from both the project and request, the server uses the + current date in the organization's configured time zone. + x-required-profession: engineering + x-required-permissions: [engineering.projects.activate] + x-audit-action: engineering.projects.activate + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEngineeringProjectRequest' + responses: + '200': + description: Engineering project activated or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/close: + post: + tags: [Engineering Projects] + operationId: closeEngineeringProject + summary: Close an active engineering project + description: | + Transition: `active → closed`. When `completedDate` is omitted, the server uses + the current date in the organization's configured time zone. The completed date + cannot precede the project start date. Every task must already be `completed` or + `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.close] + x-audit-action: engineering.projects.close + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CloseEngineeringProjectRequest' + responses: + '200': + description: Engineering project closed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/archive: + post: + tags: [Engineering Projects] + operationId: archiveEngineeringProject + summary: Archive a draft or closed engineering project + description: | + Transition: `draft|closed → archived`. Active projects must be closed first. + The prior status is retained so restore is deterministic. Related records and + audit history are never physically deleted. Every task must already be `completed` + or `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.archive + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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 project archived or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/restore: + post: + tags: [Engineering Projects] + operationId: restoreEngineeringProject + summary: Restore an archived engineering project + description: | + Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`. + Restore never reactivates a project implicitly. The referenced client must be active. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.restore + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering project restored or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/summary: + get: + tags: [Engineering Projects] + operationId: getEngineeringProjectSummary + summary: Get the engineering project dashboard summary + description: | + Returns a purpose-built read model. Counts are permission-filtered and include + only records visible to the caller. Modules not yet enabled return zero counts, + not omitted fields, preserving the response shape. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + responses: + '200': + description: Project summary returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectDashboardResponse' + '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' + + /engineering/projects/{projectId}/members: + get: + tags: [Engineering Project Members] + operationId: listEngineeringProjectMembers + summary: List temporal project-member records + description: By default, only active participation records are returned. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + - name: projectRole + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + - name: userId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: sort + in: query + schema: + type: string + enum: [joinedAt, -joinedAt, name, -name] + default: name + responses: + '200': + description: Project-member records returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberCollectionResponse' + '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 Project Members] + operationId: addEngineeringProjectMember + summary: Add an active organization member to a project + description: | + The project must be `draft` or `active`. The user must have an active organization + membership. Rejoining after departure creates a new temporal row. Only one active + row may exist for a user in a project. Project-manager assignment is controlled by + `projectManagerUserId`, not by this endpoint. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.add + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectMemberRequest' + responses: + '201': + description: Project member added. + 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/EngineeringProjectMemberResponse' + '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/projects/{projectId}/members/{memberId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ProjectMemberId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Project Members] + operationId: getEngineeringProjectMember + summary: Get a project-member record + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Project-member record returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: updateEngineeringProjectMember + summary: Change the participation role of an active project member + description: Only `projectRole` is patchable in v1. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectMemberRequest' + responses: + '200': + description: Participation role updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: endEngineeringProjectMembership + summary: End a user's project participation + description: | + Sets `leftAt`; it never deletes history. Repeating the command with the same + idempotency key replays the original 204 response. Open tasks assigned to the + user must be reassigned or unassigned first. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.remove + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Project participation ended or idempotent result replayed. + 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/tasks: + get: + tags: [Engineering Tasks] + operationId: listEngineeringTasks + summary: List engineering tasks + description: | + Permission scope is enforced per task. Assigned scope resolves when the caller is + the task assignee, an active member of the parent project, or its project manager. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskStatus' + - name: priority + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskPriority' + - name: assignedToUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: assignmentStatus + in: query + schema: + type: string + enum: [assigned, unassigned, any] + default: any + - name: dueBefore + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: dueAfter + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: q + in: query + description: Case-insensitive search across task title and description. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Null due dates are always placed last. + schema: + type: string + enum: [createdAt, -createdAt, dueAt, -dueAt, priority, -priority] + default: -createdAt + responses: + '200': + description: Engineering tasks returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskCollectionResponse' + '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 Tasks] + operationId: createEngineeringTask + summary: Create a task in todo status + description: | + The project must be `draft` or `active`. A supplied assignee must be the project + manager or an active project member and must retain an active organization membership. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringTaskRequest' + responses: + '201': + description: Engineering task 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/EngineeringTaskResponse' + '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/tasks/{taskId}: + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Tasks] + operationId: getEngineeringTask + summary: Get an engineering task + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + responses: + '200': + description: Engineering task returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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 Tasks] + operationId: updateEngineeringTask + summary: Update mutable task fields + description: | + `projectId`, status, creator, and terminal metadata are immutable through PATCH. + Assignment changes revalidate active organization and project participation. + Completed and cancelled tasks must be reopened before they can be edited. The parent + project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringTaskRequest' + responses: + '200': + description: Engineering task updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/start: + post: + tags: [Engineering Tasks] + operationId: startEngineeringTask + summary: Start a todo task + description: 'Transition: `todo → in_progress`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.start + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering task started or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/complete: + post: + tags: [Engineering Tasks] + operationId: completeEngineeringTask + summary: Complete a todo or in-progress task + description: 'Transition: `todo|in_progress → completed`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.complete + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteEngineeringTaskRequest' + responses: + '200': + description: Engineering task completed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/reopen: + post: + tags: [Engineering Tasks] + operationId: reopenEngineeringTask + summary: Reopen a completed or cancelled task + description: | + Transition: `completed|cancelled → todo`. Completion and cancellation metadata + plus any prior start metadata are cleared, while their prior values remain available + through audit history. The parent project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.reopen + parameters: + - $ref: '#/components/parameters/TaskId' + - $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 task reopened or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/cancel: + post: + tags: [Engineering Tasks] + operationId: cancelEngineeringTask + summary: Cancel a todo or in-progress task + description: 'Transition: `todo|in_progress → cancelled`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.cancel + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CancelEngineeringTaskRequest' + responses: + '200': + description: Engineering task cancelled or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/batch/assign: + post: + tags: [Engineering Tasks] + operationId: batchAssignEngineeringTasks + summary: Assign multiple tasks + description: | + Every item carries its expected version and is independently tenant-, permission-, + scope-, project-, assignee-, and state-validated. Atomic mode rolls back all items + on any failure. Partial mode commits valid items and returns per-item failures. + Only `todo` and `in_progress` tasks may be assigned, and the assignee must be an + active participant or project manager for every affected project. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_assign + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchAssignEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/tasks/batch/complete: + post: + tags: [Engineering Tasks] + operationId: batchCompleteEngineeringTasks + summary: Complete multiple tasks + description: | + Every item carries its expected version and is independently authorized and + state-validated. Atomic and partial modes follow the same semantics as batch assign. + Only `todo` and `in_progress` tasks may be completed. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_complete + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCompleteEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/sites: + get: + tags: [Engineering Sites] + operationId: listEngineeringSites + summary: List engineering sites across the active organization + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Sites visible to the caller. + headers: {X-Request-Id: {$ref: '#/components/headers/RequestId'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + + /engineering/projects/{projectId}/sites: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Sites] + operationId: listEngineeringProjectSites + summary: List sites for one project + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: Project sites. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Sites] + operationId: createEngineeringProjectSite + summary: Create a site within a project + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringSiteRequest'}}} + responses: + '201': + description: Site created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/sites/{siteId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SiteId' + get: + tags: [Engineering Sites] + operationId: getEngineeringSite + summary: Retrieve an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + responses: + '200': + description: Site. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Sites] + operationId: updateEngineeringSite + summary: Update an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringSiteRequest'}}} + responses: + '200': + description: Site updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents: + get: + tags: [Documents] + operationId: listDocuments + summary: List document metadata + description: Quarantined and infected versions are excluded unless the caller has documents.security_review. + x-required-permissions: [documents.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: classification + in: query + schema: {$ref: '#/components/schemas/DocumentClassification'} + - name: categoryId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Document metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCollectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + + /documents/upload-url: + post: + tags: [Documents] + operationId: createDocumentUploadUrl + summary: Initialize a single-part document upload + description: Creates quarantined document and version metadata, then returns a short-lived signed PUT URL. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: getDocument + summary: Retrieve document metadata + x-required-permissions: [documents.read] + responses: + '200': + description: Document metadata. No storage key or unsigned object URL is exposed. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Documents] + operationId: updateDocumentMetadata + summary: Update mutable document metadata + description: Classification cannot be weakened below the linked domain record's required classification. + x-required-permissions: [documents.manage] + x-audit-action: document.metadata_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateDocumentRequest'}}} + responses: + '200': + description: Document updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/complete-upload: + post: + tags: [Documents] + operationId: completeDocumentUpload + summary: Verify a single-part upload and enqueue malware inspection + description: Completion changes uploadStatus to completed and scanStatus to pending; it never makes the file downloadable. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteDocumentUploadRequest'}}} + responses: + '202': + description: Object verified and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: listDocumentVersions + summary: List immutable document versions + x-required-permissions: [documents.read] + responses: + '200': + description: Version metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Documents] + operationId: initializeNewDocumentVersion + summary: Initialize a new single-part version upload + description: The current version pointer changes only after upload verification and a clean scan. + x-required-permissions: [documents.upload] + x-audit-action: document.version_upload_initialized + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentVersionRequest'}}} + responses: + '201': + description: Version upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/download-url: + post: + tags: [Documents] + operationId: createDocumentDownloadUrl + summary: Create a short-lived download URL for a clean version + description: Infected, pending, failed, or quarantined versions are never downloadable. + x-required-permissions: [documents.download] + x-audit-action: document.download_authorized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateDocumentDownloadRequest'}}} + responses: + '200': + description: Short-lived download authorization. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentDownloadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /documents/multipart-uploads: + post: + tags: [Documents] + operationId: initializeMultipartDocumentUpload + summary: Initialize a multipart document upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Multipart upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeMultipartUploadResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/parts: + post: + tags: [Documents] + operationId: createMultipartPartUploadUrls + summary: Create signed URLs for selected multipart parts + x-required-permissions: [documents.upload] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsRequest'}}} + responses: + '200': + description: Signed part URLs. + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/complete: + post: + tags: [Documents] + operationId: completeMultipartDocumentUpload + summary: Assemble multipart upload and enqueue malware inspection + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteMultipartUploadRequest'}}} + responses: + '202': + description: Multipart object assembled and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}: + delete: + tags: [Documents] + operationId: abortMultipartDocumentUpload + summary: Abort an unfinished multipart upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_aborted + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Upload aborted; staged object parts are scheduled for cleanup.} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Documents] + operationId: listEngineeringProjectDocuments + summary: List active project-document links + x-required-profession: engineering + x-required-permissions: [engineering.documents.read] + responses: + '200': + description: Project documents filtered by document authorization and scan state. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Documents] + operationId: linkEngineeringProjectDocument + summary: Link a clean shared document to a project + description: Pending, failed, or infected versions cannot be linked as the active project document. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringProjectDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/project-documents/{documentLinkId}: + delete: + tags: [Engineering Project Documents] + operationId: unlinkEngineeringProjectDocument + summary: Temporally unlink a document from a project + description: Sets unlinkedAt; it does not delete the shared document or its versions. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + +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' + ProjectId: + name: projectId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + ProjectMemberId: + name: memberId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + TaskId: + name: taskId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + SiteId: + name: siteId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentId: + name: documentId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + UploadId: + name: uploadId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentLinkId: + name: documentLinkId + 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] + EngineeringSite: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, address, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + EngineeringSiteAddress: + type: object + additionalProperties: false + required: [line1, city, countryCode] + properties: + line1: {type: string, minLength: 1, maxLength: 200} + line2: {type: [string, 'null'], maxLength: 200} + city: {type: string, minLength: 1, maxLength: 120} + region: {type: [string, 'null'], maxLength: 120} + postalCode: {type: [string, 'null'], maxLength: 32} + countryCode: {$ref: '#/components/schemas/CountryCode'} + CreateEngineeringSiteRequest: + type: object + additionalProperties: false + required: [name, address] + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + UpdateEngineeringSiteRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + EngineeringSiteResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSite'}} + EngineeringSiteCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringSite'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentClassification: + type: string + enum: [public, internal, confidential, restricted, regulated] + DocumentUploadStatus: + type: string + enum: [initialized, uploading, completed, failed, aborted, expired] + MalwareScanStatus: + type: string + enum: [not_started, pending, scanning, clean, infected, failed] + Document: + type: object + additionalProperties: false + required: [id, organizationId, name, classification, currentVersionId, version, createdByUserId, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersion: {oneOf: [{$ref: '#/components/schemas/DocumentVersion'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + DocumentVersion: + type: object + additionalProperties: false + required: [id, organizationId, documentId, versionNumber, mimeType, sizeBytes, uploadStatus, scanStatus, uploadedByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + hashAlgorithm: {type: string, const: sha256} + uploadStatus: {$ref: '#/components/schemas/DocumentUploadStatus'} + scanStatus: {$ref: '#/components/schemas/MalwareScanStatus'} + scanCompletedAt: {type: [string, 'null'], format: date-time} + available: {type: boolean, readOnly: true, description: True only when uploadStatus is completed and scanStatus is clean.} + uploadedByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadRequest: + type: object + additionalProperties: false + required: [name, classification, mimeType, sizeBytes] + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentVersionRequest: + type: object + additionalProperties: false + required: [mimeType, sizeBytes] + properties: + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + UpdateDocumentRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + CompleteDocumentUploadRequest: + type: object + additionalProperties: false + required: [documentVersionId, contentHash] + properties: + documentVersionId: {$ref: '#/components/schemas/Uuid'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadUrl, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadUrl: {type: string, format: uri} + requiredHeaders: {type: object, additionalProperties: {type: string}} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeDocumentUploadData'}} + InitializeMultipartUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadId, recommendedPartSizeBytes, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadId: {$ref: '#/components/schemas/Uuid'} + recommendedPartSizeBytes: {type: integer, minimum: 5242880} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeMultipartUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeMultipartUploadData'}} + MultipartPartUrlsRequest: + type: object + additionalProperties: false + required: [partNumbers] + properties: + partNumbers: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: {type: integer, minimum: 1, maximum: 10000} + MultipartPartUploadUrl: + type: object + required: [partNumber, uploadUrl, expiresAt] + properties: + partNumber: {type: integer, minimum: 1} + uploadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + MultipartPartUrlsResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/MultipartPartUploadUrl'}}} + CompletedMultipartPart: + type: object + additionalProperties: false + required: [partNumber, etag] + properties: + partNumber: {type: integer, minimum: 1} + etag: {type: string, minLength: 1, maxLength: 200} + CompleteMultipartUploadRequest: + type: object + additionalProperties: false + required: [parts, contentHash] + properties: + parts: + type: array + minItems: 1 + maxItems: 10000 + items: {$ref: '#/components/schemas/CompletedMultipartPart'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + CreateDocumentDownloadRequest: + type: object + additionalProperties: false + properties: + documentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}], description: Defaults to the current clean version.} + DocumentDownloadData: + type: object + required: [downloadUrl, expiresAt] + properties: + downloadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + DocumentDownloadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentDownloadData'}} + DocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Document'}} + DocumentCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Document'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentVersion'}} + DocumentVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/DocumentVersion'}}} + EngineeringProjectDocumentLink: + type: object + additionalProperties: false + required: [id, organizationId, projectId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringProjectDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + EngineeringProjectDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}} + EngineeringProjectDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}}} + 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] + EngineeringProjectRestorableStatus: + type: string + enum: [draft, closed] + EngineeringDiscipline: + type: string + enum: + - civil + - structural + - mechanical + - electrical + - geotechnical + - environmental + - transportation + - water_resources + - surveying + - multidisciplinary + - other + EngineeringProjectMemberRole: + type: string + enum: [engineer, designer, reviewer, inspector, viewer, contractor] + description: Project manager is intentionally excluded; `projectManagerUserId` is authoritative. + EngineeringProjectMemberStatus: + type: string + enum: [active, left] + description: Derived from whether `leftAt` is null. + EngineeringTaskStatus: + type: string + enum: [todo, in_progress, completed, cancelled] + EngineeringTaskPriority: + type: string + enum: [low, medium, high, urgent] + BatchExecutionMode: + type: string + enum: [atomic, partial] + + 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' + + EngineeringProject: + type: object + additionalProperties: false + required: + - id + - organizationId + - clientId + - projectNumber + - name + - description + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - archivedAt + - archivedByUserId + - archivedFromStatus + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + description: Immutable, organization-unique human project reference. + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + archivedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + archivedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + archivedFromStatus: + oneOf: + - $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + enum: [active, closed] + required: [status] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: closed + required: [status] + then: + properties: + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + enum: [draft, active] + required: [status] + then: + properties: + completedDate: + type: 'null' + - if: + properties: + status: + const: archived + required: [status] + then: + properties: + archivedAt: + $ref: '#/components/schemas/Timestamp' + archivedByUserId: + $ref: '#/components/schemas/Uuid' + archivedFromStatus: + $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + else: + properties: + archivedAt: + type: 'null' + archivedByUserId: + type: 'null' + archivedFromStatus: + type: 'null' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: closed + required: [status, archivedFromStatus] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: draft + required: [status, archivedFromStatus] + then: + properties: + completedDate: + type: 'null' + description: Expected and completed dates may not precede the start date. + CreateEngineeringProjectRequest: + type: object + additionalProperties: false + required: [clientId, projectNumber, name, discipline] + properties: + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: Expected completion date may not precede start date. + UpdateEngineeringProjectRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + clientId: + $ref: '#/components/schemas/Uuid' + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: The resulting dates and manager assignment must satisfy the project's current state rules. + ActivateEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + startDate: + $ref: '#/components/schemas/Date' + CloseEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + completedDate: + $ref: '#/components/schemas/Date' + reason: + type: string + maxLength: 500 + EngineeringProjectResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProject' + EngineeringProjectCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProject' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringProjectSummary: + type: object + additionalProperties: false + required: + - id + - clientId + - projectNumber + - name + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - version + - createdAt + - updatedAt + 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: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $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' + + EngineeringClientSummary: + type: object + additionalProperties: false + required: [id, clientType, displayName, legalName, status] + properties: + id: + $ref: '#/components/schemas/Uuid' + clientType: + $ref: '#/components/schemas/EngineeringClientType' + displayName: + type: string + legalName: + type: [string, 'null'] + status: + $ref: '#/components/schemas/EngineeringClientStatus' + EngineeringProjectActivitySummary: + type: object + additionalProperties: false + required: + - projectMemberCount + - phaseCount + - siteCount + - openTaskCount + - designCount + - designsUnderReviewCount + - inspectionCount + - upcomingInspectionCount + - documentCount + - lastActivityAt + properties: + projectMemberCount: + type: integer + minimum: 0 + description: Active participation rows; the separate project-manager pointer is not double-counted. + phaseCount: + type: integer + minimum: 0 + siteCount: + type: integer + minimum: 0 + openTaskCount: + type: integer + minimum: 0 + description: Tasks in todo or in-progress status. + designCount: + type: integer + minimum: 0 + designsUnderReviewCount: + type: integer + minimum: 0 + inspectionCount: + type: integer + minimum: 0 + upcomingInspectionCount: + type: integer + minimum: 0 + documentCount: + type: integer + minimum: 0 + lastActivityAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + EngineeringProjectDashboard: + type: object + additionalProperties: false + required: [project, client, projectManager, activity] + properties: + project: + $ref: '#/components/schemas/EngineeringProject' + client: + $ref: '#/components/schemas/EngineeringClientSummary' + projectManager: + oneOf: + - $ref: '#/components/schemas/UserSummary' + - type: 'null' + activity: + $ref: '#/components/schemas/EngineeringProjectActivitySummary' + EngineeringProjectDashboardResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectDashboard' + + EngineeringProjectMember: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - user + - projectRole + - status + - joinedAt + - leftAt + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + user: + $ref: '#/components/schemas/UserSummary' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + status: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + joinedAt: + $ref: '#/components/schemas/Timestamp' + leftAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: active + required: [status] + then: + properties: + leftAt: + type: 'null' + - if: + properties: + status: + const: left + required: [status] + then: + properties: + leftAt: + $ref: '#/components/schemas/Timestamp' + CreateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [userId, projectRole] + properties: + userId: + $ref: '#/components/schemas/Uuid' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + UpdateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [projectRole] + properties: + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + EngineeringProjectMemberResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectMember' + EngineeringProjectMemberCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProjectMember' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTask: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - title + - description + - status + - priority + - createdByUserId + - assignedToUserId + - dueAt + - startedAt + - startedByUserId + - completedAt + - completedByUserId + - cancelledAt + - cancelledByUserId + - cancellationReason + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + createdByUserId: + $ref: '#/components/schemas/Uuid' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + completedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + completedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancelledAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + cancelledByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancellationReason: + type: [string, 'null'] + maxLength: 500 + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: todo + required: [status] + then: + properties: + startedAt: {type: 'null'} + startedByUserId: {type: 'null'} + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: in_progress + required: [status] + then: + properties: + startedAt: + $ref: '#/components/schemas/Timestamp' + startedByUserId: + $ref: '#/components/schemas/Uuid' + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: completed + required: [status] + then: + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + completedByUserId: + $ref: '#/components/schemas/Uuid' + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: cancelled + required: [status] + then: + properties: + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: + $ref: '#/components/schemas/Timestamp' + cancelledByUserId: + $ref: '#/components/schemas/Uuid' + description: Terminal and start metadata are controlled exclusively by task commands. + CreateEngineeringTaskRequest: + type: object + additionalProperties: false + required: [projectId, title] + properties: + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + allOf: + - $ref: '#/components/schemas/EngineeringTaskPriority' + default: medium + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + UpdateEngineeringTaskRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + CompleteEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + description: A supplied completion time cannot be in the future or precede task creation. + CancelEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + reason: + type: string + maxLength: 500 + EngineeringTaskResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTask' + EngineeringTaskCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringTask' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTaskBatchItem: + type: object + additionalProperties: false + required: [id, version] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + BatchAssignEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, assigneeUserId, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + assigneeUserId: + $ref: '#/components/schemas/Uuid' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected even when their supplied versions differ. + BatchCompleteEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + completedAt: + $ref: '#/components/schemas/Timestamp' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected; completedAt follows the single-task completion rules. + EngineeringTaskBatchSuccess: + type: object + additionalProperties: false + required: [id, version, status, assignedToUserId] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + EngineeringTaskBatchFailure: + type: object + additionalProperties: false + required: [id, code, message, currentVersion] + properties: + id: + $ref: '#/components/schemas/Uuid' + code: + type: string + pattern: '^[A-Z][A-Z0-9_]+$' + message: + type: string + maxLength: 500 + currentVersion: + type: [integer, 'null'] + minimum: 1 + EngineeringTaskBatchResult: + type: object + additionalProperties: false + required: [mode, succeeded, failed] + properties: + mode: + $ref: '#/components/schemas/BatchExecutionMode' + succeeded: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchSuccess' + failed: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchFailure' + EngineeringTaskBatchResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTaskBatchResult' + +security: + - bearerAuth: [] diff --git a/professional-platform-openapi_6.yaml b/professional-platform-openapi_6.yaml new file mode 100644 index 0000000..8a56a17 --- /dev/null +++ b/professional-platform-openapi_6.yaml @@ -0,0 +1,6373 @@ +openapi: 3.1.0 +info: + title: Professional Management Platform API + version: 1.0.0-milestone.6 + summary: Platform access, engineering collaboration, governed documents, and design control. + description: | + Executable API contract for Milestones 1 through 4 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 + - name: Engineering Projects + - name: Engineering Project Members + - name: Engineering Tasks + - name: Engineering Sites + - name: Documents + - name: Engineering Project Documents + - name: Engineering Designs + - name: Engineering Design Assignments + - name: Engineering Design Versions + - name: Engineering Design Reviews + +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' + '404': + $ref: '#/components/responses/NotFound' + '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 + description: | + Rejected when the member is the last active organization Owner or manages any + active engineering project, has active project participation, or is assigned open + engineering tasks. Those responsibilities must be reassigned or ended first. + 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' + '404': + $ref: '#/components/responses/NotFound' + '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' + + /engineering/projects: + get: + tags: [Engineering Projects] + operationId: listEngineeringProjects + summary: List engineering projects + description: | + Archived projects are excluded unless `status=archived` is requested explicitly. + Permission scope is enforced in the query: `assigned` resolves through active project + membership or the project-manager pointer; `organization` resolves across the tenant. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: clientId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectStatus' + - name: discipline + in: query + schema: + $ref: '#/components/schemas/EngineeringDiscipline' + - name: projectManagerUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: q + in: query + description: Case-insensitive search across project number, project name, and client name. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Supported deterministic sort. Null date values are always placed last. + schema: + type: string + enum: + - projectNumber + - -projectNumber + - name + - -name + - startDate + - -startDate + - expectedCompletionDate + - -expectedCompletionDate + - createdAt + - -createdAt + default: -createdAt + responses: + '200': + description: Engineering projects returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectCollectionResponse' + '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 Projects] + operationId: createEngineeringProject + summary: Create an engineering project in draft status + description: | + `projectNumber` is immutable and unique case-insensitively within the organization. + The referenced client must be active. A supplied project manager must have an active + membership in the same organization. `projectManagerUserId` is the sole project-manager + authority and is not duplicated as a project-member role. + x-required-profession: engineering + x-required-permissions: [engineering.projects.create] + x-audit-action: engineering.projects.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectRequest' + responses: + '201': + description: Engineering project 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/EngineeringProjectResponse' + '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/projects/{projectId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Projects] + operationId: getEngineeringProject + summary: Get an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Engineering project returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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 Projects] + operationId: updateEngineeringProject + summary: Update editable engineering project fields + description: | + `projectNumber`, `status`, completion fields, and archive fields are not patchable. + `clientId` may change only while the project is `draft` and has no dependent records. + Changing `projectManagerUserId` changes assigned-scope access and is audited. It does + not create a duplicate `project_manager` project-member role. Open tasks assigned to + the outgoing manager must first be reassigned unless that user remains an active member. + x-required-profession: engineering + x-required-permissions: [engineering.projects.update] + x-audit-action: engineering.projects.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectRequest' + responses: + '200': + description: Engineering project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/activate: + post: + tags: [Engineering Projects] + operationId: activateEngineeringProject + summary: Activate a draft engineering project + description: | + Transition: `draft → active`. The client and project manager must both be active. + When `startDate` is absent from both the project and request, the server uses the + current date in the organization's configured time zone. + x-required-profession: engineering + x-required-permissions: [engineering.projects.activate] + x-audit-action: engineering.projects.activate + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEngineeringProjectRequest' + responses: + '200': + description: Engineering project activated or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/close: + post: + tags: [Engineering Projects] + operationId: closeEngineeringProject + summary: Close an active engineering project + description: | + Transition: `active → closed`. When `completedDate` is omitted, the server uses + the current date in the organization's configured time zone. The completed date + cannot precede the project start date. Every task must already be `completed` or + `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.close] + x-audit-action: engineering.projects.close + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CloseEngineeringProjectRequest' + responses: + '200': + description: Engineering project closed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/archive: + post: + tags: [Engineering Projects] + operationId: archiveEngineeringProject + summary: Archive a draft or closed engineering project + description: | + Transition: `draft|closed → archived`. Active projects must be closed first. + The prior status is retained so restore is deterministic. Related records and + audit history are never physically deleted. Every task must already be `completed` + or `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.archive + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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 project archived or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/restore: + post: + tags: [Engineering Projects] + operationId: restoreEngineeringProject + summary: Restore an archived engineering project + description: | + Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`. + Restore never reactivates a project implicitly. The referenced client must be active. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.restore + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering project restored or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/summary: + get: + tags: [Engineering Projects] + operationId: getEngineeringProjectSummary + summary: Get the engineering project dashboard summary + description: | + Returns a purpose-built read model. Counts are permission-filtered and include + only records visible to the caller. Modules not yet enabled return zero counts, + not omitted fields, preserving the response shape. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + responses: + '200': + description: Project summary returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectDashboardResponse' + '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' + + /engineering/projects/{projectId}/members: + get: + tags: [Engineering Project Members] + operationId: listEngineeringProjectMembers + summary: List temporal project-member records + description: By default, only active participation records are returned. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + - name: projectRole + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + - name: userId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: sort + in: query + schema: + type: string + enum: [joinedAt, -joinedAt, name, -name] + default: name + responses: + '200': + description: Project-member records returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberCollectionResponse' + '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 Project Members] + operationId: addEngineeringProjectMember + summary: Add an active organization member to a project + description: | + The project must be `draft` or `active`. The user must have an active organization + membership. Rejoining after departure creates a new temporal row. Only one active + row may exist for a user in a project. Project-manager assignment is controlled by + `projectManagerUserId`, not by this endpoint. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.add + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectMemberRequest' + responses: + '201': + description: Project member added. + 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/EngineeringProjectMemberResponse' + '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/projects/{projectId}/members/{memberId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ProjectMemberId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Project Members] + operationId: getEngineeringProjectMember + summary: Get a project-member record + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Project-member record returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: updateEngineeringProjectMember + summary: Change the participation role of an active project member + description: Only `projectRole` is patchable in v1. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectMemberRequest' + responses: + '200': + description: Participation role updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: endEngineeringProjectMembership + summary: End a user's project participation + description: | + Sets `leftAt`; it never deletes history. Repeating the command with the same + idempotency key replays the original 204 response. Open tasks assigned to the + user must be reassigned or unassigned first. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.remove + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Project participation ended or idempotent result replayed. + 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/tasks: + get: + tags: [Engineering Tasks] + operationId: listEngineeringTasks + summary: List engineering tasks + description: | + Permission scope is enforced per task. Assigned scope resolves when the caller is + the task assignee, an active member of the parent project, or its project manager. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskStatus' + - name: priority + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskPriority' + - name: assignedToUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: assignmentStatus + in: query + schema: + type: string + enum: [assigned, unassigned, any] + default: any + - name: dueBefore + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: dueAfter + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: q + in: query + description: Case-insensitive search across task title and description. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Null due dates are always placed last. + schema: + type: string + enum: [createdAt, -createdAt, dueAt, -dueAt, priority, -priority] + default: -createdAt + responses: + '200': + description: Engineering tasks returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskCollectionResponse' + '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 Tasks] + operationId: createEngineeringTask + summary: Create a task in todo status + description: | + The project must be `draft` or `active`. A supplied assignee must be the project + manager or an active project member and must retain an active organization membership. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringTaskRequest' + responses: + '201': + description: Engineering task 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/EngineeringTaskResponse' + '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/tasks/{taskId}: + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Tasks] + operationId: getEngineeringTask + summary: Get an engineering task + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + responses: + '200': + description: Engineering task returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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 Tasks] + operationId: updateEngineeringTask + summary: Update mutable task fields + description: | + `projectId`, status, creator, and terminal metadata are immutable through PATCH. + Assignment changes revalidate active organization and project participation. + Completed and cancelled tasks must be reopened before they can be edited. The parent + project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringTaskRequest' + responses: + '200': + description: Engineering task updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/start: + post: + tags: [Engineering Tasks] + operationId: startEngineeringTask + summary: Start a todo task + description: 'Transition: `todo → in_progress`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.start + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering task started or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/complete: + post: + tags: [Engineering Tasks] + operationId: completeEngineeringTask + summary: Complete a todo or in-progress task + description: 'Transition: `todo|in_progress → completed`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.complete + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteEngineeringTaskRequest' + responses: + '200': + description: Engineering task completed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/reopen: + post: + tags: [Engineering Tasks] + operationId: reopenEngineeringTask + summary: Reopen a completed or cancelled task + description: | + Transition: `completed|cancelled → todo`. Completion and cancellation metadata + plus any prior start metadata are cleared, while their prior values remain available + through audit history. The parent project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.reopen + parameters: + - $ref: '#/components/parameters/TaskId' + - $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 task reopened or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/cancel: + post: + tags: [Engineering Tasks] + operationId: cancelEngineeringTask + summary: Cancel a todo or in-progress task + description: 'Transition: `todo|in_progress → cancelled`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.cancel + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CancelEngineeringTaskRequest' + responses: + '200': + description: Engineering task cancelled or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/batch/assign: + post: + tags: [Engineering Tasks] + operationId: batchAssignEngineeringTasks + summary: Assign multiple tasks + description: | + Every item carries its expected version and is independently tenant-, permission-, + scope-, project-, assignee-, and state-validated. Atomic mode rolls back all items + on any failure. Partial mode commits valid items and returns per-item failures. + Only `todo` and `in_progress` tasks may be assigned, and the assignee must be an + active participant or project manager for every affected project. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_assign + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchAssignEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/tasks/batch/complete: + post: + tags: [Engineering Tasks] + operationId: batchCompleteEngineeringTasks + summary: Complete multiple tasks + description: | + Every item carries its expected version and is independently authorized and + state-validated. Atomic and partial modes follow the same semantics as batch assign. + Only `todo` and `in_progress` tasks may be completed. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_complete + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCompleteEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/sites: + get: + tags: [Engineering Sites] + operationId: listEngineeringSites + summary: List engineering sites across the active organization + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Sites visible to the caller. + headers: {X-Request-Id: {$ref: '#/components/headers/RequestId'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + + /engineering/projects/{projectId}/sites: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Sites] + operationId: listEngineeringProjectSites + summary: List sites for one project + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: Project sites. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Sites] + operationId: createEngineeringProjectSite + summary: Create a site within a project + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringSiteRequest'}}} + responses: + '201': + description: Site created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/sites/{siteId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SiteId' + get: + tags: [Engineering Sites] + operationId: getEngineeringSite + summary: Retrieve an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + responses: + '200': + description: Site. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Sites] + operationId: updateEngineeringSite + summary: Update an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringSiteRequest'}}} + responses: + '200': + description: Site updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents: + get: + tags: [Documents] + operationId: listDocuments + summary: List document metadata + description: Quarantined and infected versions are excluded unless the caller has documents.security_review. + x-required-permissions: [documents.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: classification + in: query + schema: {$ref: '#/components/schemas/DocumentClassification'} + - name: categoryId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Document metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCollectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + + /documents/upload-url: + post: + tags: [Documents] + operationId: createDocumentUploadUrl + summary: Initialize a single-part document upload + description: Creates quarantined document and version metadata, then returns a short-lived signed PUT URL. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: getDocument + summary: Retrieve document metadata + x-required-permissions: [documents.read] + responses: + '200': + description: Document metadata. No storage key or unsigned object URL is exposed. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Documents] + operationId: updateDocumentMetadata + summary: Update mutable document metadata + description: Classification cannot be weakened below the linked domain record's required classification. + x-required-permissions: [documents.manage] + x-audit-action: document.metadata_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateDocumentRequest'}}} + responses: + '200': + description: Document updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/complete-upload: + post: + tags: [Documents] + operationId: completeDocumentUpload + summary: Verify a single-part upload and enqueue malware inspection + description: Completion changes uploadStatus to completed and scanStatus to pending; it never makes the file downloadable. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteDocumentUploadRequest'}}} + responses: + '202': + description: Object verified and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: listDocumentVersions + summary: List immutable document versions + x-required-permissions: [documents.read] + responses: + '200': + description: Version metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Documents] + operationId: initializeNewDocumentVersion + summary: Initialize a new single-part version upload + description: The current version pointer changes only after upload verification and a clean scan. + x-required-permissions: [documents.upload] + x-audit-action: document.version_upload_initialized + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentVersionRequest'}}} + responses: + '201': + description: Version upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/download-url: + post: + tags: [Documents] + operationId: createDocumentDownloadUrl + summary: Create a short-lived download URL for a clean version + description: Infected, pending, failed, or quarantined versions are never downloadable. + x-required-permissions: [documents.download] + x-audit-action: document.download_authorized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateDocumentDownloadRequest'}}} + responses: + '200': + description: Short-lived download authorization. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentDownloadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /documents/multipart-uploads: + post: + tags: [Documents] + operationId: initializeMultipartDocumentUpload + summary: Initialize a multipart document upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Multipart upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeMultipartUploadResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/parts: + post: + tags: [Documents] + operationId: createMultipartPartUploadUrls + summary: Create signed URLs for selected multipart parts + x-required-permissions: [documents.upload] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsRequest'}}} + responses: + '200': + description: Signed part URLs. + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/complete: + post: + tags: [Documents] + operationId: completeMultipartDocumentUpload + summary: Assemble multipart upload and enqueue malware inspection + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteMultipartUploadRequest'}}} + responses: + '202': + description: Multipart object assembled and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}: + delete: + tags: [Documents] + operationId: abortMultipartDocumentUpload + summary: Abort an unfinished multipart upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_aborted + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Upload aborted; staged object parts are scheduled for cleanup.} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Documents] + operationId: listEngineeringProjectDocuments + summary: List active project-document links + x-required-profession: engineering + x-required-permissions: [engineering.documents.read] + responses: + '200': + description: Project documents filtered by document authorization and scan state. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Documents] + operationId: linkEngineeringProjectDocument + summary: Link a clean shared document to a project + description: Pending, failed, or infected versions cannot be linked as the active project document. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringProjectDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/project-documents/{documentLinkId}: + delete: + tags: [Engineering Project Documents] + operationId: unlinkEngineeringProjectDocument + summary: Temporally unlink a document from a project + description: Sets unlinkedAt; it does not delete the shared document or its versions. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/designs: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Designs] + operationId: listEngineeringProjectDesigns + summary: List designs for a project + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringDesignStatus'} + - name: discipline + in: query + schema: {type: string, minLength: 1, maxLength: 100} + responses: + '200': + description: Project designs. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Designs] + operationId: createEngineeringDesign + summary: Create a draft design + description: Atomically creates version 1 and the required owner/preparer assignments. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignRequest'}}} + responses: + '201': + description: Draft design and initial version created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Designs] + operationId: getEngineeringDesign + summary: Retrieve a design + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Designs] + operationId: updateEngineeringDesign + summary: Update editable design metadata + description: Only draft or changes_requested designs are editable; status changes use commands. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringDesignRequest'}}} + responses: + '200': + description: Design updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/submit-review: + post: + tags: [Engineering Designs] + operationId: submitEngineeringDesignForReview + summary: Submit the current version for review + description: Requires a clean primary drawing and at least one active reviewer assignment. + x-required-profession: engineering + x-required-permissions: [engineering.designs.submit] + x-audit-action: engineering.design.submitted_for_review + parameters: &designCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalDesignReasonCommand'}}} + responses: &designCommandResponses + '200': + description: Design transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/request-changes: + post: + tags: [Engineering Designs] + operationId: requestEngineeringDesignChanges + summary: Return a design to changes requested + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.changes_requested + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/approve: + post: + tags: [Engineering Designs] + operationId: approveEngineeringDesign + summary: Professionally approve the current design version + description: Revalidates current credential, discipline, scope-of-practice, and approval policy. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.approved + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ApproveDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/reject: + post: + tags: [Engineering Designs] + operationId: rejectEngineeringDesign + summary: Reject the current design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.rejected + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/revise: + post: + tags: [Engineering Designs] + operationId: reviseRejectedEngineeringDesign + summary: Reopen a rejected design as draft with a new version + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.revised + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/cancel: + post: + tags: [Engineering Designs] + operationId: cancelEngineeringDesign + summary: Cancel a draft design + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.cancelled + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/withdraw: + post: + tags: [Engineering Designs] + operationId: withdrawEngineeringDesign + summary: Withdraw a design from review + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.withdrawn + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/supersede: + post: + tags: [Engineering Designs] + operationId: supersedeEngineeringDesign + summary: Supersede an approved design + description: Requires the replacement to be a different approved design in the same project and discipline. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.superseded + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SupersedeDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/assignments: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Assignments] + operationId: listEngineeringDesignAssignments + summary: List current and historical design assignments + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - name: activeOnly + in: query + schema: {type: boolean, default: true} + responses: + '200': + description: Assignments. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Assignments] + operationId: assignEngineeringDesignParticipant + summary: Assign a member to a design role + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AssignEngineeringDesignRequest'}}} + responses: + '201': + description: Assignment created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/unassign: + post: + tags: [Engineering Design Assignments] + operationId: unassignEngineeringDesignParticipant + summary: End an active design assignment + description: Sets unassignedAt. The final active owner or required reviewer cannot be removed while workflow depends on that role. + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_ended + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/UnassignEngineeringDesignRequest'}}} + responses: + '204': {description: Assignment ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersions + summary: List immutable logical design versions + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design versions. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: createEngineeringDesignVersion + summary: Create the next logical design version + description: Allowed only in draft or changes_requested. Version numbers are allocated transactionally. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignVersionRequest'}}} + responses: + '201': + description: Design version created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/design-versions/{designVersionId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignVersionId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersionDocuments + summary: List documents linked to a design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Version documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: linkEngineeringDesignVersionDocument + summary: Link a clean document to an editable design version + description: Only scan-clean documents may be linked; a version may have only one active primary_drawing. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringDesignVersionDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/design-version-documents/{documentLinkId}: + delete: + tags: [Engineering Design Versions] + operationId: unlinkEngineeringDesignVersionDocument + summary: Temporally unlink a document from an editable design version + description: Submitted, approved, rejected, or superseded version evidence cannot be unlinked. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/reviews: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Reviews] + operationId: listEngineeringDesignReviews + summary: List review recommendations + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Reviews. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Reviews] + operationId: recordEngineeringDesignReview + summary: Record a reviewer recommendation for the submitted version + description: A recommendation is immutable and never directly changes design status. + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.review_recorded + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignReviewRequest'}}} + responses: + '201': + description: Review recommendation recorded. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + +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' + ProjectId: + name: projectId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + ProjectMemberId: + name: memberId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + TaskId: + name: taskId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + SiteId: + name: siteId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentId: + name: documentId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + UploadId: + name: uploadId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentLinkId: + name: documentLinkId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignId: + name: designId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignVersionId: + name: designVersionId + 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] + EngineeringSite: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, address, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + EngineeringSiteAddress: + type: object + additionalProperties: false + required: [line1, city, countryCode] + properties: + line1: {type: string, minLength: 1, maxLength: 200} + line2: {type: [string, 'null'], maxLength: 200} + city: {type: string, minLength: 1, maxLength: 120} + region: {type: [string, 'null'], maxLength: 120} + postalCode: {type: [string, 'null'], maxLength: 32} + countryCode: {$ref: '#/components/schemas/CountryCode'} + CreateEngineeringSiteRequest: + type: object + additionalProperties: false + required: [name, address] + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + UpdateEngineeringSiteRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + EngineeringSiteResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSite'}} + EngineeringSiteCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringSite'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentClassification: + type: string + enum: [public, internal, confidential, restricted, regulated] + DocumentUploadStatus: + type: string + enum: [initialized, uploading, completed, failed, aborted, expired] + MalwareScanStatus: + type: string + enum: [not_started, pending, scanning, clean, infected, failed] + Document: + type: object + additionalProperties: false + required: [id, organizationId, name, classification, currentVersionId, version, createdByUserId, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersion: {oneOf: [{$ref: '#/components/schemas/DocumentVersion'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + DocumentVersion: + type: object + additionalProperties: false + required: [id, organizationId, documentId, versionNumber, mimeType, sizeBytes, uploadStatus, scanStatus, uploadedByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + hashAlgorithm: {type: string, const: sha256} + uploadStatus: {$ref: '#/components/schemas/DocumentUploadStatus'} + scanStatus: {$ref: '#/components/schemas/MalwareScanStatus'} + scanCompletedAt: {type: [string, 'null'], format: date-time} + available: {type: boolean, readOnly: true, description: True only when uploadStatus is completed and scanStatus is clean.} + uploadedByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadRequest: + type: object + additionalProperties: false + required: [name, classification, mimeType, sizeBytes] + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentVersionRequest: + type: object + additionalProperties: false + required: [mimeType, sizeBytes] + properties: + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + UpdateDocumentRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + CompleteDocumentUploadRequest: + type: object + additionalProperties: false + required: [documentVersionId, contentHash] + properties: + documentVersionId: {$ref: '#/components/schemas/Uuid'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadUrl, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadUrl: {type: string, format: uri} + requiredHeaders: {type: object, additionalProperties: {type: string}} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeDocumentUploadData'}} + InitializeMultipartUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadId, recommendedPartSizeBytes, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadId: {$ref: '#/components/schemas/Uuid'} + recommendedPartSizeBytes: {type: integer, minimum: 5242880} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeMultipartUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeMultipartUploadData'}} + MultipartPartUrlsRequest: + type: object + additionalProperties: false + required: [partNumbers] + properties: + partNumbers: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: {type: integer, minimum: 1, maximum: 10000} + MultipartPartUploadUrl: + type: object + required: [partNumber, uploadUrl, expiresAt] + properties: + partNumber: {type: integer, minimum: 1} + uploadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + MultipartPartUrlsResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/MultipartPartUploadUrl'}}} + CompletedMultipartPart: + type: object + additionalProperties: false + required: [partNumber, etag] + properties: + partNumber: {type: integer, minimum: 1} + etag: {type: string, minLength: 1, maxLength: 200} + CompleteMultipartUploadRequest: + type: object + additionalProperties: false + required: [parts, contentHash] + properties: + parts: + type: array + minItems: 1 + maxItems: 10000 + items: {$ref: '#/components/schemas/CompletedMultipartPart'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + CreateDocumentDownloadRequest: + type: object + additionalProperties: false + properties: + documentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}], description: Defaults to the current clean version.} + DocumentDownloadData: + type: object + required: [downloadUrl, expiresAt] + properties: + downloadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + DocumentDownloadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentDownloadData'}} + DocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Document'}} + DocumentCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Document'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentVersion'}} + DocumentVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/DocumentVersion'}}} + EngineeringProjectDocumentLink: + type: object + additionalProperties: false + required: [id, organizationId, projectId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringProjectDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + EngineeringProjectDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}} + EngineeringProjectDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}}} + EngineeringDesignStatus: + type: string + enum: [draft, under_review, changes_requested, approved, rejected, cancelled, withdrawn, superseded] + EngineeringDesignAssignmentRole: + type: string + enum: [owner, preparer, reviewer, contributor] + EngineeringDesignDocumentRole: + type: string + enum: [primary_drawing, calculation, supporting_document, specification, attachment] + description: Domain-specific registry independent from specification document roles. + EngineeringDesignReviewStatus: + type: string + enum: [approved, changes_requested, rejected] + EngineeringDesign: + type: object + additionalProperties: false + required: [id, organizationId, projectId, designNumber, title, discipline, status, ownerUserId, preparedByUserId, currentVersionId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + status: {$ref: '#/components/schemas/EngineeringDesignStatus'} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + currentVersionId: {$ref: '#/components/schemas/Uuid'} + approvedVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedAt: {type: [string, 'null'], format: date-time} + supersededByDesignId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignRequest: + type: object + additionalProperties: false + required: [designNumber, title, discipline, ownerUserId, preparedByUserId] + properties: + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + UpdateEngineeringDesignRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + EngineeringDesignResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesign'}} + EngineeringDesignCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesign'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + OptionalDesignReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignDecisionCommand: + type: object + additionalProperties: false + required: [designVersionId, reason] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 2000} + ApproveDesignCommand: + type: object + additionalProperties: false + required: [designVersionId, attestation] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + attestation: {type: string, minLength: 10, maxLength: 2000} + SupersedeDesignCommand: + type: object + additionalProperties: false + required: [replacementDesignId, reason] + properties: + replacementDesignId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringDesignAssignment: + type: object + additionalProperties: false + required: [id, organizationId, designId, userId, assignmentRole, assignedByUserId, assignedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + assignedByUserId: {$ref: '#/components/schemas/Uuid'} + assignedAt: {$ref: '#/components/schemas/Timestamp'} + unassignedAt: {type: [string, 'null'], format: date-time} + AssignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [userId, assignmentRole] + properties: + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + UnassignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [assignmentId] + properties: + assignmentId: {$ref: '#/components/schemas/Uuid'} + reason: {type: [string, 'null'], maxLength: 1000} + EngineeringDesignAssignmentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignAssignment'}} + EngineeringDesignAssignmentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignAssignment'}}} + EngineeringDesignVersion: + type: object + additionalProperties: false + required: [id, organizationId, designId, versionNumber, createdByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + changeSummary: {type: [string, 'null'], maxLength: 2000} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignVersionRequest: + type: object + additionalProperties: false + properties: + changeSummary: {type: [string, 'null'], maxLength: 2000} + EngineeringDesignVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersion'}} + EngineeringDesignVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersion'}}} + EngineeringDesignVersionDocument: + type: object + additionalProperties: false + required: [id, organizationId, designVersionId, documentId, documentRole, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringDesignVersionDocumentRequest: + type: object + additionalProperties: false + required: [documentId, documentRole] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + EngineeringDesignVersionDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}} + EngineeringDesignVersionDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}}} + EngineeringDesignReview: + type: object + additionalProperties: false + required: [id, organizationId, designId, designVersionId, reviewerUserId, status, comments, reviewedAt, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + reviewerUserId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + reviewedAt: {$ref: '#/components/schemas/Timestamp'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignReviewRequest: + type: object + additionalProperties: false + required: [designVersionId, status, comments] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + EngineeringDesignReviewResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignReview'}} + EngineeringDesignReviewCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignReview'}}} + 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] + EngineeringProjectRestorableStatus: + type: string + enum: [draft, closed] + EngineeringDiscipline: + type: string + enum: + - civil + - structural + - mechanical + - electrical + - geotechnical + - environmental + - transportation + - water_resources + - surveying + - multidisciplinary + - other + EngineeringProjectMemberRole: + type: string + enum: [engineer, designer, reviewer, inspector, viewer, contractor] + description: Project manager is intentionally excluded; `projectManagerUserId` is authoritative. + EngineeringProjectMemberStatus: + type: string + enum: [active, left] + description: Derived from whether `leftAt` is null. + EngineeringTaskStatus: + type: string + enum: [todo, in_progress, completed, cancelled] + EngineeringTaskPriority: + type: string + enum: [low, medium, high, urgent] + BatchExecutionMode: + type: string + enum: [atomic, partial] + + 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' + + EngineeringProject: + type: object + additionalProperties: false + required: + - id + - organizationId + - clientId + - projectNumber + - name + - description + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - archivedAt + - archivedByUserId + - archivedFromStatus + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + description: Immutable, organization-unique human project reference. + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + archivedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + archivedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + archivedFromStatus: + oneOf: + - $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + enum: [active, closed] + required: [status] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: closed + required: [status] + then: + properties: + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + enum: [draft, active] + required: [status] + then: + properties: + completedDate: + type: 'null' + - if: + properties: + status: + const: archived + required: [status] + then: + properties: + archivedAt: + $ref: '#/components/schemas/Timestamp' + archivedByUserId: + $ref: '#/components/schemas/Uuid' + archivedFromStatus: + $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + else: + properties: + archivedAt: + type: 'null' + archivedByUserId: + type: 'null' + archivedFromStatus: + type: 'null' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: closed + required: [status, archivedFromStatus] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: draft + required: [status, archivedFromStatus] + then: + properties: + completedDate: + type: 'null' + description: Expected and completed dates may not precede the start date. + CreateEngineeringProjectRequest: + type: object + additionalProperties: false + required: [clientId, projectNumber, name, discipline] + properties: + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: Expected completion date may not precede start date. + UpdateEngineeringProjectRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + clientId: + $ref: '#/components/schemas/Uuid' + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: The resulting dates and manager assignment must satisfy the project's current state rules. + ActivateEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + startDate: + $ref: '#/components/schemas/Date' + CloseEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + completedDate: + $ref: '#/components/schemas/Date' + reason: + type: string + maxLength: 500 + EngineeringProjectResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProject' + EngineeringProjectCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProject' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringProjectSummary: + type: object + additionalProperties: false + required: + - id + - clientId + - projectNumber + - name + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - version + - createdAt + - updatedAt + 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: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $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' + + EngineeringClientSummary: + type: object + additionalProperties: false + required: [id, clientType, displayName, legalName, status] + properties: + id: + $ref: '#/components/schemas/Uuid' + clientType: + $ref: '#/components/schemas/EngineeringClientType' + displayName: + type: string + legalName: + type: [string, 'null'] + status: + $ref: '#/components/schemas/EngineeringClientStatus' + EngineeringProjectActivitySummary: + type: object + additionalProperties: false + required: + - projectMemberCount + - phaseCount + - siteCount + - openTaskCount + - designCount + - designsUnderReviewCount + - inspectionCount + - upcomingInspectionCount + - documentCount + - lastActivityAt + properties: + projectMemberCount: + type: integer + minimum: 0 + description: Active participation rows; the separate project-manager pointer is not double-counted. + phaseCount: + type: integer + minimum: 0 + siteCount: + type: integer + minimum: 0 + openTaskCount: + type: integer + minimum: 0 + description: Tasks in todo or in-progress status. + designCount: + type: integer + minimum: 0 + designsUnderReviewCount: + type: integer + minimum: 0 + inspectionCount: + type: integer + minimum: 0 + upcomingInspectionCount: + type: integer + minimum: 0 + documentCount: + type: integer + minimum: 0 + lastActivityAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + EngineeringProjectDashboard: + type: object + additionalProperties: false + required: [project, client, projectManager, activity] + properties: + project: + $ref: '#/components/schemas/EngineeringProject' + client: + $ref: '#/components/schemas/EngineeringClientSummary' + projectManager: + oneOf: + - $ref: '#/components/schemas/UserSummary' + - type: 'null' + activity: + $ref: '#/components/schemas/EngineeringProjectActivitySummary' + EngineeringProjectDashboardResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectDashboard' + + EngineeringProjectMember: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - user + - projectRole + - status + - joinedAt + - leftAt + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + user: + $ref: '#/components/schemas/UserSummary' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + status: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + joinedAt: + $ref: '#/components/schemas/Timestamp' + leftAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: active + required: [status] + then: + properties: + leftAt: + type: 'null' + - if: + properties: + status: + const: left + required: [status] + then: + properties: + leftAt: + $ref: '#/components/schemas/Timestamp' + CreateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [userId, projectRole] + properties: + userId: + $ref: '#/components/schemas/Uuid' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + UpdateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [projectRole] + properties: + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + EngineeringProjectMemberResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectMember' + EngineeringProjectMemberCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProjectMember' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTask: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - title + - description + - status + - priority + - createdByUserId + - assignedToUserId + - dueAt + - startedAt + - startedByUserId + - completedAt + - completedByUserId + - cancelledAt + - cancelledByUserId + - cancellationReason + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + createdByUserId: + $ref: '#/components/schemas/Uuid' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + completedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + completedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancelledAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + cancelledByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancellationReason: + type: [string, 'null'] + maxLength: 500 + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: todo + required: [status] + then: + properties: + startedAt: {type: 'null'} + startedByUserId: {type: 'null'} + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: in_progress + required: [status] + then: + properties: + startedAt: + $ref: '#/components/schemas/Timestamp' + startedByUserId: + $ref: '#/components/schemas/Uuid' + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: completed + required: [status] + then: + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + completedByUserId: + $ref: '#/components/schemas/Uuid' + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: cancelled + required: [status] + then: + properties: + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: + $ref: '#/components/schemas/Timestamp' + cancelledByUserId: + $ref: '#/components/schemas/Uuid' + description: Terminal and start metadata are controlled exclusively by task commands. + CreateEngineeringTaskRequest: + type: object + additionalProperties: false + required: [projectId, title] + properties: + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + allOf: + - $ref: '#/components/schemas/EngineeringTaskPriority' + default: medium + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + UpdateEngineeringTaskRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + CompleteEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + description: A supplied completion time cannot be in the future or precede task creation. + CancelEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + reason: + type: string + maxLength: 500 + EngineeringTaskResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTask' + EngineeringTaskCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringTask' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTaskBatchItem: + type: object + additionalProperties: false + required: [id, version] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + BatchAssignEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, assigneeUserId, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + assigneeUserId: + $ref: '#/components/schemas/Uuid' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected even when their supplied versions differ. + BatchCompleteEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + completedAt: + $ref: '#/components/schemas/Timestamp' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected; completedAt follows the single-task completion rules. + EngineeringTaskBatchSuccess: + type: object + additionalProperties: false + required: [id, version, status, assignedToUserId] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + EngineeringTaskBatchFailure: + type: object + additionalProperties: false + required: [id, code, message, currentVersion] + properties: + id: + $ref: '#/components/schemas/Uuid' + code: + type: string + pattern: '^[A-Z][A-Z0-9_]+$' + message: + type: string + maxLength: 500 + currentVersion: + type: [integer, 'null'] + minimum: 1 + EngineeringTaskBatchResult: + type: object + additionalProperties: false + required: [mode, succeeded, failed] + properties: + mode: + $ref: '#/components/schemas/BatchExecutionMode' + succeeded: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchSuccess' + failed: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchFailure' + EngineeringTaskBatchResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTaskBatchResult' + +security: + - bearerAuth: [] diff --git a/professional-platform-openapi_7.yaml b/professional-platform-openapi_7.yaml new file mode 100644 index 0000000..d0fc92f --- /dev/null +++ b/professional-platform-openapi_7.yaml @@ -0,0 +1,7025 @@ +openapi: 3.1.0 +info: + title: Professional Management Platform API + version: 1.0.0-milestone.7 + summary: Platform access, engineering collaboration, design control, and inspection assurance. + description: | + Executable API contract for Milestones 1 through 4 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 + - name: Engineering Projects + - name: Engineering Project Members + - name: Engineering Tasks + - name: Engineering Sites + - name: Documents + - name: Engineering Project Documents + - name: Engineering Designs + - name: Engineering Design Assignments + - name: Engineering Design Versions + - name: Engineering Design Reviews + - name: Engineering Inspections + - name: Engineering Inspection Documents + - name: Engineering Inspection Findings + - name: Engineering Inspection Follow-ups + +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' + '404': + $ref: '#/components/responses/NotFound' + '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 + description: | + Rejected when the member is the last active organization Owner or manages any + active engineering project, has active project participation, or is assigned open + engineering tasks. Those responsibilities must be reassigned or ended first. + 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' + '404': + $ref: '#/components/responses/NotFound' + '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' + + /engineering/projects: + get: + tags: [Engineering Projects] + operationId: listEngineeringProjects + summary: List engineering projects + description: | + Archived projects are excluded unless `status=archived` is requested explicitly. + Permission scope is enforced in the query: `assigned` resolves through active project + membership or the project-manager pointer; `organization` resolves across the tenant. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: clientId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectStatus' + - name: discipline + in: query + schema: + $ref: '#/components/schemas/EngineeringDiscipline' + - name: projectManagerUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: q + in: query + description: Case-insensitive search across project number, project name, and client name. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Supported deterministic sort. Null date values are always placed last. + schema: + type: string + enum: + - projectNumber + - -projectNumber + - name + - -name + - startDate + - -startDate + - expectedCompletionDate + - -expectedCompletionDate + - createdAt + - -createdAt + default: -createdAt + responses: + '200': + description: Engineering projects returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectCollectionResponse' + '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 Projects] + operationId: createEngineeringProject + summary: Create an engineering project in draft status + description: | + `projectNumber` is immutable and unique case-insensitively within the organization. + The referenced client must be active. A supplied project manager must have an active + membership in the same organization. `projectManagerUserId` is the sole project-manager + authority and is not duplicated as a project-member role. + x-required-profession: engineering + x-required-permissions: [engineering.projects.create] + x-audit-action: engineering.projects.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectRequest' + responses: + '201': + description: Engineering project 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/EngineeringProjectResponse' + '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/projects/{projectId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Projects] + operationId: getEngineeringProject + summary: Get an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Engineering project returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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 Projects] + operationId: updateEngineeringProject + summary: Update editable engineering project fields + description: | + `projectNumber`, `status`, completion fields, and archive fields are not patchable. + `clientId` may change only while the project is `draft` and has no dependent records. + Changing `projectManagerUserId` changes assigned-scope access and is audited. It does + not create a duplicate `project_manager` project-member role. Open tasks assigned to + the outgoing manager must first be reassigned unless that user remains an active member. + x-required-profession: engineering + x-required-permissions: [engineering.projects.update] + x-audit-action: engineering.projects.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectRequest' + responses: + '200': + description: Engineering project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/activate: + post: + tags: [Engineering Projects] + operationId: activateEngineeringProject + summary: Activate a draft engineering project + description: | + Transition: `draft → active`. The client and project manager must both be active. + When `startDate` is absent from both the project and request, the server uses the + current date in the organization's configured time zone. + x-required-profession: engineering + x-required-permissions: [engineering.projects.activate] + x-audit-action: engineering.projects.activate + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEngineeringProjectRequest' + responses: + '200': + description: Engineering project activated or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/close: + post: + tags: [Engineering Projects] + operationId: closeEngineeringProject + summary: Close an active engineering project + description: | + Transition: `active → closed`. When `completedDate` is omitted, the server uses + the current date in the organization's configured time zone. The completed date + cannot precede the project start date. Every task must already be `completed` or + `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.close] + x-audit-action: engineering.projects.close + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CloseEngineeringProjectRequest' + responses: + '200': + description: Engineering project closed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/archive: + post: + tags: [Engineering Projects] + operationId: archiveEngineeringProject + summary: Archive a draft or closed engineering project + description: | + Transition: `draft|closed → archived`. Active projects must be closed first. + The prior status is retained so restore is deterministic. Related records and + audit history are never physically deleted. Every task must already be `completed` + or `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.archive + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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 project archived or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/restore: + post: + tags: [Engineering Projects] + operationId: restoreEngineeringProject + summary: Restore an archived engineering project + description: | + Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`. + Restore never reactivates a project implicitly. The referenced client must be active. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.restore + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering project restored or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/summary: + get: + tags: [Engineering Projects] + operationId: getEngineeringProjectSummary + summary: Get the engineering project dashboard summary + description: | + Returns a purpose-built read model. Counts are permission-filtered and include + only records visible to the caller. Modules not yet enabled return zero counts, + not omitted fields, preserving the response shape. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + responses: + '200': + description: Project summary returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectDashboardResponse' + '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' + + /engineering/projects/{projectId}/members: + get: + tags: [Engineering Project Members] + operationId: listEngineeringProjectMembers + summary: List temporal project-member records + description: By default, only active participation records are returned. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + - name: projectRole + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + - name: userId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: sort + in: query + schema: + type: string + enum: [joinedAt, -joinedAt, name, -name] + default: name + responses: + '200': + description: Project-member records returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberCollectionResponse' + '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 Project Members] + operationId: addEngineeringProjectMember + summary: Add an active organization member to a project + description: | + The project must be `draft` or `active`. The user must have an active organization + membership. Rejoining after departure creates a new temporal row. Only one active + row may exist for a user in a project. Project-manager assignment is controlled by + `projectManagerUserId`, not by this endpoint. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.add + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectMemberRequest' + responses: + '201': + description: Project member added. + 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/EngineeringProjectMemberResponse' + '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/projects/{projectId}/members/{memberId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ProjectMemberId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Project Members] + operationId: getEngineeringProjectMember + summary: Get a project-member record + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Project-member record returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: updateEngineeringProjectMember + summary: Change the participation role of an active project member + description: Only `projectRole` is patchable in v1. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectMemberRequest' + responses: + '200': + description: Participation role updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: endEngineeringProjectMembership + summary: End a user's project participation + description: | + Sets `leftAt`; it never deletes history. Repeating the command with the same + idempotency key replays the original 204 response. Open tasks assigned to the + user must be reassigned or unassigned first. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.remove + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Project participation ended or idempotent result replayed. + 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/tasks: + get: + tags: [Engineering Tasks] + operationId: listEngineeringTasks + summary: List engineering tasks + description: | + Permission scope is enforced per task. Assigned scope resolves when the caller is + the task assignee, an active member of the parent project, or its project manager. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskStatus' + - name: priority + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskPriority' + - name: assignedToUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: assignmentStatus + in: query + schema: + type: string + enum: [assigned, unassigned, any] + default: any + - name: dueBefore + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: dueAfter + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: q + in: query + description: Case-insensitive search across task title and description. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Null due dates are always placed last. + schema: + type: string + enum: [createdAt, -createdAt, dueAt, -dueAt, priority, -priority] + default: -createdAt + responses: + '200': + description: Engineering tasks returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskCollectionResponse' + '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 Tasks] + operationId: createEngineeringTask + summary: Create a task in todo status + description: | + The project must be `draft` or `active`. A supplied assignee must be the project + manager or an active project member and must retain an active organization membership. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringTaskRequest' + responses: + '201': + description: Engineering task 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/EngineeringTaskResponse' + '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/tasks/{taskId}: + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Tasks] + operationId: getEngineeringTask + summary: Get an engineering task + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + responses: + '200': + description: Engineering task returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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 Tasks] + operationId: updateEngineeringTask + summary: Update mutable task fields + description: | + `projectId`, status, creator, and terminal metadata are immutable through PATCH. + Assignment changes revalidate active organization and project participation. + Completed and cancelled tasks must be reopened before they can be edited. The parent + project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringTaskRequest' + responses: + '200': + description: Engineering task updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/start: + post: + tags: [Engineering Tasks] + operationId: startEngineeringTask + summary: Start a todo task + description: 'Transition: `todo → in_progress`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.start + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering task started or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/complete: + post: + tags: [Engineering Tasks] + operationId: completeEngineeringTask + summary: Complete a todo or in-progress task + description: 'Transition: `todo|in_progress → completed`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.complete + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteEngineeringTaskRequest' + responses: + '200': + description: Engineering task completed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/reopen: + post: + tags: [Engineering Tasks] + operationId: reopenEngineeringTask + summary: Reopen a completed or cancelled task + description: | + Transition: `completed|cancelled → todo`. Completion and cancellation metadata + plus any prior start metadata are cleared, while their prior values remain available + through audit history. The parent project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.reopen + parameters: + - $ref: '#/components/parameters/TaskId' + - $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 task reopened or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/cancel: + post: + tags: [Engineering Tasks] + operationId: cancelEngineeringTask + summary: Cancel a todo or in-progress task + description: 'Transition: `todo|in_progress → cancelled`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.cancel + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CancelEngineeringTaskRequest' + responses: + '200': + description: Engineering task cancelled or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/batch/assign: + post: + tags: [Engineering Tasks] + operationId: batchAssignEngineeringTasks + summary: Assign multiple tasks + description: | + Every item carries its expected version and is independently tenant-, permission-, + scope-, project-, assignee-, and state-validated. Atomic mode rolls back all items + on any failure. Partial mode commits valid items and returns per-item failures. + Only `todo` and `in_progress` tasks may be assigned, and the assignee must be an + active participant or project manager for every affected project. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_assign + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchAssignEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/tasks/batch/complete: + post: + tags: [Engineering Tasks] + operationId: batchCompleteEngineeringTasks + summary: Complete multiple tasks + description: | + Every item carries its expected version and is independently authorized and + state-validated. Atomic and partial modes follow the same semantics as batch assign. + Only `todo` and `in_progress` tasks may be completed. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_complete + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCompleteEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/sites: + get: + tags: [Engineering Sites] + operationId: listEngineeringSites + summary: List engineering sites across the active organization + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Sites visible to the caller. + headers: {X-Request-Id: {$ref: '#/components/headers/RequestId'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + + /engineering/projects/{projectId}/sites: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Sites] + operationId: listEngineeringProjectSites + summary: List sites for one project + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: Project sites. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Sites] + operationId: createEngineeringProjectSite + summary: Create a site within a project + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringSiteRequest'}}} + responses: + '201': + description: Site created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/sites/{siteId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SiteId' + get: + tags: [Engineering Sites] + operationId: getEngineeringSite + summary: Retrieve an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + responses: + '200': + description: Site. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Sites] + operationId: updateEngineeringSite + summary: Update an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringSiteRequest'}}} + responses: + '200': + description: Site updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents: + get: + tags: [Documents] + operationId: listDocuments + summary: List document metadata + description: Quarantined and infected versions are excluded unless the caller has documents.security_review. + x-required-permissions: [documents.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: classification + in: query + schema: {$ref: '#/components/schemas/DocumentClassification'} + - name: categoryId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Document metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCollectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + + /documents/upload-url: + post: + tags: [Documents] + operationId: createDocumentUploadUrl + summary: Initialize a single-part document upload + description: Creates quarantined document and version metadata, then returns a short-lived signed PUT URL. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: getDocument + summary: Retrieve document metadata + x-required-permissions: [documents.read] + responses: + '200': + description: Document metadata. No storage key or unsigned object URL is exposed. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Documents] + operationId: updateDocumentMetadata + summary: Update mutable document metadata + description: Classification cannot be weakened below the linked domain record's required classification. + x-required-permissions: [documents.manage] + x-audit-action: document.metadata_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateDocumentRequest'}}} + responses: + '200': + description: Document updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/complete-upload: + post: + tags: [Documents] + operationId: completeDocumentUpload + summary: Verify a single-part upload and enqueue malware inspection + description: Completion changes uploadStatus to completed and scanStatus to pending; it never makes the file downloadable. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteDocumentUploadRequest'}}} + responses: + '202': + description: Object verified and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: listDocumentVersions + summary: List immutable document versions + x-required-permissions: [documents.read] + responses: + '200': + description: Version metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Documents] + operationId: initializeNewDocumentVersion + summary: Initialize a new single-part version upload + description: The current version pointer changes only after upload verification and a clean scan. + x-required-permissions: [documents.upload] + x-audit-action: document.version_upload_initialized + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentVersionRequest'}}} + responses: + '201': + description: Version upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/download-url: + post: + tags: [Documents] + operationId: createDocumentDownloadUrl + summary: Create a short-lived download URL for a clean version + description: Infected, pending, failed, or quarantined versions are never downloadable. + x-required-permissions: [documents.download] + x-audit-action: document.download_authorized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateDocumentDownloadRequest'}}} + responses: + '200': + description: Short-lived download authorization. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentDownloadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /documents/multipart-uploads: + post: + tags: [Documents] + operationId: initializeMultipartDocumentUpload + summary: Initialize a multipart document upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Multipart upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeMultipartUploadResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/parts: + post: + tags: [Documents] + operationId: createMultipartPartUploadUrls + summary: Create signed URLs for selected multipart parts + x-required-permissions: [documents.upload] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsRequest'}}} + responses: + '200': + description: Signed part URLs. + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/complete: + post: + tags: [Documents] + operationId: completeMultipartDocumentUpload + summary: Assemble multipart upload and enqueue malware inspection + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteMultipartUploadRequest'}}} + responses: + '202': + description: Multipart object assembled and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}: + delete: + tags: [Documents] + operationId: abortMultipartDocumentUpload + summary: Abort an unfinished multipart upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_aborted + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Upload aborted; staged object parts are scheduled for cleanup.} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Documents] + operationId: listEngineeringProjectDocuments + summary: List active project-document links + x-required-profession: engineering + x-required-permissions: [engineering.documents.read] + responses: + '200': + description: Project documents filtered by document authorization and scan state. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Documents] + operationId: linkEngineeringProjectDocument + summary: Link a clean shared document to a project + description: Pending, failed, or infected versions cannot be linked as the active project document. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringProjectDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/project-documents/{documentLinkId}: + delete: + tags: [Engineering Project Documents] + operationId: unlinkEngineeringProjectDocument + summary: Temporally unlink a document from a project + description: Sets unlinkedAt; it does not delete the shared document or its versions. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/designs: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Designs] + operationId: listEngineeringProjectDesigns + summary: List designs for a project + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringDesignStatus'} + - name: discipline + in: query + schema: {type: string, minLength: 1, maxLength: 100} + responses: + '200': + description: Project designs. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Designs] + operationId: createEngineeringDesign + summary: Create a draft design + description: Atomically creates version 1 and the required owner/preparer assignments. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignRequest'}}} + responses: + '201': + description: Draft design and initial version created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Designs] + operationId: getEngineeringDesign + summary: Retrieve a design + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Designs] + operationId: updateEngineeringDesign + summary: Update editable design metadata + description: Only draft or changes_requested designs are editable; status changes use commands. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringDesignRequest'}}} + responses: + '200': + description: Design updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/submit-review: + post: + tags: [Engineering Designs] + operationId: submitEngineeringDesignForReview + summary: Submit the current version for review + description: Requires a clean primary drawing and at least one active reviewer assignment. + x-required-profession: engineering + x-required-permissions: [engineering.designs.submit] + x-audit-action: engineering.design.submitted_for_review + parameters: &designCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalDesignReasonCommand'}}} + responses: &designCommandResponses + '200': + description: Design transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/request-changes: + post: + tags: [Engineering Designs] + operationId: requestEngineeringDesignChanges + summary: Return a design to changes requested + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.changes_requested + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/approve: + post: + tags: [Engineering Designs] + operationId: approveEngineeringDesign + summary: Professionally approve the current design version + description: Revalidates current credential, discipline, scope-of-practice, and approval policy. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.approved + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ApproveDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/reject: + post: + tags: [Engineering Designs] + operationId: rejectEngineeringDesign + summary: Reject the current design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.rejected + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/revise: + post: + tags: [Engineering Designs] + operationId: reviseRejectedEngineeringDesign + summary: Reopen a rejected design as draft with a new version + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.revised + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/cancel: + post: + tags: [Engineering Designs] + operationId: cancelEngineeringDesign + summary: Cancel a draft design + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.cancelled + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/withdraw: + post: + tags: [Engineering Designs] + operationId: withdrawEngineeringDesign + summary: Withdraw a design from review + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.withdrawn + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/supersede: + post: + tags: [Engineering Designs] + operationId: supersedeEngineeringDesign + summary: Supersede an approved design + description: Requires the replacement to be a different approved design in the same project and discipline. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.superseded + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SupersedeDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/assignments: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Assignments] + operationId: listEngineeringDesignAssignments + summary: List current and historical design assignments + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - name: activeOnly + in: query + schema: {type: boolean, default: true} + responses: + '200': + description: Assignments. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Assignments] + operationId: assignEngineeringDesignParticipant + summary: Assign a member to a design role + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AssignEngineeringDesignRequest'}}} + responses: + '201': + description: Assignment created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/unassign: + post: + tags: [Engineering Design Assignments] + operationId: unassignEngineeringDesignParticipant + summary: End an active design assignment + description: Sets unassignedAt. The final active owner or required reviewer cannot be removed while workflow depends on that role. + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_ended + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/UnassignEngineeringDesignRequest'}}} + responses: + '204': {description: Assignment ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersions + summary: List immutable logical design versions + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design versions. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: createEngineeringDesignVersion + summary: Create the next logical design version + description: Allowed only in draft or changes_requested. Version numbers are allocated transactionally. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignVersionRequest'}}} + responses: + '201': + description: Design version created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/design-versions/{designVersionId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignVersionId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersionDocuments + summary: List documents linked to a design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Version documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: linkEngineeringDesignVersionDocument + summary: Link a clean document to an editable design version + description: Only scan-clean documents may be linked; a version may have only one active primary_drawing. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringDesignVersionDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/design-version-documents/{documentLinkId}: + delete: + tags: [Engineering Design Versions] + operationId: unlinkEngineeringDesignVersionDocument + summary: Temporally unlink a document from an editable design version + description: Submitted, approved, rejected, or superseded version evidence cannot be unlinked. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/reviews: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Reviews] + operationId: listEngineeringDesignReviews + summary: List review recommendations + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Reviews. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Reviews] + operationId: recordEngineeringDesignReview + summary: Record a reviewer recommendation for the submitted version + description: A recommendation is immutable and never directly changes design status. + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.review_recorded + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignReviewRequest'}}} + responses: + '201': + description: Review recommendation recorded. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/inspections: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Inspections] + operationId: listEngineeringProjectInspections + summary: List inspections for a project + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: siteId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringInspectionStatus'} + responses: + '200': + description: Project inspections. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspections] + operationId: createEngineeringInspection + summary: Create a draft inspection + description: Site and inspector must belong to the same project and active organization context. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionRequest'}}} + responses: + '201': + description: Draft inspection created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspections] + operationId: getEngineeringInspection + summary: Retrieve an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Inspections] + operationId: updateEngineeringInspection + summary: Update editable inspection metadata + description: Draft and scheduled inspections are editable; lifecycle fields use commands. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringInspectionRequest'}}} + responses: + '200': + description: Inspection updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}/schedule: + post: + tags: [Engineering Inspections] + operationId: scheduleEngineeringInspection + summary: Schedule a draft inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.scheduled + parameters: &inspectionCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ScheduleInspectionCommand'}}} + responses: &inspectionCommandResponses + '200': + description: Inspection transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}/start: + post: + tags: [Engineering Inspections] + operationId: startEngineeringInspection + summary: Start a scheduled inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.started + parameters: *inspectionCommandParameters + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/StartInspectionCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/complete: + post: + tags: [Engineering Inspections] + operationId: completeEngineeringInspection + summary: Complete an in-progress inspection + description: Outcome is mandatory. Passed outcomes are rejected while major or critical findings remain open. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.completed + parameters: *inspectionCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteInspectionCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/cancel: + post: + tags: [Engineering Inspections] + operationId: cancelEngineeringInspection + summary: Cancel a draft or scheduled inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.cancelled + parameters: *inspectionCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InspectionReasonCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Documents] + operationId: listEngineeringInspectionDocuments + summary: List active inspection-document links + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Documents] + operationId: linkEngineeringInspectionDocument + summary: Link a scan-clean document to an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringInspectionDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-documents/{documentLinkId}: + delete: + tags: [Engineering Inspection Documents] + operationId: unlinkEngineeringInspectionDocument + summary: Temporally unlink an inspection document + description: Completed inspection evidence cannot be unlinked through ordinary workflow. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/inspections/{inspectionId}/findings: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Findings] + operationId: listEngineeringInspectionFindings + summary: List findings for an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection findings. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Findings] + operationId: createEngineeringInspectionFinding + summary: Record a finding during an in-progress inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.finding_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionFindingRequest'}}} + responses: + '201': + description: Finding recorded. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FindingId' + get: + tags: [Engineering Inspection Findings] + operationId: getEngineeringInspectionFinding + summary: Retrieve an inspection finding + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Finding. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Inspection Findings] + operationId: updateEngineeringInspectionFinding + summary: Update finding description, severity, owner, or target date + description: Resolved and accepted-risk findings are immutable except through explicit reopen policy added later. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.finding_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringInspectionFindingRequest'}}} + responses: + '200': + description: Finding updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}/start-remediation: + post: + tags: [Engineering Inspection Findings] + operationId: startEngineeringFindingRemediation + summary: Start corrective work for an open finding + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.finding_remediation_started + parameters: &findingCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FindingId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: &findingCommandResponses + '200': + description: Finding transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}/resolve: + post: + tags: [Engineering Inspection Findings] + operationId: resolveEngineeringInspectionFinding + summary: Independently verify and resolve a remediated finding + description: Verifier must differ from remediation owner unless an explicit privileged override is audited. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.verify_findings] + x-audit-action: engineering.inspection.finding_resolved + parameters: *findingCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ResolveEngineeringFindingCommand'}}} + responses: *findingCommandResponses + + /engineering/inspection-findings/{findingId}/accept-risk: + post: + tags: [Engineering Inspection Findings] + operationId: acceptEngineeringInspectionFindingRisk + summary: Accept the documented risk of an unresolved finding + description: Major and critical acceptance requires privileged authority and a review date. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.accept_risk] + x-audit-action: engineering.inspection.finding_risk_accepted + parameters: *findingCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AcceptEngineeringFindingRiskCommand'}}} + responses: *findingCommandResponses + + /engineering/inspections/{inspectionId}/followups: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Follow-ups] + operationId: listEngineeringInspectionFollowups + summary: List follow-up actions + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Follow-ups. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Follow-ups] + operationId: createEngineeringInspectionFollowup + summary: Create a corrective-task or follow-up-inspection relationship + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.followup_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionFollowupRequest'}}} + responses: + '201': + description: Follow-up created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-followups/{followupId}/{command}: + post: + tags: [Engineering Inspection Follow-ups] + operationId: commandEngineeringInspectionFollowup + summary: Start, complete, or cancel a follow-up + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.followup_commanded + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FollowupId' + - name: command + in: path + required: true + schema: {type: string, enum: [start, complete, cancel]} + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalInspectionReasonCommand'}}} + responses: + '200': + description: Follow-up transitioned. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + +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' + ProjectId: + name: projectId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + ProjectMemberId: + name: memberId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + TaskId: + name: taskId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + SiteId: + name: siteId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentId: + name: documentId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + UploadId: + name: uploadId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentLinkId: + name: documentLinkId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignId: + name: designId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignVersionId: + name: designVersionId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + InspectionId: + name: inspectionId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + FindingId: + name: findingId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + FollowupId: + name: followupId + 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] + EngineeringSite: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, address, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + EngineeringSiteAddress: + type: object + additionalProperties: false + required: [line1, city, countryCode] + properties: + line1: {type: string, minLength: 1, maxLength: 200} + line2: {type: [string, 'null'], maxLength: 200} + city: {type: string, minLength: 1, maxLength: 120} + region: {type: [string, 'null'], maxLength: 120} + postalCode: {type: [string, 'null'], maxLength: 32} + countryCode: {$ref: '#/components/schemas/CountryCode'} + CreateEngineeringSiteRequest: + type: object + additionalProperties: false + required: [name, address] + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + UpdateEngineeringSiteRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + EngineeringSiteResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSite'}} + EngineeringSiteCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringSite'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentClassification: + type: string + enum: [public, internal, confidential, restricted, regulated] + DocumentUploadStatus: + type: string + enum: [initialized, uploading, completed, failed, aborted, expired] + MalwareScanStatus: + type: string + enum: [not_started, pending, scanning, clean, infected, failed] + Document: + type: object + additionalProperties: false + required: [id, organizationId, name, classification, currentVersionId, version, createdByUserId, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersion: {oneOf: [{$ref: '#/components/schemas/DocumentVersion'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + DocumentVersion: + type: object + additionalProperties: false + required: [id, organizationId, documentId, versionNumber, mimeType, sizeBytes, uploadStatus, scanStatus, uploadedByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + hashAlgorithm: {type: string, const: sha256} + uploadStatus: {$ref: '#/components/schemas/DocumentUploadStatus'} + scanStatus: {$ref: '#/components/schemas/MalwareScanStatus'} + scanCompletedAt: {type: [string, 'null'], format: date-time} + available: {type: boolean, readOnly: true, description: True only when uploadStatus is completed and scanStatus is clean.} + uploadedByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadRequest: + type: object + additionalProperties: false + required: [name, classification, mimeType, sizeBytes] + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentVersionRequest: + type: object + additionalProperties: false + required: [mimeType, sizeBytes] + properties: + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + UpdateDocumentRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + CompleteDocumentUploadRequest: + type: object + additionalProperties: false + required: [documentVersionId, contentHash] + properties: + documentVersionId: {$ref: '#/components/schemas/Uuid'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadUrl, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadUrl: {type: string, format: uri} + requiredHeaders: {type: object, additionalProperties: {type: string}} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeDocumentUploadData'}} + InitializeMultipartUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadId, recommendedPartSizeBytes, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadId: {$ref: '#/components/schemas/Uuid'} + recommendedPartSizeBytes: {type: integer, minimum: 5242880} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeMultipartUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeMultipartUploadData'}} + MultipartPartUrlsRequest: + type: object + additionalProperties: false + required: [partNumbers] + properties: + partNumbers: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: {type: integer, minimum: 1, maximum: 10000} + MultipartPartUploadUrl: + type: object + required: [partNumber, uploadUrl, expiresAt] + properties: + partNumber: {type: integer, minimum: 1} + uploadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + MultipartPartUrlsResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/MultipartPartUploadUrl'}}} + CompletedMultipartPart: + type: object + additionalProperties: false + required: [partNumber, etag] + properties: + partNumber: {type: integer, minimum: 1} + etag: {type: string, minLength: 1, maxLength: 200} + CompleteMultipartUploadRequest: + type: object + additionalProperties: false + required: [parts, contentHash] + properties: + parts: + type: array + minItems: 1 + maxItems: 10000 + items: {$ref: '#/components/schemas/CompletedMultipartPart'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + CreateDocumentDownloadRequest: + type: object + additionalProperties: false + properties: + documentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}], description: Defaults to the current clean version.} + DocumentDownloadData: + type: object + required: [downloadUrl, expiresAt] + properties: + downloadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + DocumentDownloadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentDownloadData'}} + DocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Document'}} + DocumentCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Document'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentVersion'}} + DocumentVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/DocumentVersion'}}} + EngineeringProjectDocumentLink: + type: object + additionalProperties: false + required: [id, organizationId, projectId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringProjectDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + EngineeringProjectDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}} + EngineeringProjectDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}}} + EngineeringDesignStatus: + type: string + enum: [draft, under_review, changes_requested, approved, rejected, cancelled, withdrawn, superseded] + EngineeringDesignAssignmentRole: + type: string + enum: [owner, preparer, reviewer, contributor] + EngineeringDesignDocumentRole: + type: string + enum: [primary_drawing, calculation, supporting_document, specification, attachment] + description: Domain-specific registry independent from specification document roles. + EngineeringDesignReviewStatus: + type: string + enum: [approved, changes_requested, rejected] + EngineeringDesign: + type: object + additionalProperties: false + required: [id, organizationId, projectId, designNumber, title, discipline, status, ownerUserId, preparedByUserId, currentVersionId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + status: {$ref: '#/components/schemas/EngineeringDesignStatus'} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + currentVersionId: {$ref: '#/components/schemas/Uuid'} + approvedVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedAt: {type: [string, 'null'], format: date-time} + supersededByDesignId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignRequest: + type: object + additionalProperties: false + required: [designNumber, title, discipline, ownerUserId, preparedByUserId] + properties: + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + UpdateEngineeringDesignRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + EngineeringDesignResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesign'}} + EngineeringDesignCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesign'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + OptionalDesignReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignDecisionCommand: + type: object + additionalProperties: false + required: [designVersionId, reason] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 2000} + ApproveDesignCommand: + type: object + additionalProperties: false + required: [designVersionId, attestation] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + attestation: {type: string, minLength: 10, maxLength: 2000} + SupersedeDesignCommand: + type: object + additionalProperties: false + required: [replacementDesignId, reason] + properties: + replacementDesignId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringDesignAssignment: + type: object + additionalProperties: false + required: [id, organizationId, designId, userId, assignmentRole, assignedByUserId, assignedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + assignedByUserId: {$ref: '#/components/schemas/Uuid'} + assignedAt: {$ref: '#/components/schemas/Timestamp'} + unassignedAt: {type: [string, 'null'], format: date-time} + AssignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [userId, assignmentRole] + properties: + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + UnassignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [assignmentId] + properties: + assignmentId: {$ref: '#/components/schemas/Uuid'} + reason: {type: [string, 'null'], maxLength: 1000} + EngineeringDesignAssignmentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignAssignment'}} + EngineeringDesignAssignmentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignAssignment'}}} + EngineeringDesignVersion: + type: object + additionalProperties: false + required: [id, organizationId, designId, versionNumber, createdByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + changeSummary: {type: [string, 'null'], maxLength: 2000} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignVersionRequest: + type: object + additionalProperties: false + properties: + changeSummary: {type: [string, 'null'], maxLength: 2000} + EngineeringDesignVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersion'}} + EngineeringDesignVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersion'}}} + EngineeringDesignVersionDocument: + type: object + additionalProperties: false + required: [id, organizationId, designVersionId, documentId, documentRole, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringDesignVersionDocumentRequest: + type: object + additionalProperties: false + required: [documentId, documentRole] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + EngineeringDesignVersionDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}} + EngineeringDesignVersionDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}}} + EngineeringDesignReview: + type: object + additionalProperties: false + required: [id, organizationId, designId, designVersionId, reviewerUserId, status, comments, reviewedAt, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + reviewerUserId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + reviewedAt: {$ref: '#/components/schemas/Timestamp'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignReviewRequest: + type: object + additionalProperties: false + required: [designVersionId, status, comments] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + EngineeringDesignReviewResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignReview'}} + EngineeringDesignReviewCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignReview'}}} + EngineeringInspectionStatus: + type: string + enum: [draft, scheduled, in_progress, completed, cancelled] + EngineeringInspectionOutcome: + type: string + enum: [passed, passed_with_observations, followup_required, failed] + EngineeringInspectionFindingSeverity: + type: string + enum: [observation, minor, major, critical] + EngineeringInspectionFindingStatus: + type: string + enum: [open, in_progress, resolved, accepted_risk] + EngineeringInspection: + type: object + additionalProperties: false + required: [id, organizationId, projectId, siteId, inspectionType, inspectorUserId, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100, description: Controlled application registry key.} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringInspectionStatus'} + outcome: {oneOf: [{$ref: '#/components/schemas/EngineeringInspectionOutcome'}, {type: 'null'}]} + scheduledAt: {type: [string, 'null'], format: date-time} + startedAt: {type: [string, 'null'], format: date-time} + performedAt: {type: [string, 'null'], format: date-time} + cancelledAt: {type: [string, 'null'], format: date-time} + summary: {type: [string, 'null'], maxLength: 10000} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringInspectionRequest: + type: object + additionalProperties: false + required: [siteId, inspectionType, inspectorUserId] + properties: + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + summary: {type: [string, 'null'], maxLength: 10000} + UpdateEngineeringInspectionRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + summary: {type: [string, 'null'], maxLength: 10000} + EngineeringInspectionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspection'}} + EngineeringInspectionCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspection'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + ScheduleInspectionCommand: + type: object + additionalProperties: false + required: [scheduledAt] + properties: + scheduledAt: {$ref: '#/components/schemas/Timestamp'} + StartInspectionCommand: + type: object + additionalProperties: false + properties: + startedAt: {$ref: '#/components/schemas/Timestamp'} + CompleteInspectionCommand: + type: object + additionalProperties: false + required: [outcome, summary] + properties: + outcome: {$ref: '#/components/schemas/EngineeringInspectionOutcome'} + performedAt: {$ref: '#/components/schemas/Timestamp'} + summary: {type: string, minLength: 1, maxLength: 10000} + createFollowups: {type: boolean, default: false} + InspectionReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + OptionalInspectionReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringInspectionDocument: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, enum: [evidence, photo, report, certificate, supporting_document]} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringInspectionDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, enum: [evidence, photo, report, certificate, supporting_document]} + EngineeringInspectionDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionDocument'}} + EngineeringInspectionDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionDocument'}}} + EngineeringInspectionFinding: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, severity, description, status, createdByUserId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + status: {$ref: '#/components/schemas/EngineeringInspectionFindingStatus'} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + resolutionSummary: {type: [string, 'null'], maxLength: 10000} + resolvedAt: {type: [string, 'null'], format: date-time} + resolvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + acceptedRiskReason: {type: [string, 'null'], maxLength: 5000} + riskReviewDate: {type: [string, 'null'], format: date} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringInspectionFindingRequest: + type: object + additionalProperties: false + required: [severity, description] + properties: + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + UpdateEngineeringInspectionFindingRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + ResolveEngineeringFindingCommand: + type: object + additionalProperties: false + required: [resolutionSummary] + properties: + resolutionSummary: {type: string, minLength: 3, maxLength: 10000} + evidenceDocumentIds: + type: array + maxItems: 50 + uniqueItems: true + items: {$ref: '#/components/schemas/Uuid'} + privilegedSelfVerificationReason: {type: [string, 'null'], minLength: 10, maxLength: 2000} + AcceptEngineeringFindingRiskCommand: + type: object + additionalProperties: false + required: [reason, reviewDate] + properties: + reason: {type: string, minLength: 10, maxLength: 5000} + reviewDate: {$ref: '#/components/schemas/Date'} + approvingUserId: {$ref: '#/components/schemas/Uuid'} + EngineeringInspectionFindingResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionFinding'}} + EngineeringInspectionFindingCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionFinding'}}} + EngineeringInspectionFollowupType: + type: string + enum: [corrective_task, followup_inspection, both] + EngineeringInspectionFollowupStatus: + type: string + enum: [open, in_progress, completed, cancelled] + EngineeringInspectionFollowup: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, followupType, status, createdByUserId, version, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + followupType: {$ref: '#/components/schemas/EngineeringInspectionFollowupType'} + linkedTaskId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + linkedInspectionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + status: {$ref: '#/components/schemas/EngineeringInspectionFollowupStatus'} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + completedAt: {type: [string, 'null'], format: date-time} + cancelledAt: {type: [string, 'null'], format: date-time} + CreateEngineeringInspectionFollowupRequest: + type: object + additionalProperties: false + required: [followupType] + properties: + followupType: {$ref: '#/components/schemas/EngineeringInspectionFollowupType'} + linkedTaskId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + linkedInspectionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + EngineeringInspectionFollowupResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionFollowup'}} + EngineeringInspectionFollowupCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionFollowup'}}} + 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] + EngineeringProjectRestorableStatus: + type: string + enum: [draft, closed] + EngineeringDiscipline: + type: string + enum: + - civil + - structural + - mechanical + - electrical + - geotechnical + - environmental + - transportation + - water_resources + - surveying + - multidisciplinary + - other + EngineeringProjectMemberRole: + type: string + enum: [engineer, designer, reviewer, inspector, viewer, contractor] + description: Project manager is intentionally excluded; `projectManagerUserId` is authoritative. + EngineeringProjectMemberStatus: + type: string + enum: [active, left] + description: Derived from whether `leftAt` is null. + EngineeringTaskStatus: + type: string + enum: [todo, in_progress, completed, cancelled] + EngineeringTaskPriority: + type: string + enum: [low, medium, high, urgent] + BatchExecutionMode: + type: string + enum: [atomic, partial] + + 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' + + EngineeringProject: + type: object + additionalProperties: false + required: + - id + - organizationId + - clientId + - projectNumber + - name + - description + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - archivedAt + - archivedByUserId + - archivedFromStatus + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + description: Immutable, organization-unique human project reference. + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + archivedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + archivedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + archivedFromStatus: + oneOf: + - $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + enum: [active, closed] + required: [status] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: closed + required: [status] + then: + properties: + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + enum: [draft, active] + required: [status] + then: + properties: + completedDate: + type: 'null' + - if: + properties: + status: + const: archived + required: [status] + then: + properties: + archivedAt: + $ref: '#/components/schemas/Timestamp' + archivedByUserId: + $ref: '#/components/schemas/Uuid' + archivedFromStatus: + $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + else: + properties: + archivedAt: + type: 'null' + archivedByUserId: + type: 'null' + archivedFromStatus: + type: 'null' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: closed + required: [status, archivedFromStatus] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: draft + required: [status, archivedFromStatus] + then: + properties: + completedDate: + type: 'null' + description: Expected and completed dates may not precede the start date. + CreateEngineeringProjectRequest: + type: object + additionalProperties: false + required: [clientId, projectNumber, name, discipline] + properties: + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: Expected completion date may not precede start date. + UpdateEngineeringProjectRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + clientId: + $ref: '#/components/schemas/Uuid' + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: The resulting dates and manager assignment must satisfy the project's current state rules. + ActivateEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + startDate: + $ref: '#/components/schemas/Date' + CloseEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + completedDate: + $ref: '#/components/schemas/Date' + reason: + type: string + maxLength: 500 + EngineeringProjectResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProject' + EngineeringProjectCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProject' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringProjectSummary: + type: object + additionalProperties: false + required: + - id + - clientId + - projectNumber + - name + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - version + - createdAt + - updatedAt + 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: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $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' + + EngineeringClientSummary: + type: object + additionalProperties: false + required: [id, clientType, displayName, legalName, status] + properties: + id: + $ref: '#/components/schemas/Uuid' + clientType: + $ref: '#/components/schemas/EngineeringClientType' + displayName: + type: string + legalName: + type: [string, 'null'] + status: + $ref: '#/components/schemas/EngineeringClientStatus' + EngineeringProjectActivitySummary: + type: object + additionalProperties: false + required: + - projectMemberCount + - phaseCount + - siteCount + - openTaskCount + - designCount + - designsUnderReviewCount + - inspectionCount + - upcomingInspectionCount + - documentCount + - lastActivityAt + properties: + projectMemberCount: + type: integer + minimum: 0 + description: Active participation rows; the separate project-manager pointer is not double-counted. + phaseCount: + type: integer + minimum: 0 + siteCount: + type: integer + minimum: 0 + openTaskCount: + type: integer + minimum: 0 + description: Tasks in todo or in-progress status. + designCount: + type: integer + minimum: 0 + designsUnderReviewCount: + type: integer + minimum: 0 + inspectionCount: + type: integer + minimum: 0 + upcomingInspectionCount: + type: integer + minimum: 0 + documentCount: + type: integer + minimum: 0 + lastActivityAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + EngineeringProjectDashboard: + type: object + additionalProperties: false + required: [project, client, projectManager, activity] + properties: + project: + $ref: '#/components/schemas/EngineeringProject' + client: + $ref: '#/components/schemas/EngineeringClientSummary' + projectManager: + oneOf: + - $ref: '#/components/schemas/UserSummary' + - type: 'null' + activity: + $ref: '#/components/schemas/EngineeringProjectActivitySummary' + EngineeringProjectDashboardResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectDashboard' + + EngineeringProjectMember: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - user + - projectRole + - status + - joinedAt + - leftAt + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + user: + $ref: '#/components/schemas/UserSummary' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + status: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + joinedAt: + $ref: '#/components/schemas/Timestamp' + leftAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: active + required: [status] + then: + properties: + leftAt: + type: 'null' + - if: + properties: + status: + const: left + required: [status] + then: + properties: + leftAt: + $ref: '#/components/schemas/Timestamp' + CreateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [userId, projectRole] + properties: + userId: + $ref: '#/components/schemas/Uuid' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + UpdateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [projectRole] + properties: + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + EngineeringProjectMemberResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectMember' + EngineeringProjectMemberCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProjectMember' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTask: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - title + - description + - status + - priority + - createdByUserId + - assignedToUserId + - dueAt + - startedAt + - startedByUserId + - completedAt + - completedByUserId + - cancelledAt + - cancelledByUserId + - cancellationReason + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + createdByUserId: + $ref: '#/components/schemas/Uuid' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + completedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + completedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancelledAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + cancelledByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancellationReason: + type: [string, 'null'] + maxLength: 500 + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: todo + required: [status] + then: + properties: + startedAt: {type: 'null'} + startedByUserId: {type: 'null'} + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: in_progress + required: [status] + then: + properties: + startedAt: + $ref: '#/components/schemas/Timestamp' + startedByUserId: + $ref: '#/components/schemas/Uuid' + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: completed + required: [status] + then: + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + completedByUserId: + $ref: '#/components/schemas/Uuid' + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: cancelled + required: [status] + then: + properties: + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: + $ref: '#/components/schemas/Timestamp' + cancelledByUserId: + $ref: '#/components/schemas/Uuid' + description: Terminal and start metadata are controlled exclusively by task commands. + CreateEngineeringTaskRequest: + type: object + additionalProperties: false + required: [projectId, title] + properties: + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + allOf: + - $ref: '#/components/schemas/EngineeringTaskPriority' + default: medium + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + UpdateEngineeringTaskRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + CompleteEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + description: A supplied completion time cannot be in the future or precede task creation. + CancelEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + reason: + type: string + maxLength: 500 + EngineeringTaskResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTask' + EngineeringTaskCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringTask' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTaskBatchItem: + type: object + additionalProperties: false + required: [id, version] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + BatchAssignEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, assigneeUserId, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + assigneeUserId: + $ref: '#/components/schemas/Uuid' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected even when their supplied versions differ. + BatchCompleteEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + completedAt: + $ref: '#/components/schemas/Timestamp' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected; completedAt follows the single-task completion rules. + EngineeringTaskBatchSuccess: + type: object + additionalProperties: false + required: [id, version, status, assignedToUserId] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + EngineeringTaskBatchFailure: + type: object + additionalProperties: false + required: [id, code, message, currentVersion] + properties: + id: + $ref: '#/components/schemas/Uuid' + code: + type: string + pattern: '^[A-Z][A-Z0-9_]+$' + message: + type: string + maxLength: 500 + currentVersion: + type: [integer, 'null'] + minimum: 1 + EngineeringTaskBatchResult: + type: object + additionalProperties: false + required: [mode, succeeded, failed] + properties: + mode: + $ref: '#/components/schemas/BatchExecutionMode' + succeeded: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchSuccess' + failed: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchFailure' + EngineeringTaskBatchResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTaskBatchResult' + +security: + - bearerAuth: [] diff --git a/professional-platform-openapi_8.yaml b/professional-platform-openapi_8.yaml new file mode 100644 index 0000000..fd60c5c --- /dev/null +++ b/professional-platform-openapi_8.yaml @@ -0,0 +1,7350 @@ +openapi: 3.1.0 +info: + title: Professional Management Platform API + version: 1.0.0-milestone.8 + summary: Platform access, engineering design and inspection assurance, and controlled specifications. + description: | + Executable API contract for Milestones 1 through 4 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 + - name: Engineering Projects + - name: Engineering Project Members + - name: Engineering Tasks + - name: Engineering Sites + - name: Documents + - name: Engineering Project Documents + - name: Engineering Designs + - name: Engineering Design Assignments + - name: Engineering Design Versions + - name: Engineering Design Reviews + - name: Engineering Inspections + - name: Engineering Inspection Documents + - name: Engineering Inspection Findings + - name: Engineering Inspection Follow-ups + - name: Engineering Specifications + - name: Engineering Specification Documents + +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' + '404': + $ref: '#/components/responses/NotFound' + '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 + description: | + Rejected when the member is the last active organization Owner or manages any + active engineering project, has active project participation, or is assigned open + engineering tasks. Those responsibilities must be reassigned or ended first. + 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' + '404': + $ref: '#/components/responses/NotFound' + '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' + + /engineering/projects: + get: + tags: [Engineering Projects] + operationId: listEngineeringProjects + summary: List engineering projects + description: | + Archived projects are excluded unless `status=archived` is requested explicitly. + Permission scope is enforced in the query: `assigned` resolves through active project + membership or the project-manager pointer; `organization` resolves across the tenant. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: clientId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectStatus' + - name: discipline + in: query + schema: + $ref: '#/components/schemas/EngineeringDiscipline' + - name: projectManagerUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: q + in: query + description: Case-insensitive search across project number, project name, and client name. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Supported deterministic sort. Null date values are always placed last. + schema: + type: string + enum: + - projectNumber + - -projectNumber + - name + - -name + - startDate + - -startDate + - expectedCompletionDate + - -expectedCompletionDate + - createdAt + - -createdAt + default: -createdAt + responses: + '200': + description: Engineering projects returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectCollectionResponse' + '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 Projects] + operationId: createEngineeringProject + summary: Create an engineering project in draft status + description: | + `projectNumber` is immutable and unique case-insensitively within the organization. + The referenced client must be active. A supplied project manager must have an active + membership in the same organization. `projectManagerUserId` is the sole project-manager + authority and is not duplicated as a project-member role. + x-required-profession: engineering + x-required-permissions: [engineering.projects.create] + x-audit-action: engineering.projects.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectRequest' + responses: + '201': + description: Engineering project 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/EngineeringProjectResponse' + '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/projects/{projectId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Projects] + operationId: getEngineeringProject + summary: Get an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Engineering project returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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 Projects] + operationId: updateEngineeringProject + summary: Update editable engineering project fields + description: | + `projectNumber`, `status`, completion fields, and archive fields are not patchable. + `clientId` may change only while the project is `draft` and has no dependent records. + Changing `projectManagerUserId` changes assigned-scope access and is audited. It does + not create a duplicate `project_manager` project-member role. Open tasks assigned to + the outgoing manager must first be reassigned unless that user remains an active member. + x-required-profession: engineering + x-required-permissions: [engineering.projects.update] + x-audit-action: engineering.projects.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectRequest' + responses: + '200': + description: Engineering project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/activate: + post: + tags: [Engineering Projects] + operationId: activateEngineeringProject + summary: Activate a draft engineering project + description: | + Transition: `draft → active`. The client and project manager must both be active. + When `startDate` is absent from both the project and request, the server uses the + current date in the organization's configured time zone. + x-required-profession: engineering + x-required-permissions: [engineering.projects.activate] + x-audit-action: engineering.projects.activate + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEngineeringProjectRequest' + responses: + '200': + description: Engineering project activated or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/close: + post: + tags: [Engineering Projects] + operationId: closeEngineeringProject + summary: Close an active engineering project + description: | + Transition: `active → closed`. When `completedDate` is omitted, the server uses + the current date in the organization's configured time zone. The completed date + cannot precede the project start date. Every task must already be `completed` or + `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.close] + x-audit-action: engineering.projects.close + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CloseEngineeringProjectRequest' + responses: + '200': + description: Engineering project closed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/archive: + post: + tags: [Engineering Projects] + operationId: archiveEngineeringProject + summary: Archive a draft or closed engineering project + description: | + Transition: `draft|closed → archived`. Active projects must be closed first. + The prior status is retained so restore is deterministic. Related records and + audit history are never physically deleted. Every task must already be `completed` + or `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.archive + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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 project archived or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/restore: + post: + tags: [Engineering Projects] + operationId: restoreEngineeringProject + summary: Restore an archived engineering project + description: | + Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`. + Restore never reactivates a project implicitly. The referenced client must be active. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.restore + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering project restored or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/summary: + get: + tags: [Engineering Projects] + operationId: getEngineeringProjectSummary + summary: Get the engineering project dashboard summary + description: | + Returns a purpose-built read model. Counts are permission-filtered and include + only records visible to the caller. Modules not yet enabled return zero counts, + not omitted fields, preserving the response shape. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + responses: + '200': + description: Project summary returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectDashboardResponse' + '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' + + /engineering/projects/{projectId}/members: + get: + tags: [Engineering Project Members] + operationId: listEngineeringProjectMembers + summary: List temporal project-member records + description: By default, only active participation records are returned. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + - name: projectRole + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + - name: userId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: sort + in: query + schema: + type: string + enum: [joinedAt, -joinedAt, name, -name] + default: name + responses: + '200': + description: Project-member records returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberCollectionResponse' + '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 Project Members] + operationId: addEngineeringProjectMember + summary: Add an active organization member to a project + description: | + The project must be `draft` or `active`. The user must have an active organization + membership. Rejoining after departure creates a new temporal row. Only one active + row may exist for a user in a project. Project-manager assignment is controlled by + `projectManagerUserId`, not by this endpoint. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.add + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectMemberRequest' + responses: + '201': + description: Project member added. + 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/EngineeringProjectMemberResponse' + '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/projects/{projectId}/members/{memberId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ProjectMemberId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Project Members] + operationId: getEngineeringProjectMember + summary: Get a project-member record + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Project-member record returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: updateEngineeringProjectMember + summary: Change the participation role of an active project member + description: Only `projectRole` is patchable in v1. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectMemberRequest' + responses: + '200': + description: Participation role updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: endEngineeringProjectMembership + summary: End a user's project participation + description: | + Sets `leftAt`; it never deletes history. Repeating the command with the same + idempotency key replays the original 204 response. Open tasks assigned to the + user must be reassigned or unassigned first. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.remove + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Project participation ended or idempotent result replayed. + 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/tasks: + get: + tags: [Engineering Tasks] + operationId: listEngineeringTasks + summary: List engineering tasks + description: | + Permission scope is enforced per task. Assigned scope resolves when the caller is + the task assignee, an active member of the parent project, or its project manager. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskStatus' + - name: priority + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskPriority' + - name: assignedToUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: assignmentStatus + in: query + schema: + type: string + enum: [assigned, unassigned, any] + default: any + - name: dueBefore + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: dueAfter + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: q + in: query + description: Case-insensitive search across task title and description. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Null due dates are always placed last. + schema: + type: string + enum: [createdAt, -createdAt, dueAt, -dueAt, priority, -priority] + default: -createdAt + responses: + '200': + description: Engineering tasks returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskCollectionResponse' + '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 Tasks] + operationId: createEngineeringTask + summary: Create a task in todo status + description: | + The project must be `draft` or `active`. A supplied assignee must be the project + manager or an active project member and must retain an active organization membership. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringTaskRequest' + responses: + '201': + description: Engineering task 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/EngineeringTaskResponse' + '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/tasks/{taskId}: + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Tasks] + operationId: getEngineeringTask + summary: Get an engineering task + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + responses: + '200': + description: Engineering task returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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 Tasks] + operationId: updateEngineeringTask + summary: Update mutable task fields + description: | + `projectId`, status, creator, and terminal metadata are immutable through PATCH. + Assignment changes revalidate active organization and project participation. + Completed and cancelled tasks must be reopened before they can be edited. The parent + project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringTaskRequest' + responses: + '200': + description: Engineering task updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/start: + post: + tags: [Engineering Tasks] + operationId: startEngineeringTask + summary: Start a todo task + description: 'Transition: `todo → in_progress`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.start + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering task started or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/complete: + post: + tags: [Engineering Tasks] + operationId: completeEngineeringTask + summary: Complete a todo or in-progress task + description: 'Transition: `todo|in_progress → completed`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.complete + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteEngineeringTaskRequest' + responses: + '200': + description: Engineering task completed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/reopen: + post: + tags: [Engineering Tasks] + operationId: reopenEngineeringTask + summary: Reopen a completed or cancelled task + description: | + Transition: `completed|cancelled → todo`. Completion and cancellation metadata + plus any prior start metadata are cleared, while their prior values remain available + through audit history. The parent project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.reopen + parameters: + - $ref: '#/components/parameters/TaskId' + - $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 task reopened or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/cancel: + post: + tags: [Engineering Tasks] + operationId: cancelEngineeringTask + summary: Cancel a todo or in-progress task + description: 'Transition: `todo|in_progress → cancelled`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.cancel + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CancelEngineeringTaskRequest' + responses: + '200': + description: Engineering task cancelled or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/batch/assign: + post: + tags: [Engineering Tasks] + operationId: batchAssignEngineeringTasks + summary: Assign multiple tasks + description: | + Every item carries its expected version and is independently tenant-, permission-, + scope-, project-, assignee-, and state-validated. Atomic mode rolls back all items + on any failure. Partial mode commits valid items and returns per-item failures. + Only `todo` and `in_progress` tasks may be assigned, and the assignee must be an + active participant or project manager for every affected project. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_assign + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchAssignEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/tasks/batch/complete: + post: + tags: [Engineering Tasks] + operationId: batchCompleteEngineeringTasks + summary: Complete multiple tasks + description: | + Every item carries its expected version and is independently authorized and + state-validated. Atomic and partial modes follow the same semantics as batch assign. + Only `todo` and `in_progress` tasks may be completed. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_complete + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCompleteEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/sites: + get: + tags: [Engineering Sites] + operationId: listEngineeringSites + summary: List engineering sites across the active organization + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Sites visible to the caller. + headers: {X-Request-Id: {$ref: '#/components/headers/RequestId'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + + /engineering/projects/{projectId}/sites: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Sites] + operationId: listEngineeringProjectSites + summary: List sites for one project + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: Project sites. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Sites] + operationId: createEngineeringProjectSite + summary: Create a site within a project + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringSiteRequest'}}} + responses: + '201': + description: Site created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/sites/{siteId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SiteId' + get: + tags: [Engineering Sites] + operationId: getEngineeringSite + summary: Retrieve an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + responses: + '200': + description: Site. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Sites] + operationId: updateEngineeringSite + summary: Update an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringSiteRequest'}}} + responses: + '200': + description: Site updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents: + get: + tags: [Documents] + operationId: listDocuments + summary: List document metadata + description: Quarantined and infected versions are excluded unless the caller has documents.security_review. + x-required-permissions: [documents.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: classification + in: query + schema: {$ref: '#/components/schemas/DocumentClassification'} + - name: categoryId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Document metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCollectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + + /documents/upload-url: + post: + tags: [Documents] + operationId: createDocumentUploadUrl + summary: Initialize a single-part document upload + description: Creates quarantined document and version metadata, then returns a short-lived signed PUT URL. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: getDocument + summary: Retrieve document metadata + x-required-permissions: [documents.read] + responses: + '200': + description: Document metadata. No storage key or unsigned object URL is exposed. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Documents] + operationId: updateDocumentMetadata + summary: Update mutable document metadata + description: Classification cannot be weakened below the linked domain record's required classification. + x-required-permissions: [documents.manage] + x-audit-action: document.metadata_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateDocumentRequest'}}} + responses: + '200': + description: Document updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/complete-upload: + post: + tags: [Documents] + operationId: completeDocumentUpload + summary: Verify a single-part upload and enqueue malware inspection + description: Completion changes uploadStatus to completed and scanStatus to pending; it never makes the file downloadable. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteDocumentUploadRequest'}}} + responses: + '202': + description: Object verified and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: listDocumentVersions + summary: List immutable document versions + x-required-permissions: [documents.read] + responses: + '200': + description: Version metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Documents] + operationId: initializeNewDocumentVersion + summary: Initialize a new single-part version upload + description: The current version pointer changes only after upload verification and a clean scan. + x-required-permissions: [documents.upload] + x-audit-action: document.version_upload_initialized + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentVersionRequest'}}} + responses: + '201': + description: Version upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/download-url: + post: + tags: [Documents] + operationId: createDocumentDownloadUrl + summary: Create a short-lived download URL for a clean version + description: Infected, pending, failed, or quarantined versions are never downloadable. + x-required-permissions: [documents.download] + x-audit-action: document.download_authorized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateDocumentDownloadRequest'}}} + responses: + '200': + description: Short-lived download authorization. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentDownloadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /documents/multipart-uploads: + post: + tags: [Documents] + operationId: initializeMultipartDocumentUpload + summary: Initialize a multipart document upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Multipart upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeMultipartUploadResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/parts: + post: + tags: [Documents] + operationId: createMultipartPartUploadUrls + summary: Create signed URLs for selected multipart parts + x-required-permissions: [documents.upload] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsRequest'}}} + responses: + '200': + description: Signed part URLs. + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/complete: + post: + tags: [Documents] + operationId: completeMultipartDocumentUpload + summary: Assemble multipart upload and enqueue malware inspection + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteMultipartUploadRequest'}}} + responses: + '202': + description: Multipart object assembled and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}: + delete: + tags: [Documents] + operationId: abortMultipartDocumentUpload + summary: Abort an unfinished multipart upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_aborted + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Upload aborted; staged object parts are scheduled for cleanup.} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Documents] + operationId: listEngineeringProjectDocuments + summary: List active project-document links + x-required-profession: engineering + x-required-permissions: [engineering.documents.read] + responses: + '200': + description: Project documents filtered by document authorization and scan state. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Documents] + operationId: linkEngineeringProjectDocument + summary: Link a clean shared document to a project + description: Pending, failed, or infected versions cannot be linked as the active project document. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringProjectDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/project-documents/{documentLinkId}: + delete: + tags: [Engineering Project Documents] + operationId: unlinkEngineeringProjectDocument + summary: Temporally unlink a document from a project + description: Sets unlinkedAt; it does not delete the shared document or its versions. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/designs: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Designs] + operationId: listEngineeringProjectDesigns + summary: List designs for a project + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringDesignStatus'} + - name: discipline + in: query + schema: {type: string, minLength: 1, maxLength: 100} + responses: + '200': + description: Project designs. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Designs] + operationId: createEngineeringDesign + summary: Create a draft design + description: Atomically creates version 1 and the required owner/preparer assignments. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignRequest'}}} + responses: + '201': + description: Draft design and initial version created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Designs] + operationId: getEngineeringDesign + summary: Retrieve a design + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Designs] + operationId: updateEngineeringDesign + summary: Update editable design metadata + description: Only draft or changes_requested designs are editable; status changes use commands. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringDesignRequest'}}} + responses: + '200': + description: Design updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/submit-review: + post: + tags: [Engineering Designs] + operationId: submitEngineeringDesignForReview + summary: Submit the current version for review + description: Requires a clean primary drawing and at least one active reviewer assignment. + x-required-profession: engineering + x-required-permissions: [engineering.designs.submit] + x-audit-action: engineering.design.submitted_for_review + parameters: &designCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalDesignReasonCommand'}}} + responses: &designCommandResponses + '200': + description: Design transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/request-changes: + post: + tags: [Engineering Designs] + operationId: requestEngineeringDesignChanges + summary: Return a design to changes requested + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.changes_requested + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/approve: + post: + tags: [Engineering Designs] + operationId: approveEngineeringDesign + summary: Professionally approve the current design version + description: Revalidates current credential, discipline, scope-of-practice, and approval policy. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.approved + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ApproveDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/reject: + post: + tags: [Engineering Designs] + operationId: rejectEngineeringDesign + summary: Reject the current design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.rejected + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/revise: + post: + tags: [Engineering Designs] + operationId: reviseRejectedEngineeringDesign + summary: Reopen a rejected design as draft with a new version + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.revised + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/cancel: + post: + tags: [Engineering Designs] + operationId: cancelEngineeringDesign + summary: Cancel a draft design + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.cancelled + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/withdraw: + post: + tags: [Engineering Designs] + operationId: withdrawEngineeringDesign + summary: Withdraw a design from review + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.withdrawn + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/supersede: + post: + tags: [Engineering Designs] + operationId: supersedeEngineeringDesign + summary: Supersede an approved design + description: Requires the replacement to be a different approved design in the same project and discipline. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.superseded + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SupersedeDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/assignments: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Assignments] + operationId: listEngineeringDesignAssignments + summary: List current and historical design assignments + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - name: activeOnly + in: query + schema: {type: boolean, default: true} + responses: + '200': + description: Assignments. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Assignments] + operationId: assignEngineeringDesignParticipant + summary: Assign a member to a design role + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AssignEngineeringDesignRequest'}}} + responses: + '201': + description: Assignment created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/unassign: + post: + tags: [Engineering Design Assignments] + operationId: unassignEngineeringDesignParticipant + summary: End an active design assignment + description: Sets unassignedAt. The final active owner or required reviewer cannot be removed while workflow depends on that role. + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_ended + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/UnassignEngineeringDesignRequest'}}} + responses: + '204': {description: Assignment ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersions + summary: List immutable logical design versions + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design versions. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: createEngineeringDesignVersion + summary: Create the next logical design version + description: Allowed only in draft or changes_requested. Version numbers are allocated transactionally. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignVersionRequest'}}} + responses: + '201': + description: Design version created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/design-versions/{designVersionId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignVersionId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersionDocuments + summary: List documents linked to a design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Version documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: linkEngineeringDesignVersionDocument + summary: Link a clean document to an editable design version + description: Only scan-clean documents may be linked; a version may have only one active primary_drawing. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringDesignVersionDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/design-version-documents/{documentLinkId}: + delete: + tags: [Engineering Design Versions] + operationId: unlinkEngineeringDesignVersionDocument + summary: Temporally unlink a document from an editable design version + description: Submitted, approved, rejected, or superseded version evidence cannot be unlinked. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/reviews: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Reviews] + operationId: listEngineeringDesignReviews + summary: List review recommendations + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Reviews. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Reviews] + operationId: recordEngineeringDesignReview + summary: Record a reviewer recommendation for the submitted version + description: A recommendation is immutable and never directly changes design status. + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.review_recorded + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignReviewRequest'}}} + responses: + '201': + description: Review recommendation recorded. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/inspections: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Inspections] + operationId: listEngineeringProjectInspections + summary: List inspections for a project + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: siteId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringInspectionStatus'} + responses: + '200': + description: Project inspections. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspections] + operationId: createEngineeringInspection + summary: Create a draft inspection + description: Site and inspector must belong to the same project and active organization context. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionRequest'}}} + responses: + '201': + description: Draft inspection created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspections] + operationId: getEngineeringInspection + summary: Retrieve an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Inspections] + operationId: updateEngineeringInspection + summary: Update editable inspection metadata + description: Draft and scheduled inspections are editable; lifecycle fields use commands. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringInspectionRequest'}}} + responses: + '200': + description: Inspection updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}/schedule: + post: + tags: [Engineering Inspections] + operationId: scheduleEngineeringInspection + summary: Schedule a draft inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.scheduled + parameters: &inspectionCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ScheduleInspectionCommand'}}} + responses: &inspectionCommandResponses + '200': + description: Inspection transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}/start: + post: + tags: [Engineering Inspections] + operationId: startEngineeringInspection + summary: Start a scheduled inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.started + parameters: *inspectionCommandParameters + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/StartInspectionCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/complete: + post: + tags: [Engineering Inspections] + operationId: completeEngineeringInspection + summary: Complete an in-progress inspection + description: Outcome is mandatory. Passed outcomes are rejected while major or critical findings remain open. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.completed + parameters: *inspectionCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteInspectionCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/cancel: + post: + tags: [Engineering Inspections] + operationId: cancelEngineeringInspection + summary: Cancel a draft or scheduled inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.cancelled + parameters: *inspectionCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InspectionReasonCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Documents] + operationId: listEngineeringInspectionDocuments + summary: List active inspection-document links + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Documents] + operationId: linkEngineeringInspectionDocument + summary: Link a scan-clean document to an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringInspectionDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-documents/{documentLinkId}: + delete: + tags: [Engineering Inspection Documents] + operationId: unlinkEngineeringInspectionDocument + summary: Temporally unlink an inspection document + description: Completed inspection evidence cannot be unlinked through ordinary workflow. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/inspections/{inspectionId}/findings: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Findings] + operationId: listEngineeringInspectionFindings + summary: List findings for an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection findings. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Findings] + operationId: createEngineeringInspectionFinding + summary: Record a finding during an in-progress inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.finding_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionFindingRequest'}}} + responses: + '201': + description: Finding recorded. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FindingId' + get: + tags: [Engineering Inspection Findings] + operationId: getEngineeringInspectionFinding + summary: Retrieve an inspection finding + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Finding. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Inspection Findings] + operationId: updateEngineeringInspectionFinding + summary: Update finding description, severity, owner, or target date + description: Resolved and accepted-risk findings are immutable except through explicit reopen policy added later. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.finding_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringInspectionFindingRequest'}}} + responses: + '200': + description: Finding updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}/start-remediation: + post: + tags: [Engineering Inspection Findings] + operationId: startEngineeringFindingRemediation + summary: Start corrective work for an open finding + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.finding_remediation_started + parameters: &findingCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FindingId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: &findingCommandResponses + '200': + description: Finding transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}/resolve: + post: + tags: [Engineering Inspection Findings] + operationId: resolveEngineeringInspectionFinding + summary: Independently verify and resolve a remediated finding + description: Verifier must differ from remediation owner unless an explicit privileged override is audited. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.verify_findings] + x-audit-action: engineering.inspection.finding_resolved + parameters: *findingCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ResolveEngineeringFindingCommand'}}} + responses: *findingCommandResponses + + /engineering/inspection-findings/{findingId}/accept-risk: + post: + tags: [Engineering Inspection Findings] + operationId: acceptEngineeringInspectionFindingRisk + summary: Accept the documented risk of an unresolved finding + description: Major and critical acceptance requires privileged authority and a review date. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.accept_risk] + x-audit-action: engineering.inspection.finding_risk_accepted + parameters: *findingCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AcceptEngineeringFindingRiskCommand'}}} + responses: *findingCommandResponses + + /engineering/inspections/{inspectionId}/followups: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Follow-ups] + operationId: listEngineeringInspectionFollowups + summary: List follow-up actions + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Follow-ups. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Follow-ups] + operationId: createEngineeringInspectionFollowup + summary: Create a corrective-task or follow-up-inspection relationship + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.followup_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionFollowupRequest'}}} + responses: + '201': + description: Follow-up created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-followups/{followupId}/{command}: + post: + tags: [Engineering Inspection Follow-ups] + operationId: commandEngineeringInspectionFollowup + summary: Start, complete, or cancel a follow-up + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.followup_commanded + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FollowupId' + - name: command + in: path + required: true + schema: {type: string, enum: [start, complete, cancel]} + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalInspectionReasonCommand'}}} + responses: + '200': + description: Follow-up transitioned. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/specifications: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Specifications] + operationId: listEngineeringProjectSpecifications + summary: List specifications for a project + x-required-profession: engineering + x-required-permissions: [engineering.specifications.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringSpecificationStatus'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Project specifications. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Specifications] + operationId: createEngineeringSpecification + summary: Create a draft specification + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringSpecificationRequest'}}} + responses: + '201': + description: Draft specification created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specifications/{specificationId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SpecificationId' + get: + tags: [Engineering Specifications] + operationId: getEngineeringSpecification + summary: Retrieve a specification + x-required-profession: engineering + x-required-permissions: [engineering.specifications.read] + responses: + '200': + description: Specification. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Specifications] + operationId: updateEngineeringSpecification + summary: Update draft specification metadata + description: Active, superseded and archived specifications reject PATCH; lifecycle changes use commands. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringSpecificationRequest'}}} + responses: + '200': + description: Specification updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specifications/{specificationId}/activate: + post: + tags: [Engineering Specifications] + operationId: activateEngineeringSpecification + summary: Activate a draft specification + description: Requires exactly one active, scan-clean primary document. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.activate] + x-audit-action: engineering.specification.activated + parameters: &specificationCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SpecificationId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalSpecificationReasonCommand'}}} + responses: &specificationCommandResponses + '200': + description: Specification transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specifications/{specificationId}/supersede: + post: + tags: [Engineering Specifications] + operationId: supersedeEngineeringSpecification + summary: Supersede an active specification + description: Replacement must be a different active specification in the same project. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.activate] + x-audit-action: engineering.specification.superseded + parameters: *specificationCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SupersedeSpecificationCommand'}}} + responses: *specificationCommandResponses + + /engineering/specifications/{specificationId}/archive: + post: + tags: [Engineering Specifications] + operationId: archiveEngineeringSpecification + summary: Archive a draft or active specification + description: Stores archivedFromStatus; active specifications referenced by open work may be blocked. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.archived + parameters: *specificationCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SpecificationReasonCommand'}}} + responses: *specificationCommandResponses + + /engineering/specifications/{specificationId}/restore: + post: + tags: [Engineering Specifications] + operationId: restoreEngineeringSpecification + summary: Restore an archived specification to its prior status + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.restored + parameters: *specificationCommandParameters + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalSpecificationReasonCommand'}}} + responses: *specificationCommandResponses + + /engineering/specifications/{specificationId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SpecificationId' + get: + tags: [Engineering Specification Documents] + operationId: listEngineeringSpecificationDocuments + summary: List current and historical specification-document links + x-required-profession: engineering + x-required-permissions: [engineering.specifications.read] + parameters: + - name: activeOnly + in: query + schema: {type: boolean, default: true} + responses: + '200': + description: Specification documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Specification Documents] + operationId: linkEngineeringSpecificationDocument + summary: Link a scan-clean document to a draft specification + description: A draft may have only one active primary link; active or terminal evidence is immutable. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringSpecificationDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specification-documents/{documentLinkId}: + delete: + tags: [Engineering Specification Documents] + operationId: unlinkEngineeringSpecificationDocument + summary: Temporally unlink a document from a draft specification + description: Sets unlinkedAt; it never deletes the shared document or active/terminal evidence. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + +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' + ProjectId: + name: projectId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + ProjectMemberId: + name: memberId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + TaskId: + name: taskId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + SiteId: + name: siteId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentId: + name: documentId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + UploadId: + name: uploadId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentLinkId: + name: documentLinkId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignId: + name: designId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignVersionId: + name: designVersionId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + InspectionId: + name: inspectionId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + FindingId: + name: findingId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + FollowupId: + name: followupId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + SpecificationId: + name: specificationId + 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] + EngineeringSite: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, address, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + EngineeringSiteAddress: + type: object + additionalProperties: false + required: [line1, city, countryCode] + properties: + line1: {type: string, minLength: 1, maxLength: 200} + line2: {type: [string, 'null'], maxLength: 200} + city: {type: string, minLength: 1, maxLength: 120} + region: {type: [string, 'null'], maxLength: 120} + postalCode: {type: [string, 'null'], maxLength: 32} + countryCode: {$ref: '#/components/schemas/CountryCode'} + CreateEngineeringSiteRequest: + type: object + additionalProperties: false + required: [name, address] + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + UpdateEngineeringSiteRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + EngineeringSiteResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSite'}} + EngineeringSiteCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringSite'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentClassification: + type: string + enum: [public, internal, confidential, restricted, regulated] + DocumentUploadStatus: + type: string + enum: [initialized, uploading, completed, failed, aborted, expired] + MalwareScanStatus: + type: string + enum: [not_started, pending, scanning, clean, infected, failed] + Document: + type: object + additionalProperties: false + required: [id, organizationId, name, classification, currentVersionId, version, createdByUserId, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersion: {oneOf: [{$ref: '#/components/schemas/DocumentVersion'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + DocumentVersion: + type: object + additionalProperties: false + required: [id, organizationId, documentId, versionNumber, mimeType, sizeBytes, uploadStatus, scanStatus, uploadedByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + hashAlgorithm: {type: string, const: sha256} + uploadStatus: {$ref: '#/components/schemas/DocumentUploadStatus'} + scanStatus: {$ref: '#/components/schemas/MalwareScanStatus'} + scanCompletedAt: {type: [string, 'null'], format: date-time} + available: {type: boolean, readOnly: true, description: True only when uploadStatus is completed and scanStatus is clean.} + uploadedByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadRequest: + type: object + additionalProperties: false + required: [name, classification, mimeType, sizeBytes] + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentVersionRequest: + type: object + additionalProperties: false + required: [mimeType, sizeBytes] + properties: + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + UpdateDocumentRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + CompleteDocumentUploadRequest: + type: object + additionalProperties: false + required: [documentVersionId, contentHash] + properties: + documentVersionId: {$ref: '#/components/schemas/Uuid'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadUrl, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadUrl: {type: string, format: uri} + requiredHeaders: {type: object, additionalProperties: {type: string}} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeDocumentUploadData'}} + InitializeMultipartUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadId, recommendedPartSizeBytes, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadId: {$ref: '#/components/schemas/Uuid'} + recommendedPartSizeBytes: {type: integer, minimum: 5242880} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeMultipartUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeMultipartUploadData'}} + MultipartPartUrlsRequest: + type: object + additionalProperties: false + required: [partNumbers] + properties: + partNumbers: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: {type: integer, minimum: 1, maximum: 10000} + MultipartPartUploadUrl: + type: object + required: [partNumber, uploadUrl, expiresAt] + properties: + partNumber: {type: integer, minimum: 1} + uploadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + MultipartPartUrlsResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/MultipartPartUploadUrl'}}} + CompletedMultipartPart: + type: object + additionalProperties: false + required: [partNumber, etag] + properties: + partNumber: {type: integer, minimum: 1} + etag: {type: string, minLength: 1, maxLength: 200} + CompleteMultipartUploadRequest: + type: object + additionalProperties: false + required: [parts, contentHash] + properties: + parts: + type: array + minItems: 1 + maxItems: 10000 + items: {$ref: '#/components/schemas/CompletedMultipartPart'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + CreateDocumentDownloadRequest: + type: object + additionalProperties: false + properties: + documentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}], description: Defaults to the current clean version.} + DocumentDownloadData: + type: object + required: [downloadUrl, expiresAt] + properties: + downloadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + DocumentDownloadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentDownloadData'}} + DocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Document'}} + DocumentCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Document'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentVersion'}} + DocumentVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/DocumentVersion'}}} + EngineeringProjectDocumentLink: + type: object + additionalProperties: false + required: [id, organizationId, projectId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringProjectDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + EngineeringProjectDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}} + EngineeringProjectDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}}} + EngineeringDesignStatus: + type: string + enum: [draft, under_review, changes_requested, approved, rejected, cancelled, withdrawn, superseded] + EngineeringDesignAssignmentRole: + type: string + enum: [owner, preparer, reviewer, contributor] + EngineeringDesignDocumentRole: + type: string + enum: [primary_drawing, calculation, supporting_document, specification, attachment] + description: Domain-specific registry independent from specification document roles. + EngineeringDesignReviewStatus: + type: string + enum: [approved, changes_requested, rejected] + EngineeringDesign: + type: object + additionalProperties: false + required: [id, organizationId, projectId, designNumber, title, discipline, status, ownerUserId, preparedByUserId, currentVersionId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + status: {$ref: '#/components/schemas/EngineeringDesignStatus'} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + currentVersionId: {$ref: '#/components/schemas/Uuid'} + approvedVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedAt: {type: [string, 'null'], format: date-time} + supersededByDesignId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignRequest: + type: object + additionalProperties: false + required: [designNumber, title, discipline, ownerUserId, preparedByUserId] + properties: + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + UpdateEngineeringDesignRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + EngineeringDesignResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesign'}} + EngineeringDesignCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesign'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + OptionalDesignReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignDecisionCommand: + type: object + additionalProperties: false + required: [designVersionId, reason] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 2000} + ApproveDesignCommand: + type: object + additionalProperties: false + required: [designVersionId, attestation] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + attestation: {type: string, minLength: 10, maxLength: 2000} + SupersedeDesignCommand: + type: object + additionalProperties: false + required: [replacementDesignId, reason] + properties: + replacementDesignId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringDesignAssignment: + type: object + additionalProperties: false + required: [id, organizationId, designId, userId, assignmentRole, assignedByUserId, assignedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + assignedByUserId: {$ref: '#/components/schemas/Uuid'} + assignedAt: {$ref: '#/components/schemas/Timestamp'} + unassignedAt: {type: [string, 'null'], format: date-time} + AssignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [userId, assignmentRole] + properties: + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + UnassignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [assignmentId] + properties: + assignmentId: {$ref: '#/components/schemas/Uuid'} + reason: {type: [string, 'null'], maxLength: 1000} + EngineeringDesignAssignmentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignAssignment'}} + EngineeringDesignAssignmentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignAssignment'}}} + EngineeringDesignVersion: + type: object + additionalProperties: false + required: [id, organizationId, designId, versionNumber, createdByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + changeSummary: {type: [string, 'null'], maxLength: 2000} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignVersionRequest: + type: object + additionalProperties: false + properties: + changeSummary: {type: [string, 'null'], maxLength: 2000} + EngineeringDesignVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersion'}} + EngineeringDesignVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersion'}}} + EngineeringDesignVersionDocument: + type: object + additionalProperties: false + required: [id, organizationId, designVersionId, documentId, documentRole, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringDesignVersionDocumentRequest: + type: object + additionalProperties: false + required: [documentId, documentRole] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + EngineeringDesignVersionDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}} + EngineeringDesignVersionDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}}} + EngineeringDesignReview: + type: object + additionalProperties: false + required: [id, organizationId, designId, designVersionId, reviewerUserId, status, comments, reviewedAt, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + reviewerUserId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + reviewedAt: {$ref: '#/components/schemas/Timestamp'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignReviewRequest: + type: object + additionalProperties: false + required: [designVersionId, status, comments] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + EngineeringDesignReviewResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignReview'}} + EngineeringDesignReviewCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignReview'}}} + EngineeringInspectionStatus: + type: string + enum: [draft, scheduled, in_progress, completed, cancelled] + EngineeringInspectionOutcome: + type: string + enum: [passed, passed_with_observations, followup_required, failed] + EngineeringInspectionFindingSeverity: + type: string + enum: [observation, minor, major, critical] + EngineeringInspectionFindingStatus: + type: string + enum: [open, in_progress, resolved, accepted_risk] + EngineeringInspection: + type: object + additionalProperties: false + required: [id, organizationId, projectId, siteId, inspectionType, inspectorUserId, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100, description: Controlled application registry key.} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringInspectionStatus'} + outcome: {oneOf: [{$ref: '#/components/schemas/EngineeringInspectionOutcome'}, {type: 'null'}]} + scheduledAt: {type: [string, 'null'], format: date-time} + startedAt: {type: [string, 'null'], format: date-time} + performedAt: {type: [string, 'null'], format: date-time} + cancelledAt: {type: [string, 'null'], format: date-time} + summary: {type: [string, 'null'], maxLength: 10000} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringInspectionRequest: + type: object + additionalProperties: false + required: [siteId, inspectionType, inspectorUserId] + properties: + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + summary: {type: [string, 'null'], maxLength: 10000} + UpdateEngineeringInspectionRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + summary: {type: [string, 'null'], maxLength: 10000} + EngineeringInspectionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspection'}} + EngineeringInspectionCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspection'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + ScheduleInspectionCommand: + type: object + additionalProperties: false + required: [scheduledAt] + properties: + scheduledAt: {$ref: '#/components/schemas/Timestamp'} + StartInspectionCommand: + type: object + additionalProperties: false + properties: + startedAt: {$ref: '#/components/schemas/Timestamp'} + CompleteInspectionCommand: + type: object + additionalProperties: false + required: [outcome, summary] + properties: + outcome: {$ref: '#/components/schemas/EngineeringInspectionOutcome'} + performedAt: {$ref: '#/components/schemas/Timestamp'} + summary: {type: string, minLength: 1, maxLength: 10000} + createFollowups: {type: boolean, default: false} + InspectionReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + OptionalInspectionReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringInspectionDocument: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, enum: [evidence, photo, report, certificate, supporting_document]} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringInspectionDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, enum: [evidence, photo, report, certificate, supporting_document]} + EngineeringInspectionDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionDocument'}} + EngineeringInspectionDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionDocument'}}} + EngineeringInspectionFinding: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, severity, description, status, createdByUserId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + status: {$ref: '#/components/schemas/EngineeringInspectionFindingStatus'} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + resolutionSummary: {type: [string, 'null'], maxLength: 10000} + resolvedAt: {type: [string, 'null'], format: date-time} + resolvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + acceptedRiskReason: {type: [string, 'null'], maxLength: 5000} + riskReviewDate: {type: [string, 'null'], format: date} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringInspectionFindingRequest: + type: object + additionalProperties: false + required: [severity, description] + properties: + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + UpdateEngineeringInspectionFindingRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + ResolveEngineeringFindingCommand: + type: object + additionalProperties: false + required: [resolutionSummary] + properties: + resolutionSummary: {type: string, minLength: 3, maxLength: 10000} + evidenceDocumentIds: + type: array + maxItems: 50 + uniqueItems: true + items: {$ref: '#/components/schemas/Uuid'} + privilegedSelfVerificationReason: {type: [string, 'null'], minLength: 10, maxLength: 2000} + AcceptEngineeringFindingRiskCommand: + type: object + additionalProperties: false + required: [reason, reviewDate] + properties: + reason: {type: string, minLength: 10, maxLength: 5000} + reviewDate: {$ref: '#/components/schemas/Date'} + approvingUserId: {$ref: '#/components/schemas/Uuid'} + EngineeringInspectionFindingResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionFinding'}} + EngineeringInspectionFindingCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionFinding'}}} + EngineeringInspectionFollowupType: + type: string + enum: [corrective_task, followup_inspection, both] + EngineeringInspectionFollowupStatus: + type: string + enum: [open, in_progress, completed, cancelled] + EngineeringInspectionFollowup: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, followupType, status, createdByUserId, version, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + followupType: {$ref: '#/components/schemas/EngineeringInspectionFollowupType'} + linkedTaskId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + linkedInspectionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + status: {$ref: '#/components/schemas/EngineeringInspectionFollowupStatus'} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + completedAt: {type: [string, 'null'], format: date-time} + cancelledAt: {type: [string, 'null'], format: date-time} + CreateEngineeringInspectionFollowupRequest: + type: object + additionalProperties: false + required: [followupType] + properties: + followupType: {$ref: '#/components/schemas/EngineeringInspectionFollowupType'} + linkedTaskId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + linkedInspectionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + EngineeringInspectionFollowupResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionFollowup'}} + EngineeringInspectionFollowupCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionFollowup'}}} + EngineeringSpecificationStatus: + type: string + enum: [draft, active, superseded, archived] + EngineeringSpecificationDocumentRole: + type: string + enum: [primary, attachment, supporting_document] + description: Specification-specific registry; independent from design-version document roles. + EngineeringSpecification: + type: object + additionalProperties: false + required: [id, organizationId, projectId, specificationNumber, title, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + specificationNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + status: {$ref: '#/components/schemas/EngineeringSpecificationStatus'} + supersededBySpecificationId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + archivedFromStatus: + oneOf: + - type: string + enum: [draft, active] + - type: 'null' + archivedAt: {type: [string, 'null'], format: date-time} + archivedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringSpecificationRequest: + type: object + additionalProperties: false + required: [specificationNumber, title] + properties: + specificationNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + UpdateEngineeringSpecificationRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + EngineeringSpecificationResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSpecification'}} + EngineeringSpecificationCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringSpecification'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + SpecificationReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + OptionalSpecificationReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + SupersedeSpecificationCommand: + type: object + additionalProperties: false + required: [supersededBySpecificationId, reason] + properties: + supersededBySpecificationId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringSpecificationDocument: + type: object + additionalProperties: false + required: [id, organizationId, specificationId, documentId, documentRole, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + specificationId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringSpecificationDocumentRole'} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringSpecificationDocumentRequest: + type: object + additionalProperties: false + required: [documentId, documentRole] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringSpecificationDocumentRole'} + EngineeringSpecificationDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSpecificationDocument'}} + EngineeringSpecificationDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringSpecificationDocument'}}} + 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] + EngineeringProjectRestorableStatus: + type: string + enum: [draft, closed] + EngineeringDiscipline: + type: string + enum: + - civil + - structural + - mechanical + - electrical + - geotechnical + - environmental + - transportation + - water_resources + - surveying + - multidisciplinary + - other + EngineeringProjectMemberRole: + type: string + enum: [engineer, designer, reviewer, inspector, viewer, contractor] + description: Project manager is intentionally excluded; `projectManagerUserId` is authoritative. + EngineeringProjectMemberStatus: + type: string + enum: [active, left] + description: Derived from whether `leftAt` is null. + EngineeringTaskStatus: + type: string + enum: [todo, in_progress, completed, cancelled] + EngineeringTaskPriority: + type: string + enum: [low, medium, high, urgent] + BatchExecutionMode: + type: string + enum: [atomic, partial] + + 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' + + EngineeringProject: + type: object + additionalProperties: false + required: + - id + - organizationId + - clientId + - projectNumber + - name + - description + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - archivedAt + - archivedByUserId + - archivedFromStatus + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + description: Immutable, organization-unique human project reference. + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + archivedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + archivedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + archivedFromStatus: + oneOf: + - $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + enum: [active, closed] + required: [status] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: closed + required: [status] + then: + properties: + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + enum: [draft, active] + required: [status] + then: + properties: + completedDate: + type: 'null' + - if: + properties: + status: + const: archived + required: [status] + then: + properties: + archivedAt: + $ref: '#/components/schemas/Timestamp' + archivedByUserId: + $ref: '#/components/schemas/Uuid' + archivedFromStatus: + $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + else: + properties: + archivedAt: + type: 'null' + archivedByUserId: + type: 'null' + archivedFromStatus: + type: 'null' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: closed + required: [status, archivedFromStatus] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: draft + required: [status, archivedFromStatus] + then: + properties: + completedDate: + type: 'null' + description: Expected and completed dates may not precede the start date. + CreateEngineeringProjectRequest: + type: object + additionalProperties: false + required: [clientId, projectNumber, name, discipline] + properties: + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: Expected completion date may not precede start date. + UpdateEngineeringProjectRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + clientId: + $ref: '#/components/schemas/Uuid' + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: The resulting dates and manager assignment must satisfy the project's current state rules. + ActivateEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + startDate: + $ref: '#/components/schemas/Date' + CloseEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + completedDate: + $ref: '#/components/schemas/Date' + reason: + type: string + maxLength: 500 + EngineeringProjectResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProject' + EngineeringProjectCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProject' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringProjectSummary: + type: object + additionalProperties: false + required: + - id + - clientId + - projectNumber + - name + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - version + - createdAt + - updatedAt + 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: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $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' + + EngineeringClientSummary: + type: object + additionalProperties: false + required: [id, clientType, displayName, legalName, status] + properties: + id: + $ref: '#/components/schemas/Uuid' + clientType: + $ref: '#/components/schemas/EngineeringClientType' + displayName: + type: string + legalName: + type: [string, 'null'] + status: + $ref: '#/components/schemas/EngineeringClientStatus' + EngineeringProjectActivitySummary: + type: object + additionalProperties: false + required: + - projectMemberCount + - phaseCount + - siteCount + - openTaskCount + - designCount + - designsUnderReviewCount + - inspectionCount + - upcomingInspectionCount + - documentCount + - lastActivityAt + properties: + projectMemberCount: + type: integer + minimum: 0 + description: Active participation rows; the separate project-manager pointer is not double-counted. + phaseCount: + type: integer + minimum: 0 + siteCount: + type: integer + minimum: 0 + openTaskCount: + type: integer + minimum: 0 + description: Tasks in todo or in-progress status. + designCount: + type: integer + minimum: 0 + designsUnderReviewCount: + type: integer + minimum: 0 + inspectionCount: + type: integer + minimum: 0 + upcomingInspectionCount: + type: integer + minimum: 0 + documentCount: + type: integer + minimum: 0 + lastActivityAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + EngineeringProjectDashboard: + type: object + additionalProperties: false + required: [project, client, projectManager, activity] + properties: + project: + $ref: '#/components/schemas/EngineeringProject' + client: + $ref: '#/components/schemas/EngineeringClientSummary' + projectManager: + oneOf: + - $ref: '#/components/schemas/UserSummary' + - type: 'null' + activity: + $ref: '#/components/schemas/EngineeringProjectActivitySummary' + EngineeringProjectDashboardResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectDashboard' + + EngineeringProjectMember: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - user + - projectRole + - status + - joinedAt + - leftAt + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + user: + $ref: '#/components/schemas/UserSummary' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + status: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + joinedAt: + $ref: '#/components/schemas/Timestamp' + leftAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: active + required: [status] + then: + properties: + leftAt: + type: 'null' + - if: + properties: + status: + const: left + required: [status] + then: + properties: + leftAt: + $ref: '#/components/schemas/Timestamp' + CreateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [userId, projectRole] + properties: + userId: + $ref: '#/components/schemas/Uuid' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + UpdateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [projectRole] + properties: + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + EngineeringProjectMemberResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectMember' + EngineeringProjectMemberCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProjectMember' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTask: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - title + - description + - status + - priority + - createdByUserId + - assignedToUserId + - dueAt + - startedAt + - startedByUserId + - completedAt + - completedByUserId + - cancelledAt + - cancelledByUserId + - cancellationReason + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + createdByUserId: + $ref: '#/components/schemas/Uuid' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + completedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + completedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancelledAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + cancelledByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancellationReason: + type: [string, 'null'] + maxLength: 500 + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: todo + required: [status] + then: + properties: + startedAt: {type: 'null'} + startedByUserId: {type: 'null'} + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: in_progress + required: [status] + then: + properties: + startedAt: + $ref: '#/components/schemas/Timestamp' + startedByUserId: + $ref: '#/components/schemas/Uuid' + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: completed + required: [status] + then: + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + completedByUserId: + $ref: '#/components/schemas/Uuid' + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: cancelled + required: [status] + then: + properties: + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: + $ref: '#/components/schemas/Timestamp' + cancelledByUserId: + $ref: '#/components/schemas/Uuid' + description: Terminal and start metadata are controlled exclusively by task commands. + CreateEngineeringTaskRequest: + type: object + additionalProperties: false + required: [projectId, title] + properties: + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + allOf: + - $ref: '#/components/schemas/EngineeringTaskPriority' + default: medium + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + UpdateEngineeringTaskRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + CompleteEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + description: A supplied completion time cannot be in the future or precede task creation. + CancelEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + reason: + type: string + maxLength: 500 + EngineeringTaskResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTask' + EngineeringTaskCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringTask' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTaskBatchItem: + type: object + additionalProperties: false + required: [id, version] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + BatchAssignEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, assigneeUserId, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + assigneeUserId: + $ref: '#/components/schemas/Uuid' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected even when their supplied versions differ. + BatchCompleteEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + completedAt: + $ref: '#/components/schemas/Timestamp' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected; completedAt follows the single-task completion rules. + EngineeringTaskBatchSuccess: + type: object + additionalProperties: false + required: [id, version, status, assignedToUserId] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + EngineeringTaskBatchFailure: + type: object + additionalProperties: false + required: [id, code, message, currentVersion] + properties: + id: + $ref: '#/components/schemas/Uuid' + code: + type: string + pattern: '^[A-Z][A-Z0-9_]+$' + message: + type: string + maxLength: 500 + currentVersion: + type: [integer, 'null'] + minimum: 1 + EngineeringTaskBatchResult: + type: object + additionalProperties: false + required: [mode, succeeded, failed] + properties: + mode: + $ref: '#/components/schemas/BatchExecutionMode' + succeeded: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchSuccess' + failed: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchFailure' + EngineeringTaskBatchResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTaskBatchResult' + +security: + - bearerAuth: [] diff --git a/professional-platform-openapi_9.yaml b/professional-platform-openapi_9.yaml new file mode 100644 index 0000000..053abaa --- /dev/null +++ b/professional-platform-openapi_9.yaml @@ -0,0 +1,7964 @@ +openapi: 3.1.0 +info: + title: Professional Management Platform API + version: 1.0.0-milestone.9 + summary: Engineering delivery control with phases, time capture, and approved budgets. + description: | + Executable API contract for Milestones 1 through 4 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 + - name: Engineering Projects + - name: Engineering Project Members + - name: Engineering Tasks + - name: Engineering Sites + - name: Documents + - name: Engineering Project Documents + - name: Engineering Designs + - name: Engineering Design Assignments + - name: Engineering Design Versions + - name: Engineering Design Reviews + - name: Engineering Inspections + - name: Engineering Inspection Documents + - name: Engineering Inspection Findings + - name: Engineering Inspection Follow-ups + - name: Engineering Specifications + - name: Engineering Specification Documents + - name: Engineering Project Phases + - name: Engineering Time Entries + - name: Engineering Project Budgets + +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' + '404': + $ref: '#/components/responses/NotFound' + '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 + description: | + Rejected when the member is the last active organization Owner or manages any + active engineering project, has active project participation, or is assigned open + engineering tasks. Those responsibilities must be reassigned or ended first. + 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' + '404': + $ref: '#/components/responses/NotFound' + '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' + + /engineering/projects: + get: + tags: [Engineering Projects] + operationId: listEngineeringProjects + summary: List engineering projects + description: | + Archived projects are excluded unless `status=archived` is requested explicitly. + Permission scope is enforced in the query: `assigned` resolves through active project + membership or the project-manager pointer; `organization` resolves across the tenant. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: clientId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectStatus' + - name: discipline + in: query + schema: + $ref: '#/components/schemas/EngineeringDiscipline' + - name: projectManagerUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: q + in: query + description: Case-insensitive search across project number, project name, and client name. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Supported deterministic sort. Null date values are always placed last. + schema: + type: string + enum: + - projectNumber + - -projectNumber + - name + - -name + - startDate + - -startDate + - expectedCompletionDate + - -expectedCompletionDate + - createdAt + - -createdAt + default: -createdAt + responses: + '200': + description: Engineering projects returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectCollectionResponse' + '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 Projects] + operationId: createEngineeringProject + summary: Create an engineering project in draft status + description: | + `projectNumber` is immutable and unique case-insensitively within the organization. + The referenced client must be active. A supplied project manager must have an active + membership in the same organization. `projectManagerUserId` is the sole project-manager + authority and is not duplicated as a project-member role. + x-required-profession: engineering + x-required-permissions: [engineering.projects.create] + x-audit-action: engineering.projects.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectRequest' + responses: + '201': + description: Engineering project 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/EngineeringProjectResponse' + '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/projects/{projectId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Projects] + operationId: getEngineeringProject + summary: Get an engineering project + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Engineering project returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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 Projects] + operationId: updateEngineeringProject + summary: Update editable engineering project fields + description: | + `projectNumber`, `status`, completion fields, and archive fields are not patchable. + `clientId` may change only while the project is `draft` and has no dependent records. + Changing `projectManagerUserId` changes assigned-scope access and is audited. It does + not create a duplicate `project_manager` project-member role. Open tasks assigned to + the outgoing manager must first be reassigned unless that user remains an active member. + x-required-profession: engineering + x-required-permissions: [engineering.projects.update] + x-audit-action: engineering.projects.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectRequest' + responses: + '200': + description: Engineering project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/activate: + post: + tags: [Engineering Projects] + operationId: activateEngineeringProject + summary: Activate a draft engineering project + description: | + Transition: `draft → active`. The client and project manager must both be active. + When `startDate` is absent from both the project and request, the server uses the + current date in the organization's configured time zone. + x-required-profession: engineering + x-required-permissions: [engineering.projects.activate] + x-audit-action: engineering.projects.activate + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateEngineeringProjectRequest' + responses: + '200': + description: Engineering project activated or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/close: + post: + tags: [Engineering Projects] + operationId: closeEngineeringProject + summary: Close an active engineering project + description: | + Transition: `active → closed`. When `completedDate` is omitted, the server uses + the current date in the organization's configured time zone. The completed date + cannot precede the project start date. Every task must already be `completed` or + `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.close] + x-audit-action: engineering.projects.close + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CloseEngineeringProjectRequest' + responses: + '200': + description: Engineering project closed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/archive: + post: + tags: [Engineering Projects] + operationId: archiveEngineeringProject + summary: Archive a draft or closed engineering project + description: | + Transition: `draft|closed → archived`. Active projects must be closed first. + The prior status is retained so restore is deterministic. Related records and + audit history are never physically deleted. Every task must already be `completed` + or `cancelled`. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.archive + parameters: + - $ref: '#/components/parameters/ProjectId' + - $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 project archived or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/restore: + post: + tags: [Engineering Projects] + operationId: restoreEngineeringProject + summary: Restore an archived engineering project + description: | + Transition: `archived → archivedFromStatus`, which is either `draft` or `closed`. + Restore never reactivates a project implicitly. The referenced client must be active. + x-required-profession: engineering + x-required-permissions: [engineering.projects.archive] + x-audit-action: engineering.projects.restore + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering project restored or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectResponse' + '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/projects/{projectId}/summary: + get: + tags: [Engineering Projects] + operationId: getEngineeringProjectSummary + summary: Get the engineering project dashboard summary + description: | + Returns a purpose-built read model. Counts are permission-filtered and include + only records visible to the caller. Modules not yet enabled return zero counts, + not omitted fields, preserving the response shape. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + responses: + '200': + description: Project summary returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectDashboardResponse' + '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' + + /engineering/projects/{projectId}/members: + get: + tags: [Engineering Project Members] + operationId: listEngineeringProjectMembers + summary: List temporal project-member records + description: By default, only active participation records are returned. + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + - name: projectRole + in: query + schema: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + - name: userId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: sort + in: query + schema: + type: string + enum: [joinedAt, -joinedAt, name, -name] + default: name + responses: + '200': + description: Project-member records returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberCollectionResponse' + '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 Project Members] + operationId: addEngineeringProjectMember + summary: Add an active organization member to a project + description: | + The project must be `draft` or `active`. The user must have an active organization + membership. Rejoining after departure creates a new temporal row. Only one active + row may exist for a user in a project. Project-manager assignment is controlled by + `projectManagerUserId`, not by this endpoint. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.add + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringProjectMemberRequest' + responses: + '201': + description: Project member added. + 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/EngineeringProjectMemberResponse' + '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/projects/{projectId}/members/{memberId}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ProjectMemberId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Project Members] + operationId: getEngineeringProjectMember + summary: Get a project-member record + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + x-permission-scope-policy: engineering_project_assignment + responses: + '200': + description: Project-member record returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: updateEngineeringProjectMember + summary: Change the participation role of an active project member + description: Only `projectRole` is patchable in v1. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringProjectMemberRequest' + responses: + '200': + description: Participation role updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringProjectMemberResponse' + '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 Project Members] + operationId: endEngineeringProjectMembership + summary: End a user's project participation + description: | + Sets `leftAt`; it never deletes history. Repeating the command with the same + idempotency key replays the original 204 response. Open tasks assigned to the + user must be reassigned or unassigned first. + x-required-profession: engineering + x-required-permissions: [engineering.project_members.manage] + x-audit-action: engineering.project_members.remove + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Project participation ended or idempotent result replayed. + 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/tasks: + get: + tags: [Engineering Tasks] + operationId: listEngineeringTasks + summary: List engineering tasks + description: | + Permission scope is enforced per task. Assigned scope resolves when the caller is + the task assignee, an active member of the parent project, or its project manager. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: status + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskStatus' + - name: priority + in: query + schema: + $ref: '#/components/schemas/EngineeringTaskPriority' + - name: assignedToUserId + in: query + schema: + $ref: '#/components/schemas/Uuid' + - name: assignmentStatus + in: query + schema: + type: string + enum: [assigned, unassigned, any] + default: any + - name: dueBefore + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: dueAfter + in: query + schema: + $ref: '#/components/schemas/Timestamp' + - name: q + in: query + description: Case-insensitive search across task title and description. + schema: + type: string + minLength: 2 + maxLength: 200 + - name: sort + in: query + description: Null due dates are always placed last. + schema: + type: string + enum: [createdAt, -createdAt, dueAt, -dueAt, priority, -priority] + default: -createdAt + responses: + '200': + description: Engineering tasks returned. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskCollectionResponse' + '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 Tasks] + operationId: createEngineeringTask + summary: Create a task in todo status + description: | + The project must be `draft` or `active`. A supplied assignee must be the project + manager or an active project member and must retain an active organization membership. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.create + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEngineeringTaskRequest' + responses: + '201': + description: Engineering task 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/EngineeringTaskResponse' + '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/tasks/{taskId}: + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + get: + tags: [Engineering Tasks] + operationId: getEngineeringTask + summary: Get an engineering task + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + responses: + '200': + description: Engineering task returned. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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 Tasks] + operationId: updateEngineeringTask + summary: Update mutable task fields + description: | + `projectId`, status, creator, and terminal metadata are immutable through PATCH. + Assignment changes revalidate active organization and project participation. + Completed and cancelled tasks must be reopened before they can be edited. The parent + project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.update + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/UpdateEngineeringTaskRequest' + responses: + '200': + description: Engineering task updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/start: + post: + tags: [Engineering Tasks] + operationId: startEngineeringTask + summary: Start a todo task + description: 'Transition: `todo → in_progress`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.start + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + responses: + '200': + description: Engineering task started or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/complete: + post: + tags: [Engineering Tasks] + operationId: completeEngineeringTask + summary: Complete a todo or in-progress task + description: 'Transition: `todo|in_progress → completed`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.complete + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteEngineeringTaskRequest' + responses: + '200': + description: Engineering task completed or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/reopen: + post: + tags: [Engineering Tasks] + operationId: reopenEngineeringTask + summary: Reopen a completed or cancelled task + description: | + Transition: `completed|cancelled → todo`. Completion and cancellation metadata + plus any prior start metadata are cleared, while their prior values remain available + through audit history. The parent project must be `draft` or `active`. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.reopen + parameters: + - $ref: '#/components/parameters/TaskId' + - $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 task reopened or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/{taskId}/cancel: + post: + tags: [Engineering Tasks] + operationId: cancelEngineeringTask + summary: Cancel a todo or in-progress task + description: 'Transition: `todo|in_progress → cancelled`; the parent project must be `draft` or `active`.' + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-permission-scope-policy: engineering_task_or_parent_project_assignment + x-audit-action: engineering.tasks.cancel + parameters: + - $ref: '#/components/parameters/TaskId' + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CancelEngineeringTaskRequest' + responses: + '200': + description: Engineering task cancelled or idempotent result replayed. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskResponse' + '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/tasks/batch/assign: + post: + tags: [Engineering Tasks] + operationId: batchAssignEngineeringTasks + summary: Assign multiple tasks + description: | + Every item carries its expected version and is independently tenant-, permission-, + scope-, project-, assignee-, and state-validated. Atomic mode rolls back all items + on any failure. Partial mode commits valid items and returns per-item failures. + Only `todo` and `in_progress` tasks may be assigned, and the assignee must be an + active participant or project manager for every affected project. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_assign + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchAssignEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/tasks/batch/complete: + post: + tags: [Engineering Tasks] + operationId: batchCompleteEngineeringTasks + summary: Complete multiple tasks + description: | + Every item carries its expected version and is independently authorized and + state-validated. Atomic and partial modes follow the same semantics as batch assign. + Only `todo` and `in_progress` tasks may be completed. + Milestone 4 executes at most 100 items synchronously; larger requests are rejected. + Asynchronous execution is introduced with the background-jobs milestone. + x-required-profession: engineering + x-required-permissions: [engineering.tasks.manage] + x-audit-action: engineering.tasks.batch_complete + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchCompleteEngineeringTasksRequest' + responses: + '200': + description: Batch executed synchronously. + headers: + X-Request-Id: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/EngineeringTaskBatchResponse' + '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/sites: + get: + tags: [Engineering Sites] + operationId: listEngineeringSites + summary: List engineering sites across the active organization + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Sites visible to the caller. + headers: {X-Request-Id: {$ref: '#/components/headers/RequestId'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + + /engineering/projects/{projectId}/sites: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Sites] + operationId: listEngineeringProjectSites + summary: List sites for one project + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: Project sites. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Sites] + operationId: createEngineeringProjectSite + summary: Create a site within a project + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringSiteRequest'}}} + responses: + '201': + description: Site created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/sites/{siteId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SiteId' + get: + tags: [Engineering Sites] + operationId: getEngineeringSite + summary: Retrieve an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.read] + responses: + '200': + description: Site. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Sites] + operationId: updateEngineeringSite + summary: Update an engineering site + x-required-profession: engineering + x-required-permissions: [engineering.sites.manage] + x-audit-action: engineering.site.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringSiteRequest'}}} + responses: + '200': + description: Site updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSiteResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents: + get: + tags: [Documents] + operationId: listDocuments + summary: List document metadata + description: Quarantined and infected versions are excluded unless the caller has documents.security_review. + x-required-permissions: [documents.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: classification + in: query + schema: {$ref: '#/components/schemas/DocumentClassification'} + - name: categoryId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Document metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCollectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + + /documents/upload-url: + post: + tags: [Documents] + operationId: createDocumentUploadUrl + summary: Initialize a single-part document upload + description: Creates quarantined document and version metadata, then returns a short-lived signed PUT URL. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: getDocument + summary: Retrieve document metadata + x-required-permissions: [documents.read] + responses: + '200': + description: Document metadata. No storage key or unsigned object URL is exposed. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Documents] + operationId: updateDocumentMetadata + summary: Update mutable document metadata + description: Classification cannot be weakened below the linked domain record's required classification. + x-required-permissions: [documents.manage] + x-audit-action: document.metadata_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateDocumentRequest'}}} + responses: + '200': + description: Document updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/complete-upload: + post: + tags: [Documents] + operationId: completeDocumentUpload + summary: Verify a single-part upload and enqueue malware inspection + description: Completion changes uploadStatus to completed and scanStatus to pending; it never makes the file downloadable. + x-required-permissions: [documents.upload] + x-audit-action: document.upload_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteDocumentUploadRequest'}}} + responses: + '202': + description: Object verified and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + get: + tags: [Documents] + operationId: listDocumentVersions + summary: List immutable document versions + x-required-permissions: [documents.read] + responses: + '200': + description: Version metadata. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Documents] + operationId: initializeNewDocumentVersion + summary: Initialize a new single-part version upload + description: The current version pointer changes only after upload verification and a clean scan. + x-required-permissions: [documents.upload] + x-audit-action: document.version_upload_initialized + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentVersionRequest'}}} + responses: + '201': + description: Version upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/download-url: + post: + tags: [Documents] + operationId: createDocumentDownloadUrl + summary: Create a short-lived download URL for a clean version + description: Infected, pending, failed, or quarantined versions are never downloadable. + x-required-permissions: [documents.download] + x-audit-action: document.download_authorized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateDocumentDownloadRequest'}}} + responses: + '200': + description: Short-lived download authorization. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentDownloadResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /documents/multipart-uploads: + post: + tags: [Documents] + operationId: initializeMultipartDocumentUpload + summary: Initialize a multipart document upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_initialized + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeDocumentUploadRequest'}}} + responses: + '201': + description: Multipart upload initialized. + content: {application/json: {schema: {$ref: '#/components/schemas/InitializeMultipartUploadResponse'}}} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/parts: + post: + tags: [Documents] + operationId: createMultipartPartUploadUrls + summary: Create signed URLs for selected multipart parts + x-required-permissions: [documents.upload] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsRequest'}}} + responses: + '200': + description: Signed part URLs. + content: {application/json: {schema: {$ref: '#/components/schemas/MultipartPartUrlsResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}/complete: + post: + tags: [Documents] + operationId: completeMultipartDocumentUpload + summary: Assemble multipart upload and enqueue malware inspection + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteMultipartUploadRequest'}}} + responses: + '202': + description: Multipart object assembled and security scan queued. + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /documents/{documentId}/multipart-uploads/{uploadId}: + delete: + tags: [Documents] + operationId: abortMultipartDocumentUpload + summary: Abort an unfinished multipart upload + x-required-permissions: [documents.upload] + x-audit-action: document.multipart_aborted + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentId' + - $ref: '#/components/parameters/UploadId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Upload aborted; staged object parts are scheduled for cleanup.} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Documents] + operationId: listEngineeringProjectDocuments + summary: List active project-document links + x-required-profession: engineering + x-required-permissions: [engineering.documents.read] + responses: + '200': + description: Project documents filtered by document authorization and scan state. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Documents] + operationId: linkEngineeringProjectDocument + summary: Link a clean shared document to a project + description: Pending, failed, or infected versions cannot be linked as the active project document. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringProjectDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/project-documents/{documentLinkId}: + delete: + tags: [Engineering Project Documents] + operationId: unlinkEngineeringProjectDocument + summary: Temporally unlink a document from a project + description: Sets unlinkedAt; it does not delete the shared document or its versions. + x-required-profession: engineering + x-required-permissions: [engineering.documents.manage] + x-audit-action: engineering.project_document.unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/designs: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Designs] + operationId: listEngineeringProjectDesigns + summary: List designs for a project + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringDesignStatus'} + - name: discipline + in: query + schema: {type: string, minLength: 1, maxLength: 100} + responses: + '200': + description: Project designs. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Designs] + operationId: createEngineeringDesign + summary: Create a draft design + description: Atomically creates version 1 and the required owner/preparer assignments. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignRequest'}}} + responses: + '201': + description: Draft design and initial version created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Designs] + operationId: getEngineeringDesign + summary: Retrieve a design + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Designs] + operationId: updateEngineeringDesign + summary: Update editable design metadata + description: Only draft or changes_requested designs are editable; status changes use commands. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringDesignRequest'}}} + responses: + '200': + description: Design updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/submit-review: + post: + tags: [Engineering Designs] + operationId: submitEngineeringDesignForReview + summary: Submit the current version for review + description: Requires a clean primary drawing and at least one active reviewer assignment. + x-required-profession: engineering + x-required-permissions: [engineering.designs.submit] + x-audit-action: engineering.design.submitted_for_review + parameters: &designCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalDesignReasonCommand'}}} + responses: &designCommandResponses + '200': + description: Design transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/request-changes: + post: + tags: [Engineering Designs] + operationId: requestEngineeringDesignChanges + summary: Return a design to changes requested + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.changes_requested + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/approve: + post: + tags: [Engineering Designs] + operationId: approveEngineeringDesign + summary: Professionally approve the current design version + description: Revalidates current credential, discipline, scope-of-practice, and approval policy. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.approved + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ApproveDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/reject: + post: + tags: [Engineering Designs] + operationId: rejectEngineeringDesign + summary: Reject the current design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.rejected + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignDecisionCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/revise: + post: + tags: [Engineering Designs] + operationId: reviseRejectedEngineeringDesign + summary: Reopen a rejected design as draft with a new version + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.revised + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/cancel: + post: + tags: [Engineering Designs] + operationId: cancelEngineeringDesign + summary: Cancel a draft design + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.cancelled + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/withdraw: + post: + tags: [Engineering Designs] + operationId: withdrawEngineeringDesign + summary: Withdraw a design from review + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.withdrawn + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DesignReasonCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/supersede: + post: + tags: [Engineering Designs] + operationId: supersedeEngineeringDesign + summary: Supersede an approved design + description: Requires the replacement to be a different approved design in the same project and discipline. + x-required-profession: engineering + x-required-permissions: [engineering.designs.approve] + x-audit-action: engineering.design.superseded + parameters: *designCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SupersedeDesignCommand'}}} + responses: *designCommandResponses + + /engineering/designs/{designId}/assignments: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Assignments] + operationId: listEngineeringDesignAssignments + summary: List current and historical design assignments + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + parameters: + - name: activeOnly + in: query + schema: {type: boolean, default: true} + responses: + '200': + description: Assignments. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Assignments] + operationId: assignEngineeringDesignParticipant + summary: Assign a member to a design role + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AssignEngineeringDesignRequest'}}} + responses: + '201': + description: Assignment created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignAssignmentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/designs/{designId}/unassign: + post: + tags: [Engineering Design Assignments] + operationId: unassignEngineeringDesignParticipant + summary: End an active design assignment + description: Sets unassignedAt. The final active owner or required reviewer cannot be removed while workflow depends on that role. + x-required-profession: engineering + x-required-permissions: [engineering.designs.assign] + x-audit-action: engineering.design.assignment_ended + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/UnassignEngineeringDesignRequest'}}} + responses: + '204': {description: Assignment ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/versions: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersions + summary: List immutable logical design versions + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Design versions. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: createEngineeringDesignVersion + summary: Create the next logical design version + description: Allowed only in draft or changes_requested. Version numbers are allocated transactionally. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignVersionRequest'}}} + responses: + '201': + description: Design version created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/design-versions/{designVersionId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignVersionId' + get: + tags: [Engineering Design Versions] + operationId: listEngineeringDesignVersionDocuments + summary: List documents linked to a design version + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Version documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Versions] + operationId: linkEngineeringDesignVersionDocument + summary: Link a clean document to an editable design version + description: Only scan-clean documents may be linked; a version may have only one active primary_drawing. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringDesignVersionDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignVersionDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/design-version-documents/{documentLinkId}: + delete: + tags: [Engineering Design Versions] + operationId: unlinkEngineeringDesignVersionDocument + summary: Temporally unlink a document from an editable design version + description: Submitted, approved, rejected, or superseded version evidence cannot be unlinked. + x-required-profession: engineering + x-required-permissions: [engineering.designs.manage] + x-audit-action: engineering.design.version_document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/designs/{designId}/reviews: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DesignId' + get: + tags: [Engineering Design Reviews] + operationId: listEngineeringDesignReviews + summary: List review recommendations + x-required-profession: engineering + x-required-permissions: [engineering.designs.read] + responses: + '200': + description: Reviews. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Design Reviews] + operationId: recordEngineeringDesignReview + summary: Record a reviewer recommendation for the submitted version + description: A recommendation is immutable and never directly changes design status. + x-required-profession: engineering + x-required-permissions: [engineering.designs.review] + x-audit-action: engineering.design.review_recorded + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringDesignReviewRequest'}}} + responses: + '201': + description: Review recommendation recorded. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringDesignReviewResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/inspections: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Inspections] + operationId: listEngineeringProjectInspections + summary: List inspections for a project + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: siteId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringInspectionStatus'} + responses: + '200': + description: Project inspections. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspections] + operationId: createEngineeringInspection + summary: Create a draft inspection + description: Site and inspector must belong to the same project and active organization context. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionRequest'}}} + responses: + '201': + description: Draft inspection created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspections] + operationId: getEngineeringInspection + summary: Retrieve an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Inspections] + operationId: updateEngineeringInspection + summary: Update editable inspection metadata + description: Draft and scheduled inspections are editable; lifecycle fields use commands. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringInspectionRequest'}}} + responses: + '200': + description: Inspection updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}/schedule: + post: + tags: [Engineering Inspections] + operationId: scheduleEngineeringInspection + summary: Schedule a draft inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.scheduled + parameters: &inspectionCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ScheduleInspectionCommand'}}} + responses: &inspectionCommandResponses + '200': + description: Inspection transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspections/{inspectionId}/start: + post: + tags: [Engineering Inspections] + operationId: startEngineeringInspection + summary: Start a scheduled inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.started + parameters: *inspectionCommandParameters + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/StartInspectionCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/complete: + post: + tags: [Engineering Inspections] + operationId: completeEngineeringInspection + summary: Complete an in-progress inspection + description: Outcome is mandatory. Passed outcomes are rejected while major or critical findings remain open. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.completed + parameters: *inspectionCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CompleteInspectionCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/cancel: + post: + tags: [Engineering Inspections] + operationId: cancelEngineeringInspection + summary: Cancel a draft or scheduled inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.cancelled + parameters: *inspectionCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/InspectionReasonCommand'}}} + responses: *inspectionCommandResponses + + /engineering/inspections/{inspectionId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Documents] + operationId: listEngineeringInspectionDocuments + summary: List active inspection-document links + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Documents] + operationId: linkEngineeringInspectionDocument + summary: Link a scan-clean document to an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringInspectionDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-documents/{documentLinkId}: + delete: + tags: [Engineering Inspection Documents] + operationId: unlinkEngineeringInspectionDocument + summary: Temporally unlink an inspection document + description: Completed inspection evidence cannot be unlinked through ordinary workflow. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage] + x-audit-action: engineering.inspection.document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/inspections/{inspectionId}/findings: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Findings] + operationId: listEngineeringInspectionFindings + summary: List findings for an inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Inspection findings. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Findings] + operationId: createEngineeringInspectionFinding + summary: Record a finding during an in-progress inspection + x-required-profession: engineering + x-required-permissions: [engineering.inspections.perform] + x-audit-action: engineering.inspection.finding_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionFindingRequest'}}} + responses: + '201': + description: Finding recorded. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FindingId' + get: + tags: [Engineering Inspection Findings] + operationId: getEngineeringInspectionFinding + summary: Retrieve an inspection finding + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Finding. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Inspection Findings] + operationId: updateEngineeringInspectionFinding + summary: Update finding description, severity, owner, or target date + description: Resolved and accepted-risk findings are immutable except through explicit reopen policy added later. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.finding_updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringInspectionFindingRequest'}}} + responses: + '200': + description: Finding updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}/start-remediation: + post: + tags: [Engineering Inspection Findings] + operationId: startEngineeringFindingRemediation + summary: Start corrective work for an open finding + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.finding_remediation_started + parameters: &findingCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FindingId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: &findingCommandResponses + '200': + description: Finding transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFindingResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-findings/{findingId}/resolve: + post: + tags: [Engineering Inspection Findings] + operationId: resolveEngineeringInspectionFinding + summary: Independently verify and resolve a remediated finding + description: Verifier must differ from remediation owner unless an explicit privileged override is audited. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.verify_findings] + x-audit-action: engineering.inspection.finding_resolved + parameters: *findingCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ResolveEngineeringFindingCommand'}}} + responses: *findingCommandResponses + + /engineering/inspection-findings/{findingId}/accept-risk: + post: + tags: [Engineering Inspection Findings] + operationId: acceptEngineeringInspectionFindingRisk + summary: Accept the documented risk of an unresolved finding + description: Major and critical acceptance requires privileged authority and a review date. + x-required-profession: engineering + x-required-permissions: [engineering.inspections.accept_risk] + x-audit-action: engineering.inspection.finding_risk_accepted + parameters: *findingCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/AcceptEngineeringFindingRiskCommand'}}} + responses: *findingCommandResponses + + /engineering/inspections/{inspectionId}/followups: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/InspectionId' + get: + tags: [Engineering Inspection Follow-ups] + operationId: listEngineeringInspectionFollowups + summary: List follow-up actions + x-required-profession: engineering + x-required-permissions: [engineering.inspections.read] + responses: + '200': + description: Follow-ups. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Inspection Follow-ups] + operationId: createEngineeringInspectionFollowup + summary: Create a corrective-task or follow-up-inspection relationship + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.followup_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringInspectionFollowupRequest'}}} + responses: + '201': + description: Follow-up created. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/inspection-followups/{followupId}/{command}: + post: + tags: [Engineering Inspection Follow-ups] + operationId: commandEngineeringInspectionFollowup + summary: Start, complete, or cancel a follow-up + x-required-profession: engineering + x-required-permissions: [engineering.inspections.manage_findings] + x-audit-action: engineering.inspection.followup_commanded + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/FollowupId' + - name: command + in: path + required: true + schema: {type: string, enum: [start, complete, cancel]} + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalInspectionReasonCommand'}}} + responses: + '200': + description: Follow-up transitioned. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringInspectionFollowupResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/specifications: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Specifications] + operationId: listEngineeringProjectSpecifications + summary: List specifications for a project + x-required-profession: engineering + x-required-permissions: [engineering.specifications.read] + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: {$ref: '#/components/schemas/EngineeringSpecificationStatus'} + - name: search + in: query + schema: {type: string, minLength: 1, maxLength: 200} + responses: + '200': + description: Project specifications. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Specifications] + operationId: createEngineeringSpecification + summary: Create a draft specification + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringSpecificationRequest'}}} + responses: + '201': + description: Draft specification created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specifications/{specificationId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SpecificationId' + get: + tags: [Engineering Specifications] + operationId: getEngineeringSpecification + summary: Retrieve a specification + x-required-profession: engineering + x-required-permissions: [engineering.specifications.read] + responses: + '200': + description: Specification. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Specifications] + operationId: updateEngineeringSpecification + summary: Update draft specification metadata + description: Active, superseded and archived specifications reject PATCH; lifecycle changes use commands. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringSpecificationRequest'}}} + responses: + '200': + description: Specification updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specifications/{specificationId}/activate: + post: + tags: [Engineering Specifications] + operationId: activateEngineeringSpecification + summary: Activate a draft specification + description: Requires exactly one active, scan-clean primary document. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.activate] + x-audit-action: engineering.specification.activated + parameters: &specificationCommandParameters + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SpecificationId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalSpecificationReasonCommand'}}} + responses: &specificationCommandResponses + '200': + description: Specification transitioned. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationResponse'}}} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specifications/{specificationId}/supersede: + post: + tags: [Engineering Specifications] + operationId: supersedeEngineeringSpecification + summary: Supersede an active specification + description: Replacement must be a different active specification in the same project. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.activate] + x-audit-action: engineering.specification.superseded + parameters: *specificationCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SupersedeSpecificationCommand'}}} + responses: *specificationCommandResponses + + /engineering/specifications/{specificationId}/archive: + post: + tags: [Engineering Specifications] + operationId: archiveEngineeringSpecification + summary: Archive a draft or active specification + description: Stores archivedFromStatus; active specifications referenced by open work may be blocked. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.archived + parameters: *specificationCommandParameters + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SpecificationReasonCommand'}}} + responses: *specificationCommandResponses + + /engineering/specifications/{specificationId}/restore: + post: + tags: [Engineering Specifications] + operationId: restoreEngineeringSpecification + summary: Restore an archived specification to its prior status + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.restored + parameters: *specificationCommandParameters + requestBody: + content: {application/json: {schema: {$ref: '#/components/schemas/OptionalSpecificationReasonCommand'}}} + responses: *specificationCommandResponses + + /engineering/specifications/{specificationId}/documents: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/SpecificationId' + get: + tags: [Engineering Specification Documents] + operationId: listEngineeringSpecificationDocuments + summary: List current and historical specification-document links + x-required-profession: engineering + x-required-permissions: [engineering.specifications.read] + parameters: + - name: activeOnly + in: query + schema: {type: boolean, default: true} + responses: + '200': + description: Specification documents. + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationDocumentCollectionResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Specification Documents] + operationId: linkEngineeringSpecificationDocument + summary: Link a scan-clean document to a draft specification + description: A draft may have only one active primary link; active or terminal evidence is immutable. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.document_linked + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/LinkEngineeringSpecificationDocumentRequest'}}} + responses: + '201': + description: Document linked. + headers: {Location: {$ref: '#/components/headers/Location'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringSpecificationDocumentResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/specification-documents/{documentLinkId}: + delete: + tags: [Engineering Specification Documents] + operationId: unlinkEngineeringSpecificationDocument + summary: Temporally unlink a document from a draft specification + description: Sets unlinkedAt; it never deletes the shared document or active/terminal evidence. + x-required-profession: engineering + x-required-permissions: [engineering.specifications.manage] + x-audit-action: engineering.specification.document_unlinked + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/DocumentLinkId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': {description: Link ended.} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + + /engineering/projects/{projectId}/phases: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Phases] + operationId: listEngineeringProjectPhases + summary: List ordered project phases + x-required-profession: engineering + x-required-permissions: [engineering.projects.read] + responses: + '200': {description: Ordered phases., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseCollectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Phases] + operationId: createEngineeringProjectPhase + summary: Add a planned phase + x-required-profession: engineering + x-required-permissions: [engineering.projects.manage] + x-audit-action: engineering.project.phase_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringProjectPhaseRequest'}}}} + responses: + '201': + description: Phase created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/phases/{phaseId}: + patch: + tags: [Engineering Project Phases] + operationId: updateEngineeringProjectPhase + summary: Update phase metadata + description: Completion and ordering use commands; planned/active/cancelled may be managed while the phase is otherwise editable. + x-required-profession: engineering + x-required-permissions: [engineering.projects.manage] + x-audit-action: engineering.project.phase_updated + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/PhaseId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringProjectPhaseRequest'}}}} + responses: + '200': + description: Phase updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/phases/{phaseId}/complete: + post: + tags: [Engineering Project Phases] + operationId: completeEngineeringProjectPhase + summary: Complete an active phase + x-required-profession: engineering + x-required-permissions: [engineering.projects.manage] + x-audit-action: engineering.project.phase_completed + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/PhaseId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '200': + description: Phase completed. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + + /engineering/projects/{projectId}/phases/reorder: + post: + tags: [Engineering Project Phases] + operationId: reorderEngineeringProjectPhases + summary: Transactionally reorder every phase in a project + x-required-profession: engineering + x-required-permissions: [engineering.projects.manage] + x-audit-action: engineering.project.phases_reordered + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReorderEngineeringProjectPhasesRequest'}}}} + responses: + '200': {description: Phases reordered., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectPhaseCollectionResponse'}}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/time-entries: + get: + tags: [Engineering Time Entries] + operationId: listEngineeringTimeEntries + summary: List time entries + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: projectId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: userId + in: query + schema: {$ref: '#/components/schemas/Uuid'} + - name: fromDate + in: query + schema: {$ref: '#/components/schemas/Date'} + - name: toDate + in: query + schema: {$ref: '#/components/schemas/Date'} + - name: billable + in: query + schema: {type: boolean} + responses: + '200': {description: Time entries., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryCollectionResponse'}}}} + post: + tags: [Engineering Time Entries] + operationId: createEngineeringTimeEntry + summary: Create a time entry + description: At most one work-item reference is allowed and it must belong to the same project. + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.create] + x-audit-action: engineering.time_entry.created + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringTimeEntryRequest'}}}} + responses: + '201': + description: Time entry created with immutable billing snapshot. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/time-entries/{timeEntryId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/TimeEntryId' + get: + tags: [Engineering Time Entries] + operationId: getEngineeringTimeEntry + summary: Retrieve a time entry + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.read] + responses: + '200': + description: Time entry. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Time Entries] + operationId: updateEngineeringTimeEntry + summary: Update an uninvoiced time entry + description: Once referenced by issued billing, financial snapshot fields and duration are immutable. + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.update] + x-audit-action: engineering.time_entry.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringTimeEntryRequest'}}}} + responses: + '200': + description: Time entry updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/time-entries/batch/submit: + post: + tags: [Engineering Time Entries] + operationId: batchCreateEngineeringTimeEntries + summary: Create up to 100 time entries synchronously + description: Atomic mode rolls back all entries; partial mode returns per-item results. + x-required-profession: engineering + x-required-permissions: [engineering.time_entries.create] + x-audit-action: engineering.time_entries.batch_created + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BatchCreateEngineeringTimeEntriesRequest'}}}} + responses: + '200': {description: Batch processed., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringTimeEntryBatchResponse'}}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/projects/{projectId}/budgets: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/ProjectId' + get: + tags: [Engineering Project Budgets] + operationId: listEngineeringProjectBudgets + summary: List project budgets + x-required-profession: engineering + x-required-permissions: [engineering.budgets.read] + responses: + '200': {description: Budgets., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetCollectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Budgets] + operationId: createEngineeringProjectBudget + summary: Create a draft project budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.manage] + x-audit-action: engineering.budget.created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringProjectBudgetRequest'}}}} + responses: + '201': + description: Draft budget created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/budgets/{budgetId}: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BudgetId' + get: + tags: [Engineering Project Budgets] + operationId: getEngineeringProjectBudget + summary: Retrieve a project budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.read] + responses: + '200': + description: Budget. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetResponse'}}} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Engineering Project Budgets] + operationId: updateEngineeringProjectBudget + summary: Update a draft budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.manage] + x-audit-action: engineering.budget.updated + parameters: + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/merge-patch+json: {schema: {$ref: '#/components/schemas/UpdateEngineeringProjectBudgetRequest'}}}} + responses: + '200': + description: Budget updated. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + + /engineering/budgets/{budgetId}/approve: + post: + tags: [Engineering Project Budgets] + operationId: approveEngineeringProjectBudget + summary: Approve and freeze a draft budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.approve] + x-audit-action: engineering.budget.approved + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BudgetId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ApproveEngineeringBudgetCommand'}}}} + responses: + '200': + description: Budget approved and frozen. + headers: {ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '428': {$ref: '#/components/responses/PreconditionRequired'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/budgets/{budgetId}/items: + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BudgetId' + get: + tags: [Engineering Project Budgets] + operationId: listEngineeringProjectBudgetItems + summary: List budget items + x-required-profession: engineering + x-required-permissions: [engineering.budgets.read] + responses: + '200': {description: Budget items., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetItemCollectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Engineering Project Budgets] + operationId: createEngineeringProjectBudgetItem + summary: Add an item to a draft budget + x-required-profession: engineering + x-required-permissions: [engineering.budgets.manage] + x-audit-action: engineering.budget.item_created + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateEngineeringProjectBudgetItemRequest'}}}} + responses: + '201': + description: Budget item created. + headers: {Location: {$ref: '#/components/headers/Location'}, ETag: {$ref: '#/components/headers/ETag'}} + content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringProjectBudgetItemResponse'}}} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/ValidationError'} + + /engineering/budgets/{budgetId}/projection: + get: + tags: [Engineering Project Budgets] + operationId: getEngineeringProjectBudgetProjection + summary: Retrieve derived allocations, commitments, actuals, and variance + description: Projection values are derived from authoritative records and are not independently mutable. + x-required-profession: engineering + x-required-permissions: [engineering.budgets.read] + parameters: + - $ref: '#/components/parameters/OrganizationContext' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/BudgetId' + responses: + '200': {description: Derived budget projection., content: {application/json: {schema: {$ref: '#/components/schemas/EngineeringBudgetProjectionResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + +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' + ProjectId: + name: projectId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + ProjectMemberId: + name: memberId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + TaskId: + name: taskId + in: path + required: true + schema: + $ref: '#/components/schemas/Uuid' + SiteId: + name: siteId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentId: + name: documentId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + UploadId: + name: uploadId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DocumentLinkId: + name: documentLinkId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignId: + name: designId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + DesignVersionId: + name: designVersionId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + InspectionId: + name: inspectionId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + FindingId: + name: findingId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + FollowupId: + name: followupId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + SpecificationId: + name: specificationId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + PhaseId: + name: phaseId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + TimeEntryId: + name: timeEntryId + in: path + required: true + schema: {$ref: '#/components/schemas/Uuid'} + BudgetId: + name: budgetId + 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] + EngineeringSite: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, address, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + EngineeringSiteAddress: + type: object + additionalProperties: false + required: [line1, city, countryCode] + properties: + line1: {type: string, minLength: 1, maxLength: 200} + line2: {type: [string, 'null'], maxLength: 200} + city: {type: string, minLength: 1, maxLength: 120} + region: {type: [string, 'null'], maxLength: 120} + postalCode: {type: [string, 'null'], maxLength: 32} + countryCode: {$ref: '#/components/schemas/CountryCode'} + CreateEngineeringSiteRequest: + type: object + additionalProperties: false + required: [name, address] + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + UpdateEngineeringSiteRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 200} + address: {$ref: '#/components/schemas/EngineeringSiteAddress'} + latitude: {type: [number, 'null'], minimum: -90, maximum: 90} + longitude: {type: [number, 'null'], minimum: -180, maximum: 180} + EngineeringSiteResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSite'}} + EngineeringSiteCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringSite'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentClassification: + type: string + enum: [public, internal, confidential, restricted, regulated] + DocumentUploadStatus: + type: string + enum: [initialized, uploading, completed, failed, aborted, expired] + MalwareScanStatus: + type: string + enum: [not_started, pending, scanning, clean, infected, failed] + Document: + type: object + additionalProperties: false + required: [id, organizationId, name, classification, currentVersionId, version, createdByUserId, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + currentVersion: {oneOf: [{$ref: '#/components/schemas/DocumentVersion'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + DocumentVersion: + type: object + additionalProperties: false + required: [id, organizationId, documentId, versionNumber, mimeType, sizeBytes, uploadStatus, scanStatus, uploadedByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + hashAlgorithm: {type: string, const: sha256} + uploadStatus: {$ref: '#/components/schemas/DocumentUploadStatus'} + scanStatus: {$ref: '#/components/schemas/MalwareScanStatus'} + scanCompletedAt: {type: [string, 'null'], format: date-time} + available: {type: boolean, readOnly: true, description: True only when uploadStatus is completed and scanStatus is clean.} + uploadedByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadRequest: + type: object + additionalProperties: false + required: [name, classification, mimeType, sizeBytes] + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentVersionRequest: + type: object + additionalProperties: false + required: [mimeType, sizeBytes] + properties: + mimeType: {type: string, minLength: 1, maxLength: 255} + sizeBytes: {type: integer, minimum: 1, maximum: 5368709120} + contentHash: {type: [string, 'null'], pattern: '^sha256:[a-f0-9]{64}$'} + UpdateDocumentRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 255} + categoryId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + classification: {$ref: '#/components/schemas/DocumentClassification'} + retentionPolicyId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + CompleteDocumentUploadRequest: + type: object + additionalProperties: false + required: [documentVersionId, contentHash] + properties: + documentVersionId: {$ref: '#/components/schemas/Uuid'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + InitializeDocumentUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadUrl, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadUrl: {type: string, format: uri} + requiredHeaders: {type: object, additionalProperties: {type: string}} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeDocumentUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeDocumentUploadData'}} + InitializeMultipartUploadData: + type: object + additionalProperties: false + required: [documentId, documentVersionId, uploadId, recommendedPartSizeBytes, expiresAt] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentVersionId: {$ref: '#/components/schemas/Uuid'} + uploadId: {$ref: '#/components/schemas/Uuid'} + recommendedPartSizeBytes: {type: integer, minimum: 5242880} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + InitializeMultipartUploadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/InitializeMultipartUploadData'}} + MultipartPartUrlsRequest: + type: object + additionalProperties: false + required: [partNumbers] + properties: + partNumbers: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: {type: integer, minimum: 1, maximum: 10000} + MultipartPartUploadUrl: + type: object + required: [partNumber, uploadUrl, expiresAt] + properties: + partNumber: {type: integer, minimum: 1} + uploadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + MultipartPartUrlsResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/MultipartPartUploadUrl'}}} + CompletedMultipartPart: + type: object + additionalProperties: false + required: [partNumber, etag] + properties: + partNumber: {type: integer, minimum: 1} + etag: {type: string, minLength: 1, maxLength: 200} + CompleteMultipartUploadRequest: + type: object + additionalProperties: false + required: [parts, contentHash] + properties: + parts: + type: array + minItems: 1 + maxItems: 10000 + items: {$ref: '#/components/schemas/CompletedMultipartPart'} + contentHash: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + CreateDocumentDownloadRequest: + type: object + additionalProperties: false + properties: + documentVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}], description: Defaults to the current clean version.} + DocumentDownloadData: + type: object + required: [downloadUrl, expiresAt] + properties: + downloadUrl: {type: string, format: uri} + expiresAt: {$ref: '#/components/schemas/Timestamp'} + DocumentDownloadResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentDownloadData'}} + DocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/Document'}} + DocumentCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/Document'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + DocumentVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/DocumentVersion'}} + DocumentVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/DocumentVersion'}}} + EngineeringProjectDocumentLink: + type: object + additionalProperties: false + required: [id, organizationId, projectId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringProjectDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + EngineeringProjectDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}} + EngineeringProjectDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectDocumentLink'}}} + EngineeringDesignStatus: + type: string + enum: [draft, under_review, changes_requested, approved, rejected, cancelled, withdrawn, superseded] + EngineeringDesignAssignmentRole: + type: string + enum: [owner, preparer, reviewer, contributor] + EngineeringDesignDocumentRole: + type: string + enum: [primary_drawing, calculation, supporting_document, specification, attachment] + description: Domain-specific registry independent from specification document roles. + EngineeringDesignReviewStatus: + type: string + enum: [approved, changes_requested, rejected] + EngineeringDesign: + type: object + additionalProperties: false + required: [id, organizationId, projectId, designNumber, title, discipline, status, ownerUserId, preparedByUserId, currentVersionId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + status: {$ref: '#/components/schemas/EngineeringDesignStatus'} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + currentVersionId: {$ref: '#/components/schemas/Uuid'} + approvedVersionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedAt: {type: [string, 'null'], format: date-time} + supersededByDesignId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignRequest: + type: object + additionalProperties: false + required: [designNumber, title, discipline, ownerUserId, preparedByUserId] + properties: + designNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + ownerUserId: {$ref: '#/components/schemas/Uuid'} + preparedByUserId: {$ref: '#/components/schemas/Uuid'} + UpdateEngineeringDesignRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + discipline: {type: string, minLength: 1, maxLength: 100} + EngineeringDesignResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesign'}} + EngineeringDesignCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesign'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + OptionalDesignReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + DesignDecisionCommand: + type: object + additionalProperties: false + required: [designVersionId, reason] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 2000} + ApproveDesignCommand: + type: object + additionalProperties: false + required: [designVersionId, attestation] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + attestation: {type: string, minLength: 10, maxLength: 2000} + SupersedeDesignCommand: + type: object + additionalProperties: false + required: [replacementDesignId, reason] + properties: + replacementDesignId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringDesignAssignment: + type: object + additionalProperties: false + required: [id, organizationId, designId, userId, assignmentRole, assignedByUserId, assignedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + assignedByUserId: {$ref: '#/components/schemas/Uuid'} + assignedAt: {$ref: '#/components/schemas/Timestamp'} + unassignedAt: {type: [string, 'null'], format: date-time} + AssignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [userId, assignmentRole] + properties: + userId: {$ref: '#/components/schemas/Uuid'} + assignmentRole: {$ref: '#/components/schemas/EngineeringDesignAssignmentRole'} + notes: {type: [string, 'null'], maxLength: 2000} + UnassignEngineeringDesignRequest: + type: object + additionalProperties: false + required: [assignmentId] + properties: + assignmentId: {$ref: '#/components/schemas/Uuid'} + reason: {type: [string, 'null'], maxLength: 1000} + EngineeringDesignAssignmentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignAssignment'}} + EngineeringDesignAssignmentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignAssignment'}}} + EngineeringDesignVersion: + type: object + additionalProperties: false + required: [id, organizationId, designId, versionNumber, createdByUserId, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + versionNumber: {type: integer, minimum: 1} + changeSummary: {type: [string, 'null'], maxLength: 2000} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignVersionRequest: + type: object + additionalProperties: false + properties: + changeSummary: {type: [string, 'null'], maxLength: 2000} + EngineeringDesignVersionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersion'}} + EngineeringDesignVersionCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersion'}}} + EngineeringDesignVersionDocument: + type: object + additionalProperties: false + required: [id, organizationId, designVersionId, documentId, documentRole, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringDesignVersionDocumentRequest: + type: object + additionalProperties: false + required: [documentId, documentRole] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringDesignDocumentRole'} + EngineeringDesignVersionDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}} + EngineeringDesignVersionDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignVersionDocument'}}} + EngineeringDesignReview: + type: object + additionalProperties: false + required: [id, organizationId, designId, designVersionId, reviewerUserId, status, comments, reviewedAt, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + designId: {$ref: '#/components/schemas/Uuid'} + designVersionId: {$ref: '#/components/schemas/Uuid'} + reviewerUserId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + reviewedAt: {$ref: '#/components/schemas/Timestamp'} + createdAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringDesignReviewRequest: + type: object + additionalProperties: false + required: [designVersionId, status, comments] + properties: + designVersionId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringDesignReviewStatus'} + comments: {type: string, minLength: 1, maxLength: 10000} + EngineeringDesignReviewResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringDesignReview'}} + EngineeringDesignReviewCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringDesignReview'}}} + EngineeringInspectionStatus: + type: string + enum: [draft, scheduled, in_progress, completed, cancelled] + EngineeringInspectionOutcome: + type: string + enum: [passed, passed_with_observations, followup_required, failed] + EngineeringInspectionFindingSeverity: + type: string + enum: [observation, minor, major, critical] + EngineeringInspectionFindingStatus: + type: string + enum: [open, in_progress, resolved, accepted_risk] + EngineeringInspection: + type: object + additionalProperties: false + required: [id, organizationId, projectId, siteId, inspectionType, inspectorUserId, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100, description: Controlled application registry key.} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + status: {$ref: '#/components/schemas/EngineeringInspectionStatus'} + outcome: {oneOf: [{$ref: '#/components/schemas/EngineeringInspectionOutcome'}, {type: 'null'}]} + scheduledAt: {type: [string, 'null'], format: date-time} + startedAt: {type: [string, 'null'], format: date-time} + performedAt: {type: [string, 'null'], format: date-time} + cancelledAt: {type: [string, 'null'], format: date-time} + summary: {type: [string, 'null'], maxLength: 10000} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringInspectionRequest: + type: object + additionalProperties: false + required: [siteId, inspectionType, inspectorUserId] + properties: + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + summary: {type: [string, 'null'], maxLength: 10000} + UpdateEngineeringInspectionRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + siteId: {$ref: '#/components/schemas/Uuid'} + inspectionType: {type: string, minLength: 1, maxLength: 100} + inspectorUserId: {$ref: '#/components/schemas/Uuid'} + summary: {type: [string, 'null'], maxLength: 10000} + EngineeringInspectionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspection'}} + EngineeringInspectionCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspection'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + ScheduleInspectionCommand: + type: object + additionalProperties: false + required: [scheduledAt] + properties: + scheduledAt: {$ref: '#/components/schemas/Timestamp'} + StartInspectionCommand: + type: object + additionalProperties: false + properties: + startedAt: {$ref: '#/components/schemas/Timestamp'} + CompleteInspectionCommand: + type: object + additionalProperties: false + required: [outcome, summary] + properties: + outcome: {$ref: '#/components/schemas/EngineeringInspectionOutcome'} + performedAt: {$ref: '#/components/schemas/Timestamp'} + summary: {type: string, minLength: 1, maxLength: 10000} + createFollowups: {type: boolean, default: false} + InspectionReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + OptionalInspectionReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringInspectionDocument: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, documentId, category, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, enum: [evidence, photo, report, certificate, supporting_document]} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringInspectionDocumentRequest: + type: object + additionalProperties: false + required: [documentId, category] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, enum: [evidence, photo, report, certificate, supporting_document]} + EngineeringInspectionDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionDocument'}} + EngineeringInspectionDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionDocument'}}} + EngineeringInspectionFinding: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, severity, description, status, createdByUserId, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + status: {$ref: '#/components/schemas/EngineeringInspectionFindingStatus'} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + resolutionSummary: {type: [string, 'null'], maxLength: 10000} + resolvedAt: {type: [string, 'null'], format: date-time} + resolvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + acceptedRiskReason: {type: [string, 'null'], maxLength: 5000} + riskReviewDate: {type: [string, 'null'], format: date} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringInspectionFindingRequest: + type: object + additionalProperties: false + required: [severity, description] + properties: + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + UpdateEngineeringInspectionFindingRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + severity: {$ref: '#/components/schemas/EngineeringInspectionFindingSeverity'} + description: {type: string, minLength: 1, maxLength: 10000} + remediationOwnerUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + targetResolutionDate: {type: [string, 'null'], format: date} + ResolveEngineeringFindingCommand: + type: object + additionalProperties: false + required: [resolutionSummary] + properties: + resolutionSummary: {type: string, minLength: 3, maxLength: 10000} + evidenceDocumentIds: + type: array + maxItems: 50 + uniqueItems: true + items: {$ref: '#/components/schemas/Uuid'} + privilegedSelfVerificationReason: {type: [string, 'null'], minLength: 10, maxLength: 2000} + AcceptEngineeringFindingRiskCommand: + type: object + additionalProperties: false + required: [reason, reviewDate] + properties: + reason: {type: string, minLength: 10, maxLength: 5000} + reviewDate: {$ref: '#/components/schemas/Date'} + approvingUserId: {$ref: '#/components/schemas/Uuid'} + EngineeringInspectionFindingResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionFinding'}} + EngineeringInspectionFindingCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionFinding'}}} + EngineeringInspectionFollowupType: + type: string + enum: [corrective_task, followup_inspection, both] + EngineeringInspectionFollowupStatus: + type: string + enum: [open, in_progress, completed, cancelled] + EngineeringInspectionFollowup: + type: object + additionalProperties: false + required: [id, organizationId, inspectionId, followupType, status, createdByUserId, version, createdAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + inspectionId: {$ref: '#/components/schemas/Uuid'} + followupType: {$ref: '#/components/schemas/EngineeringInspectionFollowupType'} + linkedTaskId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + linkedInspectionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + status: {$ref: '#/components/schemas/EngineeringInspectionFollowupStatus'} + createdByUserId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + completedAt: {type: [string, 'null'], format: date-time} + cancelledAt: {type: [string, 'null'], format: date-time} + CreateEngineeringInspectionFollowupRequest: + type: object + additionalProperties: false + required: [followupType] + properties: + followupType: {$ref: '#/components/schemas/EngineeringInspectionFollowupType'} + linkedTaskId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + linkedInspectionId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + EngineeringInspectionFollowupResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringInspectionFollowup'}} + EngineeringInspectionFollowupCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringInspectionFollowup'}}} + EngineeringSpecificationStatus: + type: string + enum: [draft, active, superseded, archived] + EngineeringSpecificationDocumentRole: + type: string + enum: [primary, attachment, supporting_document] + description: Specification-specific registry; independent from design-version document roles. + EngineeringSpecification: + type: object + additionalProperties: false + required: [id, organizationId, projectId, specificationNumber, title, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + specificationNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + status: {$ref: '#/components/schemas/EngineeringSpecificationStatus'} + supersededBySpecificationId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + archivedFromStatus: + oneOf: + - type: string + enum: [draft, active] + - type: 'null' + archivedAt: {type: [string, 'null'], format: date-time} + archivedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringSpecificationRequest: + type: object + additionalProperties: false + required: [specificationNumber, title] + properties: + specificationNumber: {type: string, minLength: 1, maxLength: 64} + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + UpdateEngineeringSpecificationRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: {type: string, minLength: 1, maxLength: 300} + description: {type: [string, 'null'], maxLength: 10000} + EngineeringSpecificationResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSpecification'}} + EngineeringSpecificationCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringSpecification'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + SpecificationReasonCommand: + type: object + additionalProperties: false + required: [reason] + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + OptionalSpecificationReasonCommand: + type: object + additionalProperties: false + properties: + reason: {type: string, minLength: 3, maxLength: 1000} + SupersedeSpecificationCommand: + type: object + additionalProperties: false + required: [supersededBySpecificationId, reason] + properties: + supersededBySpecificationId: {$ref: '#/components/schemas/Uuid'} + reason: {type: string, minLength: 3, maxLength: 1000} + EngineeringSpecificationDocument: + type: object + additionalProperties: false + required: [id, organizationId, specificationId, documentId, documentRole, linkedByUserId, linkedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + specificationId: {$ref: '#/components/schemas/Uuid'} + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringSpecificationDocumentRole'} + document: {$ref: '#/components/schemas/Document'} + linkedByUserId: {$ref: '#/components/schemas/Uuid'} + linkedAt: {$ref: '#/components/schemas/Timestamp'} + unlinkedAt: {type: [string, 'null'], format: date-time} + LinkEngineeringSpecificationDocumentRequest: + type: object + additionalProperties: false + required: [documentId, documentRole] + properties: + documentId: {$ref: '#/components/schemas/Uuid'} + documentRole: {$ref: '#/components/schemas/EngineeringSpecificationDocumentRole'} + EngineeringSpecificationDocumentResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringSpecificationDocument'}} + EngineeringSpecificationDocumentCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringSpecificationDocument'}}} + EngineeringProjectPhaseStatus: + type: string + enum: [planned, active, completed, cancelled] + EngineeringProjectPhase: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, sequence, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + sequence: {type: integer, minimum: 1} + status: {$ref: '#/components/schemas/EngineeringProjectPhaseStatus'} + startDate: {type: [string, 'null'], format: date} + endDate: {type: [string, 'null'], format: date} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringProjectPhaseRequest: + type: object + additionalProperties: false + required: [name] + properties: + name: {type: string, minLength: 1, maxLength: 200} + startDate: {type: [string, 'null'], format: date} + endDate: {type: [string, 'null'], format: date} + UpdateEngineeringProjectPhaseRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + name: {type: string, minLength: 1, maxLength: 200} + startDate: {type: [string, 'null'], format: date} + endDate: {type: [string, 'null'], format: date} + status: {type: string, enum: [planned, active, cancelled]} + ReorderEngineeringProjectPhasesRequest: + type: object + additionalProperties: false + required: [projectVersion, orderedPhaseIds] + properties: + projectVersion: {type: integer, minimum: 1} + orderedPhaseIds: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: {$ref: '#/components/schemas/Uuid'} + EngineeringProjectPhaseResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectPhase'}} + EngineeringProjectPhaseCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectPhase'}}} + EngineeringTimeWorkItem: + oneOf: + - $ref: '#/components/schemas/EngineeringTimePhaseReference' + - $ref: '#/components/schemas/EngineeringTimeTaskReference' + - $ref: '#/components/schemas/EngineeringTimeDesignReference' + - $ref: '#/components/schemas/EngineeringTimeInspectionReference' + discriminator: {propertyName: type} + description: Maps to one project-aware nullable FK column; no unconstrained polymorphic database reference is used. + EngineeringTimePhaseReference: + type: object + additionalProperties: false + required: [type, id] + properties: {type: {type: string, const: phase}, id: {$ref: '#/components/schemas/Uuid'}} + EngineeringTimeTaskReference: + type: object + additionalProperties: false + required: [type, id] + properties: {type: {type: string, const: task}, id: {$ref: '#/components/schemas/Uuid'}} + EngineeringTimeDesignReference: + type: object + additionalProperties: false + required: [type, id] + properties: {type: {type: string, const: design}, id: {$ref: '#/components/schemas/Uuid'}} + EngineeringTimeInspectionReference: + type: object + additionalProperties: false + required: [type, id] + properties: {type: {type: string, const: inspection}, id: {$ref: '#/components/schemas/Uuid'}} + EngineeringTimeEntryInput: + type: object + additionalProperties: false + required: [projectId, userId, workDate, durationMinutes, description, billable] + properties: + projectId: {$ref: '#/components/schemas/Uuid'} + userId: {$ref: '#/components/schemas/Uuid'} + workDate: {$ref: '#/components/schemas/Date'} + durationMinutes: {type: integer, minimum: 1, maximum: 1440} + description: {type: string, minLength: 1, maxLength: 2000} + billable: {type: boolean} + billingRateMinor: {type: [integer, 'null'], minimum: 1} + currencyCode: {oneOf: [{$ref: '#/components/schemas/CurrencyCode'}, {type: 'null'}]} + workItem: {oneOf: [{$ref: '#/components/schemas/EngineeringTimeWorkItem'}, {type: 'null'}]} + allOf: + - if: {properties: {billable: {const: true}}, required: [billable]} + then: {required: [billingRateMinor, currencyCode], properties: {billingRateMinor: {type: integer, minimum: 1}, currencyCode: {$ref: '#/components/schemas/CurrencyCode'}}} + else: {properties: {billingRateMinor: {type: 'null'}, currencyCode: {type: 'null'}}} + CreateEngineeringTimeEntryRequest: + $ref: '#/components/schemas/EngineeringTimeEntryInput' + UpdateEngineeringTimeEntryRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + workDate: {$ref: '#/components/schemas/Date'} + durationMinutes: {type: integer, minimum: 1, maximum: 1440} + description: {type: string, minLength: 1, maxLength: 2000} + billable: {type: boolean} + billingRateMinor: {type: [integer, 'null'], minimum: 1} + currencyCode: {oneOf: [{$ref: '#/components/schemas/CurrencyCode'}, {type: 'null'}]} + workItem: {oneOf: [{$ref: '#/components/schemas/EngineeringTimeWorkItem'}, {type: 'null'}]} + description: The service validates the same billable/rate/currency invariant after merge. + EngineeringTimeEntry: + allOf: + - $ref: '#/components/schemas/EngineeringTimeEntryInput' + - type: object + required: [id, organizationId, version, invoiced, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + version: {type: integer, minimum: 1} + invoiced: {type: boolean, readOnly: true} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + EngineeringTimeEntryResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringTimeEntry'}} + EngineeringTimeEntryCollectionResponse: + type: object + required: [data, meta] + properties: + data: {type: array, items: {$ref: '#/components/schemas/EngineeringTimeEntry'}} + meta: {$ref: '#/components/schemas/CollectionMeta'} + BatchCreateEngineeringTimeEntriesRequest: + type: object + additionalProperties: false + required: [mode, entries] + properties: + mode: {$ref: '#/components/schemas/BatchExecutionMode'} + entries: {type: array, minItems: 1, maxItems: 100, items: {$ref: '#/components/schemas/EngineeringTimeEntryInput'}} + EngineeringTimeEntryBatchItemResult: + type: object + required: [index, status] + properties: + index: {type: integer, minimum: 0} + status: {type: string, enum: [created, failed]} + data: {oneOf: [{$ref: '#/components/schemas/EngineeringTimeEntry'}, {type: 'null'}]} + problem: {oneOf: [{$ref: '#/components/schemas/Problem'}, {type: 'null'}]} + EngineeringTimeEntryBatchResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringTimeEntryBatchItemResult'}}} + EngineeringBudgetStatus: + type: string + enum: [draft, approved, closed] + EngineeringProjectBudget: + type: object + additionalProperties: false + required: [id, organizationId, projectId, name, currencyCode, status, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + projectId: {$ref: '#/components/schemas/Uuid'} + name: {type: string, minLength: 1, maxLength: 200} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + status: {$ref: '#/components/schemas/EngineeringBudgetStatus'} + approvedByUserId: {oneOf: [{$ref: '#/components/schemas/Uuid'}, {type: 'null'}]} + approvedAt: {type: [string, 'null'], format: date-time} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringProjectBudgetRequest: + type: object + additionalProperties: false + required: [name, currencyCode] + properties: + name: {type: string, minLength: 1, maxLength: 200} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + UpdateEngineeringProjectBudgetRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: {name: {type: string, minLength: 1, maxLength: 200}} + ApproveEngineeringBudgetCommand: + type: object + additionalProperties: false + required: [attestation] + properties: {attestation: {type: string, minLength: 10, maxLength: 1000}} + EngineeringProjectBudgetResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectBudget'}} + EngineeringProjectBudgetCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectBudget'}}} + EngineeringProjectBudgetItem: + type: object + additionalProperties: false + required: [id, organizationId, budgetId, category, description, allocatedAmountMinor, version, createdAt, updatedAt] + properties: + id: {$ref: '#/components/schemas/Uuid'} + organizationId: {$ref: '#/components/schemas/Uuid'} + budgetId: {$ref: '#/components/schemas/Uuid'} + category: {type: string, minLength: 1, maxLength: 100} + description: {type: string, minLength: 1, maxLength: 1000} + allocatedAmountMinor: {type: integer, minimum: 0} + version: {type: integer, minimum: 1} + createdAt: {$ref: '#/components/schemas/Timestamp'} + updatedAt: {$ref: '#/components/schemas/Timestamp'} + CreateEngineeringProjectBudgetItemRequest: + type: object + additionalProperties: false + required: [category, description, allocatedAmountMinor] + properties: + category: {type: string, minLength: 1, maxLength: 100} + description: {type: string, minLength: 1, maxLength: 1000} + allocatedAmountMinor: {type: integer, minimum: 0} + EngineeringProjectBudgetItemResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringProjectBudgetItem'}} + EngineeringProjectBudgetItemCollectionResponse: + type: object + required: [data] + properties: {data: {type: array, items: {$ref: '#/components/schemas/EngineeringProjectBudgetItem'}}} + EngineeringBudgetProjection: + type: object + additionalProperties: false + required: [budgetId, currencyCode, allocatedAmountMinor, committedAmountMinor, actualAmountMinor, varianceAmountMinor, calculatedAt] + properties: + budgetId: {$ref: '#/components/schemas/Uuid'} + currencyCode: {$ref: '#/components/schemas/CurrencyCode'} + allocatedAmountMinor: {type: integer, minimum: 0} + committedAmountMinor: {type: integer, minimum: 0} + actualAmountMinor: {type: integer, minimum: 0} + varianceAmountMinor: {type: integer} + calculatedAt: {$ref: '#/components/schemas/Timestamp'} + sourceWatermark: {type: string, description: Reconciliation watermark for authoritative source records.} + EngineeringBudgetProjectionResponse: + type: object + required: [data] + properties: {data: {$ref: '#/components/schemas/EngineeringBudgetProjection'}} + 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] + EngineeringProjectRestorableStatus: + type: string + enum: [draft, closed] + EngineeringDiscipline: + type: string + enum: + - civil + - structural + - mechanical + - electrical + - geotechnical + - environmental + - transportation + - water_resources + - surveying + - multidisciplinary + - other + EngineeringProjectMemberRole: + type: string + enum: [engineer, designer, reviewer, inspector, viewer, contractor] + description: Project manager is intentionally excluded; `projectManagerUserId` is authoritative. + EngineeringProjectMemberStatus: + type: string + enum: [active, left] + description: Derived from whether `leftAt` is null. + EngineeringTaskStatus: + type: string + enum: [todo, in_progress, completed, cancelled] + EngineeringTaskPriority: + type: string + enum: [low, medium, high, urgent] + BatchExecutionMode: + type: string + enum: [atomic, partial] + + 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' + + EngineeringProject: + type: object + additionalProperties: false + required: + - id + - organizationId + - clientId + - projectNumber + - name + - description + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - archivedAt + - archivedByUserId + - archivedFromStatus + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + description: Immutable, organization-unique human project reference. + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + archivedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + archivedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + archivedFromStatus: + oneOf: + - $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + enum: [active, closed] + required: [status] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: closed + required: [status] + then: + properties: + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + enum: [draft, active] + required: [status] + then: + properties: + completedDate: + type: 'null' + - if: + properties: + status: + const: archived + required: [status] + then: + properties: + archivedAt: + $ref: '#/components/schemas/Timestamp' + archivedByUserId: + $ref: '#/components/schemas/Uuid' + archivedFromStatus: + $ref: '#/components/schemas/EngineeringProjectRestorableStatus' + else: + properties: + archivedAt: + type: 'null' + archivedByUserId: + type: 'null' + archivedFromStatus: + type: 'null' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: closed + required: [status, archivedFromStatus] + then: + properties: + projectManagerUserId: + $ref: '#/components/schemas/Uuid' + startDate: + $ref: '#/components/schemas/Date' + completedDate: + $ref: '#/components/schemas/Date' + - if: + properties: + status: + const: archived + archivedFromStatus: + const: draft + required: [status, archivedFromStatus] + then: + properties: + completedDate: + type: 'null' + description: Expected and completed dates may not precede the start date. + CreateEngineeringProjectRequest: + type: object + additionalProperties: false + required: [clientId, projectNumber, name, discipline] + properties: + clientId: + $ref: '#/components/schemas/Uuid' + projectNumber: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._/-]*$' + minLength: 1 + maxLength: 100 + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: Expected completion date may not precede start date. + UpdateEngineeringProjectRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + clientId: + $ref: '#/components/schemas/Uuid' + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: [string, 'null'] + maxLength: 5000 + discipline: + $ref: '#/components/schemas/EngineeringDiscipline' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + description: The resulting dates and manager assignment must satisfy the project's current state rules. + ActivateEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + startDate: + $ref: '#/components/schemas/Date' + CloseEngineeringProjectRequest: + type: object + additionalProperties: false + properties: + completedDate: + $ref: '#/components/schemas/Date' + reason: + type: string + maxLength: 500 + EngineeringProjectResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProject' + EngineeringProjectCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProject' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringProjectSummary: + type: object + additionalProperties: false + required: + - id + - clientId + - projectNumber + - name + - discipline + - status + - projectManagerUserId + - startDate + - expectedCompletionDate + - completedDate + - version + - createdAt + - updatedAt + 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: + $ref: '#/components/schemas/EngineeringDiscipline' + status: + $ref: '#/components/schemas/EngineeringProjectStatus' + projectManagerUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + startDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + expectedCompletionDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + completedDate: + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $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' + + EngineeringClientSummary: + type: object + additionalProperties: false + required: [id, clientType, displayName, legalName, status] + properties: + id: + $ref: '#/components/schemas/Uuid' + clientType: + $ref: '#/components/schemas/EngineeringClientType' + displayName: + type: string + legalName: + type: [string, 'null'] + status: + $ref: '#/components/schemas/EngineeringClientStatus' + EngineeringProjectActivitySummary: + type: object + additionalProperties: false + required: + - projectMemberCount + - phaseCount + - siteCount + - openTaskCount + - designCount + - designsUnderReviewCount + - inspectionCount + - upcomingInspectionCount + - documentCount + - lastActivityAt + properties: + projectMemberCount: + type: integer + minimum: 0 + description: Active participation rows; the separate project-manager pointer is not double-counted. + phaseCount: + type: integer + minimum: 0 + siteCount: + type: integer + minimum: 0 + openTaskCount: + type: integer + minimum: 0 + description: Tasks in todo or in-progress status. + designCount: + type: integer + minimum: 0 + designsUnderReviewCount: + type: integer + minimum: 0 + inspectionCount: + type: integer + minimum: 0 + upcomingInspectionCount: + type: integer + minimum: 0 + documentCount: + type: integer + minimum: 0 + lastActivityAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + EngineeringProjectDashboard: + type: object + additionalProperties: false + required: [project, client, projectManager, activity] + properties: + project: + $ref: '#/components/schemas/EngineeringProject' + client: + $ref: '#/components/schemas/EngineeringClientSummary' + projectManager: + oneOf: + - $ref: '#/components/schemas/UserSummary' + - type: 'null' + activity: + $ref: '#/components/schemas/EngineeringProjectActivitySummary' + EngineeringProjectDashboardResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectDashboard' + + EngineeringProjectMember: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - user + - projectRole + - status + - joinedAt + - leftAt + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + user: + $ref: '#/components/schemas/UserSummary' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + status: + $ref: '#/components/schemas/EngineeringProjectMemberStatus' + joinedAt: + $ref: '#/components/schemas/Timestamp' + leftAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: active + required: [status] + then: + properties: + leftAt: + type: 'null' + - if: + properties: + status: + const: left + required: [status] + then: + properties: + leftAt: + $ref: '#/components/schemas/Timestamp' + CreateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [userId, projectRole] + properties: + userId: + $ref: '#/components/schemas/Uuid' + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + UpdateEngineeringProjectMemberRequest: + type: object + additionalProperties: false + required: [projectRole] + properties: + projectRole: + $ref: '#/components/schemas/EngineeringProjectMemberRole' + EngineeringProjectMemberResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringProjectMember' + EngineeringProjectMemberCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringProjectMember' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTask: + type: object + additionalProperties: false + required: + - id + - organizationId + - projectId + - title + - description + - status + - priority + - createdByUserId + - assignedToUserId + - dueAt + - startedAt + - startedByUserId + - completedAt + - completedByUserId + - cancelledAt + - cancelledByUserId + - cancellationReason + - version + - createdAt + - updatedAt + properties: + id: + $ref: '#/components/schemas/Uuid' + organizationId: + $ref: '#/components/schemas/Uuid' + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + createdByUserId: + $ref: '#/components/schemas/Uuid' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + startedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + completedAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + completedByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancelledAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + cancelledByUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + cancellationReason: + type: [string, 'null'] + maxLength: 500 + version: + type: integer + minimum: 1 + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + allOf: + - if: + properties: + status: + const: todo + required: [status] + then: + properties: + startedAt: {type: 'null'} + startedByUserId: {type: 'null'} + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: in_progress + required: [status] + then: + properties: + startedAt: + $ref: '#/components/schemas/Timestamp' + startedByUserId: + $ref: '#/components/schemas/Uuid' + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: completed + required: [status] + then: + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + completedByUserId: + $ref: '#/components/schemas/Uuid' + cancelledAt: {type: 'null'} + cancelledByUserId: {type: 'null'} + cancellationReason: {type: 'null'} + - if: + properties: + status: + const: cancelled + required: [status] + then: + properties: + completedAt: {type: 'null'} + completedByUserId: {type: 'null'} + cancelledAt: + $ref: '#/components/schemas/Timestamp' + cancelledByUserId: + $ref: '#/components/schemas/Uuid' + description: Terminal and start metadata are controlled exclusively by task commands. + CreateEngineeringTaskRequest: + type: object + additionalProperties: false + required: [projectId, title] + properties: + projectId: + $ref: '#/components/schemas/Uuid' + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + allOf: + - $ref: '#/components/schemas/EngineeringTaskPriority' + default: medium + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + UpdateEngineeringTaskRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + title: + type: string + minLength: 1 + maxLength: 300 + description: + type: [string, 'null'] + maxLength: 10000 + priority: + $ref: '#/components/schemas/EngineeringTaskPriority' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + dueAt: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + CompleteEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + completedAt: + $ref: '#/components/schemas/Timestamp' + description: A supplied completion time cannot be in the future or precede task creation. + CancelEngineeringTaskRequest: + type: object + additionalProperties: false + properties: + reason: + type: string + maxLength: 500 + EngineeringTaskResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTask' + EngineeringTaskCollectionResponse: + type: object + additionalProperties: false + required: [data, meta] + properties: + data: + type: array + items: + $ref: '#/components/schemas/EngineeringTask' + meta: + $ref: '#/components/schemas/CollectionMeta' + + EngineeringTaskBatchItem: + type: object + additionalProperties: false + required: [id, version] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + BatchAssignEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, assigneeUserId, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + assigneeUserId: + $ref: '#/components/schemas/Uuid' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected even when their supplied versions differ. + BatchCompleteEngineeringTasksRequest: + type: object + additionalProperties: false + required: [tasks, mode] + properties: + tasks: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EngineeringTaskBatchItem' + completedAt: + $ref: '#/components/schemas/Timestamp' + mode: + $ref: '#/components/schemas/BatchExecutionMode' + description: Duplicate task IDs are rejected; completedAt follows the single-task completion rules. + EngineeringTaskBatchSuccess: + type: object + additionalProperties: false + required: [id, version, status, assignedToUserId] + properties: + id: + $ref: '#/components/schemas/Uuid' + version: + type: integer + minimum: 1 + status: + $ref: '#/components/schemas/EngineeringTaskStatus' + assignedToUserId: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + EngineeringTaskBatchFailure: + type: object + additionalProperties: false + required: [id, code, message, currentVersion] + properties: + id: + $ref: '#/components/schemas/Uuid' + code: + type: string + pattern: '^[A-Z][A-Z0-9_]+$' + message: + type: string + maxLength: 500 + currentVersion: + type: [integer, 'null'] + minimum: 1 + EngineeringTaskBatchResult: + type: object + additionalProperties: false + required: [mode, succeeded, failed] + properties: + mode: + $ref: '#/components/schemas/BatchExecutionMode' + succeeded: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchSuccess' + failed: + type: array + items: + $ref: '#/components/schemas/EngineeringTaskBatchFailure' + EngineeringTaskBatchResponse: + type: object + additionalProperties: false + required: [data] + properties: + data: + $ref: '#/components/schemas/EngineeringTaskBatchResult' + +security: + - bearerAuth: [] diff --git a/professional_management_platform_rest_plan.md b/professional_management_platform_rest_plan.md new file mode 100644 index 0000000..aec6e51 --- /dev/null +++ b/professional_management_platform_rest_plan.md @@ -0,0 +1,3163 @@ +# Professional Management Platform +## Full REST-First System Design Plan + +## 1. Product Vision + +Build one shared backend platform that powers multiple profession-specific management applications. + +Initial professions: + +- Engineering +- Legal +- Healthcare + +Future professions may include accounting, architecture, consulting, property management, veterinary practices, financial advisory, and other regulated or professional-service industries. + +The core design principle is: + +> **Share infrastructure, not domain meaning.** + +Engineering projects, legal matters, and medical patients are fundamentally different concepts and should not be forced into the same universal business table. + +--- + +## 2. Core Architecture Decision + +The platform will use: + +- REST +- JSON +- OpenAPI +- Versioned endpoints +- PostgreSQL +- Modular monolith backend +- Profession-specific frontends +- Profession-specific database tables +- Shared identity, security, billing, documents, audit, and infrastructure + +Base API path: + +```text +/api/v1 +``` + +GraphQL is not part of v1. + +--- + +## 3. High-Level Architecture + +```text + FRONTENDS + + ┌──────────────────┼──────────────────┐ + │ │ │ + Engineering Web Legal Web Healthcare Web + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ + REST API + /api/v1 + │ + ┌───────────┼───────────┐ + │ │ │ + Core Engineering Legal + │ │ │ + │ Healthcare │ + │ │ │ + └───────────┼───────────┘ + │ + PostgreSQL + │ + ┌───────────────┼────────────────┐ + │ │ │ + Shared Tables Profession Tables Audit/Event Tables +``` + +Shared infrastructure: + +```text +PostgreSQL +Redis +Object Storage +Queue / Workers +Audit +Notifications +Billing +Observability +``` + +--- + +## 4. System Architecture Strategy + +Start with a modular monolith. + +Do not start with microservices. + +Initial deployment: + +```text +Frontend Apps + │ + ▼ +Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Worker Queue +``` + +Benefits: + +- simpler transactions +- easier development +- easier deployment +- clearer domain boundaries +- lower operational burden +- easier refactoring +- future service extraction remains possible + +--- + +## 5. Repository Structure + +Recommended monorepo: + +```text +professional-platform/ +│ +├── apps/ +│ ├── engineering-web/ +│ ├── legal-web/ +│ ├── healthcare-web/ +│ ├── platform-admin/ +│ ├── api/ +│ └── workers/ +│ +├── packages/ +│ ├── ui/ +│ ├── api-client/ +│ ├── auth-client/ +│ ├── validation/ +│ ├── types/ +│ ├── config/ +│ └── testing/ +│ +├── database/ +│ ├── migrations/ +│ ├── seeds/ +│ └── scripts/ +│ +├── infrastructure/ +│ ├── docker/ +│ ├── deployment/ +│ └── monitoring/ +│ +└── docs/ + ├── architecture/ + ├── api/ + ├── security/ + └── domains/ +``` + +--- + +## 6. Frontend Strategy + +Every profession receives its own frontend application. + +Avoid one giant frontend filled with profession checks. + +### Engineering Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Projects +Project Phases +Project Team +Sites +Designs +Design Reviews +Inspections +Specifications +Tasks +Documents +Timesheets +Billing +Reports +Administration +``` + +### Legal Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Matters +Cases +Hearings +Courts +Deadlines +Documents +Conflict Checks +Time Tracking +Retainers +Billing +Reports +Administration +``` + +### Healthcare Frontend + +Suggested navigation: + +```text +Dashboard +Patients +Appointments +Practitioners +Encounters +Clinical Records +Diagnoses +Prescriptions +Insurance +Documents +Billing +Reports +Administration +``` + +### Platform Admin Frontend + +Suggested functions: + +```text +Organizations +Users +Profession Modules +Subscriptions +System Health +Audit +Support +Global Configuration +``` + +Platform administrators and organization administrators are separate concepts. + +--- + +## 7. REST API Structure + +Shared endpoints: + +```text +/api/v1/auth +/api/v1/me +/api/v1/organizations +/api/v1/memberships +/api/v1/membership-invitations +/api/v1/roles +/api/v1/permissions +/api/v1/documents +/api/v1/invoices +/api/v1/payments +/api/v1/audit-events +``` + +Engineering: + +```text +/api/v1/engineering/clients +/api/v1/engineering/projects +/api/v1/engineering/project-members +/api/v1/engineering/phases +/api/v1/engineering/sites +/api/v1/engineering/tasks +/api/v1/engineering/designs +/api/v1/engineering/inspections +/api/v1/engineering/specifications +/api/v1/engineering/time-entries +``` + +Legal: + +```text +/api/v1/legal/clients +/api/v1/legal/matters +/api/v1/legal/cases +/api/v1/legal/hearings +/api/v1/legal/deadlines +/api/v1/legal/conflict-checks +/api/v1/legal/retainers +/api/v1/legal/time-entries +``` + +Healthcare: + +```text +/api/v1/healthcare/patients +/api/v1/healthcare/practitioners +/api/v1/healthcare/appointments +/api/v1/healthcare/encounters +/api/v1/healthcare/clinical-records +/api/v1/healthcare/diagnoses +/api/v1/healthcare/prescriptions +/api/v1/healthcare/insurance +``` + +--- + +## 8. REST Conventions + +All APIs use JSON. + +Typical headers: + +```http +Authorization: Bearer +X-Organization-Id: org_123 +Content-Type: application/json +``` + +Every tenant-scoped request requires organization context. + +The server must verify: + +1. user is authenticated +2. organization exists +3. membership exists +4. membership is active +5. required permission is present +6. resource belongs to the organization +7. resource policy permits access +8. profession-specific rules pass + +--- + +## 9. Standard Response Format + +Single resource: + +```json +{ + "data": { + "id": "project_123", + "name": "Central Tower" + } +} +``` + +Collection: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Error: + +```json +{ + "error": { + "code": "PROJECT_NOT_FOUND", + "message": "Project not found.", + "details": {} + } +} +``` + +Clients should rely on `error.code`, not human-readable messages. + +--- + +## 10. HTTP Status Rules + +```text +200 Success +201 Created +202 Accepted +204 No Content +400 Bad Request +401 Unauthorized +403 Forbidden +404 Not Found +409 Conflict +422 Validation Error +429 Too Many Requests +500 Internal Server Error +``` + +Cross-tenant resource access should return 404. + +--- + +## 11. API Versioning + +Current API: + +```text +/api/v1 +``` + +Breaking changes require: + +```text +/api/v2 +``` + +Additive fields generally do not require a new version. + +--- + +## 12. Authentication + +Initial authentication: + +```text +Email ++ +Password ++ +Access Token ++ +Refresh Token +``` + +Endpoints: + +```http +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/refresh +POST /api/v1/auth/logout +GET /api/v1/me +``` + +Access tokens: + +- short-lived +- signed +- identify user/session + +Refresh tokens: + +- rotated +- revocable +- stored hashed + +Future: + +- MFA +- passkeys +- SSO +- OAuth +- enterprise identity providers + +--- + +## 13. Shared Core Backend + +Recommended modules: + +```text +core/ +├── auth/ +├── users/ +├── organizations/ +├── memberships/ +├── roles/ +├── permissions/ +├── authorization/ +├── documents/ +├── billing/ +├── notifications/ +├── audit/ +└── events/ +``` + +Dependency rule: + +```text +Profession module → Core +``` + +Never: + +```text +Core → Profession module +``` + +--- + +## 14. Organizations + +Organizations are tenants. + +Examples: + +```text +Atlas Structural Engineering +Smith & Associates Law +North Shore Medical Practice +``` + +Suggested fields: + +```text +id +name +slug +status +country_code +timezone +currency_code +created_at +updated_at +``` + +--- + +## 15. Profession Enablement + +Use: + +```text +organization_professions +``` + +Suggested fields: + +```text +organization_id +profession +enabled_at +configuration +``` + +Possible professions: + +```text +engineering +legal +healthcare +``` + +An organization may eventually enable more than one profession module. + +--- + +## 16. Users and Memberships + +Users are global identities. + +A user gains tenant access through membership. + +```text +User + │ + ▼ +Membership + │ + ▼ +Organization +``` + +Suggested `users` fields: + +```text +id +email +first_name +last_name +phone +avatar_url +status +created_at +updated_at +``` + +Suggested `memberships` fields: + +```text +id +organization_id +user_id +status +joined_at +created_at +updated_at +``` + +--- + +## 17. Membership Invitations + +Keep invitations separate from memberships. + +Suggested table: + +```text +membership_invitations +``` + +Fields: + +```text +id +organization_id +email +invited_by_user_id +expires_at +accepted_at +revoked_at +created_at +``` + +Flow: + +```text +Invitation + ↓ +Accepted + ↓ +User + ↓ +Membership +``` + +--- + +## 18. Authorization + +Use: + +```text +RBAC ++ +Resource Policies ++ +Professional Qualifications +``` + +Decision flow: + +```text +Authenticated User + ↓ +Organization Membership + ↓ +Roles + ↓ +Permissions + ↓ +Permission Scope + ↓ +Resource Policy + ↓ +Professional Qualification Rule + ↓ +Domain State Rule + ↓ +ALLOW / DENY +``` + +Default decision: + +```text +DENY +``` + +--- + +## 19. Roles and Permissions + +Example roles: + +```text +Owner +Administrator +Project Manager +Engineer +Lawyer +Paralegal +Doctor +Nurse +Billing Manager +Viewer +``` + +Example permissions: + +```text +clients.read +clients.create +clients.update + +documents.read +documents.upload + +billing.read +invoices.issue +payments.record + +engineering.projects.read +engineering.projects.manage +engineering.designs.approve + +legal.matters.read +legal.matters.manage + +healthcare.records.read +healthcare.records.write +``` + +--- + +## 20. Permission Scopes + +Initial scopes: + +```text +assigned +organization +``` + +Examples: + +```text +Engineer: +engineering.projects.read = assigned + +Principal Engineer: +engineering.projects.read = organization +``` + +Potential future scopes: + +```text +owned +team +department +restricted +``` + +Do not implement until required. + +--- + +## 21. Professional Credentials + +Professional qualification is separate from RBAC. + +Suggested table: + +```text +professional_profiles +``` + +Fields: + +```text +organization_id +user_id +profession +title +license_number +registration_number +jurisdiction +status +expiry_date +``` + +A user having a permission does not automatically mean they are professionally qualified to perform every regulated action. + +--- + +## 22. Database Architecture + +Use PostgreSQL. + +Start with: + +```text +One database ++ +Shared schema ++ +Profession-specific tables +``` + +Do not begin with database-per-profession or database-per-customer unless compliance or residency requirements force that choice. + +--- + +## 23. Shared Tables + +Recommended shared tables: + +```text +organizations +organization_professions + +users +user_credentials +sessions + +memberships +membership_invitations + +roles +permissions +role_permissions +membership_roles + +professional_profiles + +documents +document_versions + +invoices +invoice_items +payments + +notifications +notification_deliveries + +audit_events +outbox_events +``` + +--- + +## 24. Multi-Tenancy Rule + +Every tenant-owned row must contain: + +```text +organization_id +``` + +Examples: + +```text +engineering_projects.organization_id +legal_matters.organization_id +healthcare_patients.organization_id +``` + +Enforce tenant boundaries at: + +- API layer +- authorization layer +- repository/query layer +- database constraints + +--- + +## 25. Tenant-Safe Foreign Keys + +Use composite tenant-aware foreign keys when possible. + +Example: + +```text +engineering_projects +organization_id +client_id +``` + +references: + +```text +engineering_clients +organization_id +id +``` + +This prevents linking a resource from one organization to another organization's data. + +--- + +# Engineering Domain + +## 26. Engineering Tables + +Initial tables: + +```text +engineering_clients +engineering_projects +engineering_project_members +engineering_project_phases +engineering_sites +engineering_tasks +engineering_designs +engineering_design_versions +engineering_design_reviews +engineering_inspections +engineering_inspection_findings +engineering_specifications +engineering_change_requests +engineering_time_entries +``` + +--- + +## 27. Engineering Clients + +Suggested fields: + +```text +id +organization_id +client_type +display_name +legal_name +contact_name +email +phone +status +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/clients +GET /api/v1/engineering/clients +GET /api/v1/engineering/clients/{clientId} +PATCH /api/v1/engineering/clients/{clientId} +POST /api/v1/engineering/clients/{clientId}/archive +``` + +--- + +## 28. Engineering Projects + +Suggested fields: + +```text +id +organization_id +client_id +project_number +name +description +discipline +stage +status +project_manager_user_id +start_date +expected_completion_date +completed_date +budget_minor +currency_code +created_at +updated_at +version +``` + +REST: + +```http +POST /api/v1/engineering/projects +GET /api/v1/engineering/projects +GET /api/v1/engineering/projects/{projectId} +PATCH /api/v1/engineering/projects/{projectId} + +POST /api/v1/engineering/projects/{projectId}/activate +POST /api/v1/engineering/projects/{projectId}/close +``` + +--- + +## 29. Engineering Project Members + +Suggested fields: + +```text +id +organization_id +project_id +user_id +project_role +joined_at +left_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/members +POST /api/v1/engineering/projects/{projectId}/members +PATCH /api/v1/engineering/projects/{projectId}/members/{memberId} +DELETE /api/v1/engineering/projects/{projectId}/members/{memberId} +``` + +--- + +## 30. Engineering Project Phases + +Suggested fields: + +```text +id +organization_id +project_id +name +sequence +status +start_date +end_date +created_at +updated_at +``` + +Typical phases: + +```text +Concept +Preliminary Design +Detailed Design +Construction +Inspection +Closeout +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/phases +POST /api/v1/engineering/projects/{projectId}/phases +PATCH /api/v1/engineering/projects/{projectId}/phases/{phaseId} +POST /api/v1/engineering/projects/{projectId}/phases/{phaseId}/complete +``` + +--- + +## 31. Engineering Sites + +Suggested fields: + +```text +id +organization_id +project_id +name +address +latitude +longitude +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/sites/{siteId} +PATCH /api/v1/engineering/sites/{siteId} +``` + +--- + +## 32. Engineering Tasks + +Suggested fields: + +```text +id +organization_id +project_id +title +description +status +priority +created_by_user_id +assigned_to_user_id +due_at +completed_at +created_at +updated_at +version +``` + +REST: + +```http +POST /api/v1/engineering/tasks +GET /api/v1/engineering/tasks +GET /api/v1/engineering/tasks/{taskId} +PATCH /api/v1/engineering/tasks/{taskId} + +POST /api/v1/engineering/tasks/{taskId}/complete +POST /api/v1/engineering/tasks/{taskId}/reopen +POST /api/v1/engineering/tasks/{taskId}/cancel +``` + +--- + +## 33. Engineering Designs + +Suggested fields: + +```text +id +organization_id +project_id +design_number +title +description +discipline +status +prepared_by_user_id +approved_by_user_id +created_at +updated_at +version +``` + +Possible statuses: + +```text +draft +under_review +changes_requested +approved +superseded +``` + +REST: + +```http +POST /api/v1/engineering/projects/{projectId}/designs +GET /api/v1/engineering/projects/{projectId}/designs +GET /api/v1/engineering/designs/{designId} +PATCH /api/v1/engineering/designs/{designId} + +POST /api/v1/engineering/designs/{designId}/submit-review +POST /api/v1/engineering/designs/{designId}/request-changes +POST /api/v1/engineering/designs/{designId}/approve +``` + +Important approvals use explicit commands, not generic status PATCH operations. + +--- + +## 34. Design Versions and Reviews + +`engineering_design_versions`: + +```text +id +design_id +version_number +document_id +created_by_user_id +created_at +``` + +`engineering_design_reviews`: + +```text +id +organization_id +design_id +reviewer_user_id +status +comments +reviewed_at +``` + +Possible review statuses: + +```text +pending +approved +changes_requested +rejected +``` + +--- + +## 35. Engineering Inspections + +Suggested fields: + +```text +id +organization_id +project_id +site_id +inspection_type +inspector_user_id +status +scheduled_at +performed_at +summary +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/projects/{projectId}/inspections +GET /api/v1/engineering/projects/{projectId}/inspections +GET /api/v1/engineering/inspections/{inspectionId} +PATCH /api/v1/engineering/inspections/{inspectionId} + +POST /api/v1/engineering/inspections/{inspectionId}/start +POST /api/v1/engineering/inspections/{inspectionId}/complete +``` + +--- + +## 36. Inspection Findings + +Suggested fields: + +```text +id +inspection_id +severity +description +status +resolved_at +``` + +Possible severities: + +```text +observation +minor +major +critical +``` + +REST: + +```http +POST /api/v1/engineering/inspections/{inspectionId}/findings +PATCH /api/v1/engineering/inspection-findings/{findingId} +POST /api/v1/engineering/inspection-findings/{findingId}/resolve +``` + +--- + +## 37. Engineering Specifications + +Suggested fields: + +```text +id +organization_id +project_id +specification_number +title +version +status +document_id +created_at +updated_at +``` + +--- + +## 38. Engineering Change Requests + +Suggested fields: + +```text +id +organization_id +project_id +request_number +title +description +status +requested_by_user_id +approved_by_user_id +estimated_cost_minor +created_at +updated_at +``` + +--- + +## 39. Engineering Time Entries + +Suggested fields: + +```text +id +organization_id +project_id +user_id +work_date +duration_minutes +description +billable +billing_rate_minor +currency_code +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries/{id} +PATCH /api/v1/engineering/time-entries/{id} +``` + +Store duration as integer minutes. + +--- + +# Legal Domain + +## 40. Legal Tables + +Initial tables: + +```text +legal_clients +legal_matters +legal_matter_members +legal_cases +legal_case_parties +legal_courts +legal_hearings +legal_deadlines +legal_documents +legal_time_entries +legal_retainers +legal_conflict_checks +``` + +REST namespace: + +```text +/api/v1/legal +``` + +Examples: + +```http +GET /api/v1/legal/clients +POST /api/v1/legal/matters +GET /api/v1/legal/matters/{id} +POST /api/v1/legal/matters/{id}/close +GET /api/v1/legal/cases +POST /api/v1/legal/hearings +POST /api/v1/legal/conflict-checks +``` + +--- + +## 41. Legal Matters + +Suggested fields: + +```text +id +organization_id +client_id +matter_number +title +practice_area +responsible_lawyer_user_id +status +opened_date +closed_date +created_at +updated_at +``` + +--- + +## 42. Legal Cases + +Suggested fields: + +```text +id +organization_id +matter_id +case_number +court_id +jurisdiction +case_type +status +filed_date +created_at +updated_at +``` + +--- + +## 43. Legal Hearings + +Suggested fields: + +```text +id +organization_id +case_id +hearing_type +scheduled_at +courtroom +judge +status +notes +``` + +--- + +## 44. Legal Conflict Checks + +Suggested fields: + +```text +id +organization_id +potential_client_name +related_parties +requested_by_user_id +reviewed_by_user_id +status +result +created_at +reviewed_at +``` + +--- + +# Healthcare Domain + +## 45. Healthcare Tables + +Initial tables: + +```text +healthcare_patients +healthcare_practitioners +healthcare_appointments +healthcare_encounters +healthcare_clinical_records +healthcare_diagnoses +healthcare_prescriptions +healthcare_insurance +healthcare_allergies +healthcare_medications +``` + +REST namespace: + +```text +/api/v1/healthcare +``` + +Examples: + +```http +GET /api/v1/healthcare/patients +POST /api/v1/healthcare/patients +POST /api/v1/healthcare/appointments +POST /api/v1/healthcare/encounters +GET /api/v1/healthcare/clinical-records/{id} +POST /api/v1/healthcare/prescriptions +``` + +Healthcare must have stricter privacy, auditing, retention, and credential checks. + +--- + +## 46. Healthcare Patients + +Suggested fields: + +```text +id +organization_id +patient_number +first_name +middle_name +last_name +date_of_birth +sex +phone +email +address +status +created_at +updated_at +``` + +--- + +## 47. Healthcare Practitioners + +Suggested fields: + +```text +id +organization_id +user_id +specialty +license_number +license_jurisdiction +credential_status +created_at +updated_at +``` + +--- + +## 48. Healthcare Appointments + +Suggested fields: + +```text +id +organization_id +patient_id +practitioner_id +appointment_type +starts_at +ends_at +status +reason +created_at +updated_at +``` + +--- + +## 49. Healthcare Encounters + +Suggested fields: + +```text +id +organization_id +patient_id +practitioner_id +appointment_id +encounter_type +started_at +ended_at +status +``` + +--- + +## 50. Clinical Records + +Suggested fields: + +```text +id +organization_id +patient_id +encounter_id +author_practitioner_id +record_type +content +created_at +updated_at +``` + +Sensitive clinical content may require application-level encryption. + +--- + +# Shared Platform Services + +## 51. Documents + +Use shared object storage. + +Database: + +```text +documents +document_versions +``` + +Actual binary files: + +```text +S3-compatible Object Storage +``` + +Suggested `documents` fields: + +```text +id +organization_id +name +mime_type +size_bytes +created_by_user_id +created_at +updated_at +``` + +Suggested `document_versions` fields: + +```text +id +document_id +version_number +storage_key +checksum +size_bytes +uploaded_by_user_id +created_at +``` + +--- + +## 52. Document Upload Flow + +```text +Frontend + ↓ +Request upload URL + ↓ +Backend authorizes + ↓ +Signed upload URL + ↓ +Frontend uploads to object storage + ↓ +Backend finalizes document + ↓ +Virus/security scan + ↓ +Document becomes available +``` + +REST: + +```http +POST /api/v1/documents/upload-url +POST /api/v1/documents/{documentId}/complete-upload +GET /api/v1/documents/{documentId} +GET /api/v1/documents/{documentId}/download-url +POST /api/v1/documents/{documentId}/versions +``` + +--- + +## 53. Profession-Specific Document Links + +Use explicit tables where possible. + +Engineering: + +```text +engineering_project_documents +engineering_design_documents +engineering_inspection_documents +``` + +Legal: + +```text +legal_matter_documents +legal_case_documents +``` + +Healthcare: + +```text +healthcare_patient_documents +healthcare_encounter_documents +``` + +This gives stronger foreign-key integrity than generic polymorphic document links. + +--- + +## 54. Billing + +Shared financial core: + +```text +invoices +invoice_items +payments +``` + +Profession-specific modules may extend billing workflows. + +Engineering examples: + +```text +project billing +hourly billing +milestone billing +``` + +Legal examples: + +```text +matter billing +time billing +retainers +trust accounting +``` + +Healthcare examples: + +```text +insurance +claims +patient billing +``` + +REST: + +```http +POST /api/v1/invoices +GET /api/v1/invoices +GET /api/v1/invoices/{invoiceId} +PATCH /api/v1/invoices/{invoiceId} + +POST /api/v1/invoices/{invoiceId}/issue +POST /api/v1/invoices/{invoiceId}/void +POST /api/v1/invoices/{invoiceId}/payments +POST /api/v1/payments/{paymentId}/refund +``` + +--- + +## 55. Money Representation + +Use integer minor units: + +```json +{ + "amountMinor": 12550, + "currency": "USD" +} +``` + +Meaning: + +```text +$125.50 +``` + +Never use floating point for money. + +--- + +## 56. Audit Logging + +Table: + +```text +audit_events +``` + +Suggested fields: + +```text +id +organization_id +actor_user_id +action +resource_type +resource_id +request_id +ip_address +user_agent +metadata +occurred_at +``` + +Examples: + +```text +engineering.project.created +engineering.design.approved +legal.matter.created +legal.document.viewed +healthcare.record.viewed +invoice.issued +membership.role_changed +``` + +Audit logs are append-only. + +REST: + +```http +GET /api/v1/audit-events +``` + +No public create/update/delete endpoints. + +--- + +## 57. Domain Events and Transactional Outbox + +Profession modules produce internal domain events. + +Examples: + +```text +engineering.project.created +engineering.inspection.completed +legal.hearing.scheduled +healthcare.appointment.created +invoice.issued +``` + +Consumers: + +```text +notifications +webhooks +analytics +search +integrations +background workflows +``` + +Use: + +```text +outbox_events +``` + +Transaction pattern: + +```text +BEGIN + +business update +audit event +outbox event + +COMMIT +``` + +Worker processes outbox events after commit. + +--- + +## 58. Background Jobs + +Workers handle: + +```text +Email +SMS +Notifications +Report generation +PDF generation +File scanning +Document processing +Imports +Exports +Webhook delivery +Search indexing +Large data operations +``` + +Architecture: + +```text +API + ↓ +Queue + ↓ +Worker +``` + +--- + +## 59. Redis + +Use Redis for: + +```text +job queue +rate limiting +short-lived caching +distributed locks +optional session support +``` + +PostgreSQL remains the authoritative source of truth. + +--- + +## 60. Pagination + +Use cursor pagination. + +Example: + +```http +GET /api/v1/engineering/projects?limit=25 +``` + +Response: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": "...", + "hasMore": true + } + } +} +``` + +Maximum page size: + +```text +100 +``` + +--- + +## 61. Filtering + +Use explicit resource-specific filters. + +Examples: + +```http +GET /api/v1/engineering/projects?status=active&discipline=structural +GET /api/v1/engineering/tasks?status=todo&assignedToUserId=user_123 +``` + +Do not build a generic query DSL in v1. + +--- + +## 62. Sorting + +Examples: + +```http +GET /api/v1/engineering/projects?sort=createdAt +GET /api/v1/engineering/projects?sort=-createdAt +``` + +Only explicitly supported fields may be sorted. + +--- + +## 63. Search + +Start with PostgreSQL search. + +Engineering search may cover: + +```text +project number +project name +client name +``` + +Legal: + +```text +matter number +client +case number +``` + +Healthcare: + +```text +patient number +patient identity +``` + +Healthcare search requires tighter privacy controls. + +Do not introduce Elasticsearch/OpenSearch until PostgreSQL is genuinely insufficient. + +--- + +## 64. Optimistic Concurrency + +Important mutable resources should use a version field. + +Example: + +```json +{ + "id": "project_123", + "version": 6 +} +``` + +Update: + +```json +{ + "version": 6, + "name": "Central Tower Phase II" +} +``` + +If the current database version differs: + +```text +409 CONCURRENT_MODIFICATION +``` + +--- + +## 65. Domain-Oriented REST + +Important state transitions use explicit command endpoints. + +Good: + +```http +POST /engineering/projects/{id}/close +POST /engineering/designs/{id}/approve +POST /engineering/tasks/{id}/complete +POST /engineering/inspections/{id}/complete +POST /invoices/{id}/issue +``` + +Avoid: + +```http +PATCH /resource/{id} +{ + "status": "approved" +} +``` + +when the change has significant rules or side effects. + +--- + +## 66. Transaction Boundaries + +Create project: + +```text +BEGIN + +create project +assign project manager +write audit event +write outbox event + +COMMIT +``` + +Approve design: + +```text +BEGIN + +validate permission +validate project access +validate credentials +validate design state +create review result +mark approved +write audit event +write outbox event + +COMMIT +``` + +--- + +## 67. Request Context + +Every authenticated request should resolve: + +```text +RequestContext +{ + requestId + userId + sessionId + organizationId + membershipId + permissions +} +``` + +Profession modules consume this context. + +--- + +## 68. Request IDs + +Every request has: + +```http +X-Request-Id +``` + +If missing, the server generates one. + +Use it in: + +- logs +- audit context +- error diagnostics +- asynchronous correlation + +--- + +## 69. OpenAPI + +Maintain: + +```text +openapi.yaml +``` + +OpenAPI should define: + +- routes +- request DTOs +- response DTOs +- authentication +- error schemas +- pagination +- filters +- examples + +Generate frontend API clients when practical. + +--- + +## 70. DTO Rule + +Database models are not public API contracts. + +Use: + +```text +Request DTO +Response DTO +``` + +A database migration should not accidentally change the public API. + +--- + +## 71. Backend Module Structure + +Recommended: + +```text +src/ +├── core/ +│ ├── auth/ +│ ├── organizations/ +│ ├── memberships/ +│ ├── authorization/ +│ ├── documents/ +│ ├── billing/ +│ ├── audit/ +│ └── events/ +│ +├── engineering/ +│ ├── clients/ +│ ├── projects/ +│ ├── project-members/ +│ ├── phases/ +│ ├── sites/ +│ ├── tasks/ +│ ├── designs/ +│ ├── inspections/ +│ └── specifications/ +│ +├── legal/ +│ ├── clients/ +│ ├── matters/ +│ ├── cases/ +│ ├── hearings/ +│ ├── conflicts/ +│ └── retainers/ +│ +└── healthcare/ + ├── patients/ + ├── practitioners/ + ├── appointments/ + ├── encounters/ + ├── records/ + └── prescriptions/ +``` + +--- + +## 72. Internal Module Structure + +Example: + +```text +projects/ +├── domain/ +│ ├── project.entity.ts +│ ├── project-status.ts +│ └── project.errors.ts +│ +├── application/ +│ ├── commands/ +│ │ ├── create-project.ts +│ │ ├── update-project.ts +│ │ └── close-project.ts +│ │ +│ └── queries/ +│ ├── get-project.ts +│ └── list-projects.ts +│ +├── infrastructure/ +│ └── project.repository.ts +│ +└── api/ + ├── project.controller.ts + ├── project.request.ts + └── project.response.ts +``` + +--- + +## 73. Controllers + +Controllers should handle: + +```text +HTTP +authentication context +input DTO parsing +application command/query invocation +response mapping +``` + +Controllers should not contain: + +```text +business rules +raw SQL +role logic +transaction orchestration +email sending +audit implementation +``` + +--- + +## 74. Commands and Queries + +Mutations use commands. + +Examples: + +```text +CreateEngineeringProjectCommand +ApproveEngineeringDesignCommand +CloseLegalMatterCommand +CompleteHealthcareEncounterCommand +``` + +Reads use queries. + +Examples: + +```text +GetEngineeringProjectQuery +ListLegalMattersQuery +GetHealthcarePatientQuery +``` + +--- + +## 75. Repositories + +Use domain-specific repositories. + +Examples: + +```text +EngineeringProjectRepository +LegalMatterRepository +HealthcarePatientRepository +``` + +Avoid one massive generic repository abstraction that eventually needs dozens of flags. + +--- + +## 76. Security Baseline + +Minimum controls: + +```text +TLS everywhere +strong password hashing +secure refresh token storage +rate limiting +RBAC +tenant isolation +input validation +SQL injection protection +signed object-storage URLs +audit trails +secret management +encryption at rest +dependency scanning +security headers +session revocation +``` + +--- + +## 77. Data Classification + +Suggested classes: + +### Public + +```text +marketing configuration +``` + +### Internal + +```text +organization settings +tasks +``` + +### Confidential + +```text +engineering documents +legal matters +billing +``` + +### Highly Sensitive + +```text +clinical records +professional credentials +authentication secrets +``` + +--- + +## 78. Healthcare Security + +Before healthcare production use, define: + +```text +privacy model +clinical access model +audit policy +retention policy +credential model +jurisdiction requirements +encryption strategy +consent requirements +data residency requirements +``` + +Healthcare should be treated as a stricter security tier. + +--- + +## 79. Observability + +Use: + +```text +structured logs +metrics +distributed tracing +request IDs +``` + +Recommended: + +```text +OpenTelemetry +``` + +Track: + +```text +API latency +error rate +database latency +database connections +queue depth +worker failures +authentication failures +authorization denials +external service failures +``` + +--- + +## 80. Logging + +Useful fields: + +```text +request_id +route +method +status +duration +user_id when appropriate +organization_id when appropriate +``` + +Never log: + +```text +passwords +tokens +clinical record text +full sensitive documents +payment secrets +``` + +--- + +## 81. Testing Strategy + +### Unit Tests + +Test: + +```text +domain rules +authorization policies +calculations +state transitions +``` + +### Integration Tests + +Test: + +```text +repositories +PostgreSQL constraints +transactions +``` + +### API Tests + +Test: + +```text +routes +validation +authentication +authorization +error responses +``` + +### End-to-End Tests + +Test complete professional workflows. + +--- + +## 82. Tenant Security Tests + +For every major resource, attempt: + +```text +Organization A resource +using Organization B context +``` + +Test: + +```text +read +update +delete/action +list filtering +search +documents +``` + +Expected result: + +```text +404 / denied +``` + +--- + +## 83. Engineering MVP + +Engineering is the first vertical. + +Initial features: + +```text +Authentication +Organization management +Users / memberships / roles +Engineering clients +Projects +Project members +Project phases +Tasks +Sites +Documents +Basic design records +Inspections +Time entries +Basic billing +Audit history +``` + +Do not initially build: + +```text +advanced CAD integration +BIM integration +full document markup +advanced resource planning +procurement +complex accounting +AI design analysis +IoT integrations +``` + +--- + +## 84. Engineering MVP Workflow + +```text +User registers + ↓ +Creates engineering organization + ↓ +Invites engineer + ↓ +Assigns role + ↓ +Creates client + ↓ +Creates project + ↓ +Assigns project team + ↓ +Creates project phases + ↓ +Creates tasks + ↓ +Uploads documents + ↓ +Creates design + ↓ +Reviews / approves design + ↓ +Schedules inspection + ↓ +Records inspection findings + ↓ +Records engineering time + ↓ +Creates invoice + ↓ +Records payment + ↓ +Closes project + ↓ +Audit history contains lifecycle +``` + +--- + +## 85. Development Phases + +### Phase 0: Architecture Foundation + +Deliver: + +```text +Domain design +Database conventions +REST conventions +Authorization rules +Repository structure +Engineering workflows +``` + +### Phase 1: Shared Platform Core + +Build: + +```text +Auth +Users +Organizations +Organization professions +Membership invitations +Memberships +Roles +Permissions +Authorization +Audit +Outbox +``` + +### Phase 2: Engineering CRM + +Build: + +```text +engineering_clients +``` + +### Phase 3: Engineering Projects + +Build: + +```text +engineering_projects +engineering_project_members +engineering_project_phases +``` + +### Phase 4: Work Management + +Build: + +```text +engineering_tasks +engineering_sites +``` + +### Phase 5: Documents + +Build: + +```text +documents +document_versions +object storage +signed uploads +engineering document links +``` + +### Phase 6: Designs + +Build: + +```text +engineering_designs +engineering_design_versions +engineering_design_reviews +approval rules +credential-aware authorization +``` + +### Phase 7: Inspections + +Build: + +```text +engineering_inspections +engineering_inspection_findings +follow-ups +attachments +``` + +### Phase 8: Time and Billing + +Build: + +```text +engineering_time_entries +invoices +invoice_items +payments +``` + +### Phase 9: Notifications + +Build: + +```text +in-app notifications +email +worker processing +``` + +### Phase 10: Reporting + +Initial reports: + +```text +project status +overdue tasks +inspection status +billable time +revenue +outstanding invoices +``` + +--- + +## 86. Legal Expansion + +Only after engineering proves the shared platform assumptions. + +Build: + +```text +Legal Client + ↓ +Matter + ↓ +Case + ↓ +Hearings / Deadlines / Documents +``` + +Do not redesign engineering around legal terminology. + +Extract only genuinely reusable infrastructure. + +--- + +## 87. Healthcare Expansion + +Healthcare comes after: + +- core platform is stable +- audit model is proven +- permission model is proven +- tenant isolation is tested +- retention and encryption strategies are defined + +Healthcare should be treated as its own security and compliance workstream. + +--- + +## 88. Deployment Environments + +Use: + +```text +development +testing +staging +production +``` + +Each environment has independent: + +```text +database +object storage +secrets +queues +API keys +``` + +--- + +## 89. Initial Deployment Architecture + +```text +CDN + │ + ├── Engineering Web + ├── Legal Web + └── Healthcare Web + +Load Balancer + │ + Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Queue + │ + Workers +``` + +Prefer managed infrastructure where practical. + +--- + +## 90. Backup Strategy + +Database: + +```text +automated backups +point-in-time recovery +tested restores +``` + +Object storage: + +```text +versioning +retention policies +backup or replication where required +``` + +A backup strategy is incomplete until restoration is tested. + +--- + +## 91. Migration Strategy + +Use explicit numbered migrations. + +Example: + +```text +001_core_organizations +002_core_users +003_core_memberships +004_core_rbac +005_core_audit +006_engineering_clients +007_engineering_projects +008_engineering_project_members +009_engineering_phases +010_engineering_tasks +``` + +Do not use automatic ORM schema synchronization in production. + +--- + +## 92. Technology Recommendation + +Backend: + +```text +TypeScript +NestJS or Fastify-based architecture +``` + +Database: + +```text +PostgreSQL +``` + +ORM/query layer candidates: + +```text +Prisma +Drizzle +Kysely +``` + +Frontend: + +```text +React / Next.js +``` + +Queue: + +```text +Redis + BullMQ +``` + +Storage: + +```text +S3-compatible storage +``` + +Observability: + +```text +OpenTelemetry +``` + +Containers: + +```text +Docker +``` + +--- + +## 93. REST API Milestones + +### Milestone 1: Platform Access + +```http +POST /auth/register +POST /auth/login +POST /auth/refresh +GET /me + +POST /organizations +GET /me/organizations + +POST /membership-invitations +GET /memberships + +GET /roles +POST /roles +GET /permissions +``` + +Goal: + +```text +Create account +↓ +Create organization +↓ +Invite team +↓ +Assign roles +``` + +### Milestone 2: Engineering Clients + +```http +POST /engineering/clients +GET /engineering/clients +GET /engineering/clients/{id} +PATCH /engineering/clients/{id} +``` + +### Milestone 3: Engineering Projects + +```http +POST /engineering/projects +GET /engineering/projects +GET /engineering/projects/{id} +PATCH /engineering/projects/{id} + +POST /engineering/projects/{id}/activate +POST /engineering/projects/{id}/close +``` + +### Milestone 4: Collaboration + +```http +POST /engineering/projects/{id}/members +GET /engineering/projects/{id}/members + +POST /engineering/tasks +GET /engineering/tasks +POST /engineering/tasks/{id}/complete +``` + +### Milestone 5: Sites and Documents + +Build: + +```text +engineering sites +signed file uploads +project document links +``` + +### Milestone 6: Designs and Inspections + +Build: + +```text +designs +reviews +approvals +inspections +findings +``` + +### Milestone 7: Commercial Workflows + +Build: + +```text +time entries +invoices +payments +reports +``` + +--- + +## 94. Architecture Rules to Freeze + +1. REST is the primary frontend and integration API. +2. Base path is `/api/v1`. +3. GraphQL is not part of v1. +4. One shared backend platform. +5. Separate frontend application for each profession. +6. Each profession owns its database tables. +7. Shared modules contain infrastructure, not profession-specific semantics. +8. Every tenant-owned row has `organization_id`. +9. Tenant isolation is enforced by both application and database. +10. Authorization is always server-side. +11. Roles and professional credentials are different concepts. +12. Profession-specific permissions use namespaces. +13. PostgreSQL is the source of truth. +14. Files live in object storage. +15. Important business transitions use explicit REST command endpoints. +16. Important mutations are audited. +17. Domain events use a transactional outbox. +18. Background side effects run through workers. +19. Start as a modular monolith. +20. Do not start with microservices. +21. Do not replace proper domain modeling with JSON blobs. +22. Do not hard-delete important professional or financial records without explicit retention rules. +23. Database models and public API DTOs are separate contracts. +24. The frontend never determines authorization. +25. Healthcare receives stricter security treatment than ordinary CRM data. +26. Engineering is the first implemented vertical. +27. Legal follows after engineering validates the platform. +28. Healthcare follows after security and compliance requirements are explicitly designed. +29. OpenAPI is the source of truth for the REST contract. +30. Reconsider GraphQL only if a demonstrated client-composition problem justifies the added complexity. + +--- + +## 95. Required Design Artifacts + +Prepare and maintain: + +```text +01_PROJECT_ARCHITECTURE.md +02_DATABASE_CONVENTIONS.md +03_AUTHORIZATION_MODEL.md +04_ENGINEERING_DOMAIN.md +05_ENGINEERING_DATABASE_SCHEMA.md +06_API_CONVENTIONS.md +07_ENGINEERING_API_SPEC.md +08_FRONTEND_ARCHITECTURE.md +09_SECURITY_MODEL.md +10_DEPLOYMENT_ARCHITECTURE.md +11_TESTING_STRATEGY.md +12_MVP_BACKLOG.md +13_OPENAPI.yaml +``` + +--- + +## 96. Recommended Implementation Order + +```text +Foundation + ↓ +Authentication + ↓ +Organizations + ↓ +Memberships + ↓ +RBAC + ↓ +Engineering Clients + ↓ +Engineering Projects + ↓ +Project Team + ↓ +Tasks + ↓ +Sites + ↓ +Documents + ↓ +Designs + ↓ +Inspections + ↓ +Time Tracking + ↓ +Billing + ↓ +Notifications + ↓ +Reports + ↓ +Legal Vertical + ↓ +Healthcare Vertical +``` + +--- + +## 97. Final Design Position + +The platform is not a generic management system with profession names painted on top. + +It is: + +```text +One Shared Platform + │ + ├── Shared Identity + ├── Shared Security + ├── Shared Infrastructure + ├── Shared Documents + ├── Shared Financial Core + ├── Shared Audit/Event Platform + │ + ├── Engineering Product + │ ├── Engineering Frontend + │ ├── Engineering REST APIs + │ └── Engineering Tables + │ + ├── Legal Product + │ ├── Legal Frontend + │ ├── Legal REST APIs + │ └── Legal Tables + │ + └── Healthcare Product + ├── Healthcare Frontend + ├── Healthcare REST APIs + └── Healthcare Tables +``` + +The system shares infrastructure where reuse is valuable while preserving independent domain models where professional workflows differ. diff --git a/professional_management_platform_rest_plan_v2.md b/professional_management_platform_rest_plan_v2.md new file mode 100644 index 0000000..4709e56 --- /dev/null +++ b/professional_management_platform_rest_plan_v2.md @@ -0,0 +1,4581 @@ +# Professional Management Platform +## Full REST-First System Design Plan + +> **Revision:** v2 — Review-integrated architecture +> **Status:** Implementation-ready baseline, not a claim of regulatory or production certification. +> **Primary vertical:** Engineering +> **API style:** REST + JSON + OpenAPI 3.1 +> **Backend style:** Modular monolith +> **Data:** PostgreSQL + profession-specific tables +> **Tenant model:** Organization-scoped, explicit tenant context + +### Review Integration Notes + +The external review was incorporated selectively rather than mechanically. + +Accepted and strengthened: + +- explicit session management and refresh-token rotation +- token reuse detection and revocation +- idempotency for high-risk commands +- richer engineering design and inspection state machines +- legal conflict-check workflow +- healthcare record history and access auditing +- standardized error taxonomy +- permission matrices and credential-aware authorization +- layered caching with explicit invalidation +- database index conventions +- rate-limiting framework +- CI/CD, migration compatibility, and deployment gates +- OpenAPI contract validation +- performance and security test categories + +Adjusted rather than copied: + +- tenant-scoped endpoints **always require `X-Organization-Id`**; no silent auto-selection +- no extra `X-Organization-Context` header +- idempotency is not mandatory for every PATCH; it is required for commands where duplicate execution is dangerous +- critical idempotency records are durable in PostgreSQL; Redis may accelerate lookups but is not the sole source of truth +- UUIDv7 remains the identifier standard; UUIDv4 `gen_random_uuid()` examples are not adopted +- high-risk credential checks are revalidated against authoritative data rather than trusting a stale cache +- healthcare prescribing rules are jurisdiction-specific and are not hard-coded to a single profession or U.S.-only credential +- signed clinical records are amended/versioned rather than casually overwritten by PATCH +- read replicas, materialized views, trigram indexes, and fixed rate-limit numbers are introduced only when workload evidence justifies them +- database migrations use expand/contract compatibility; production rollback is not assumed to be a simple reverse migration + + +## 1. Product Vision + +Build one shared backend platform that powers multiple profession-specific management applications. + +Initial professions: + +- Engineering +- Legal +- Healthcare + +Future professions may include accounting, architecture, consulting, property management, veterinary practices, financial advisory, and other regulated or professional-service industries. + +The core design principle is: + +> **Share infrastructure, not domain meaning.** + +Engineering projects, legal matters, and medical patients are fundamentally different concepts and should not be forced into the same universal business table. + +--- + +## 2. Core Architecture Decision + +The platform will use: + +- REST +- JSON +- OpenAPI +- Versioned endpoints +- PostgreSQL +- Modular monolith backend +- Profession-specific frontends +- Profession-specific database tables +- Shared identity, security, billing, documents, audit, and infrastructure + +Base API path: + +```text +/api/v1 +``` + +GraphQL is not part of v1. + +--- + +## 3. High-Level Architecture + +```text + FRONTENDS + + ┌──────────────────┼──────────────────┐ + │ │ │ + Engineering Web Legal Web Healthcare Web + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ + REST API + /api/v1 + │ + ┌───────────┼───────────┐ + │ │ │ + Core Engineering Legal + │ │ │ + │ Healthcare │ + │ │ │ + └───────────┼───────────┘ + │ + PostgreSQL + │ + ┌───────────────┼────────────────┐ + │ │ │ + Shared Tables Profession Tables Audit/Event Tables +``` + +Shared infrastructure: + +```text +PostgreSQL +Redis +Object Storage +Queue / Workers +Audit +Notifications +Billing +Observability +``` + +--- + +## 4. System Architecture Strategy + +Start with a modular monolith. + +Do not start with microservices. + +Initial deployment: + +```text +Frontend Apps + │ + ▼ +Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Worker Queue +``` + +Benefits: + +- simpler transactions +- easier development +- easier deployment +- clearer domain boundaries +- lower operational burden +- easier refactoring +- future service extraction remains possible + +--- + +## 5. Repository Structure + +Recommended monorepo: + +```text +professional-platform/ +│ +├── apps/ +│ ├── engineering-web/ +│ ├── legal-web/ +│ ├── healthcare-web/ +│ ├── platform-admin/ +│ ├── api/ +│ └── workers/ +│ +├── packages/ +│ ├── ui/ +│ ├── api-client/ +│ ├── auth-client/ +│ ├── validation/ +│ ├── types/ +│ ├── config/ +│ └── testing/ +│ +├── database/ +│ ├── migrations/ +│ ├── seeds/ +│ └── scripts/ +│ +├── infrastructure/ +│ ├── docker/ +│ ├── deployment/ +│ └── monitoring/ +│ +└── docs/ + ├── architecture/ + ├── api/ + ├── security/ + └── domains/ +``` + +--- + +## 6. Frontend Strategy + +Every profession receives its own frontend application. + +Avoid one giant frontend filled with profession checks. + +### Engineering Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Projects +Project Phases +Project Team +Sites +Designs +Design Reviews +Inspections +Specifications +Tasks +Documents +Timesheets +Billing +Reports +Administration +``` + +### Legal Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Matters +Cases +Hearings +Courts +Deadlines +Documents +Conflict Checks +Time Tracking +Retainers +Billing +Reports +Administration +``` + +### Healthcare Frontend + +Suggested navigation: + +```text +Dashboard +Patients +Appointments +Practitioners +Encounters +Clinical Records +Diagnoses +Prescriptions +Insurance +Documents +Billing +Reports +Administration +``` + +### Platform Admin Frontend + +Suggested functions: + +```text +Organizations +Users +Profession Modules +Subscriptions +System Health +Audit +Support +Global Configuration +``` + +Platform administrators and organization administrators are separate concepts. + +--- + +## 7. REST API Structure + +Shared endpoints: + +```text +/api/v1/auth +/api/v1/me +/api/v1/organizations +/api/v1/memberships +/api/v1/membership-invitations +/api/v1/roles +/api/v1/permissions +/api/v1/documents +/api/v1/invoices +/api/v1/payments +/api/v1/audit-events +``` + +Engineering: + +```text +/api/v1/engineering/clients +/api/v1/engineering/projects +/api/v1/engineering/project-members +/api/v1/engineering/phases +/api/v1/engineering/sites +/api/v1/engineering/tasks +/api/v1/engineering/designs +/api/v1/engineering/inspections +/api/v1/engineering/specifications +/api/v1/engineering/time-entries +``` + +Legal: + +```text +/api/v1/legal/clients +/api/v1/legal/matters +/api/v1/legal/cases +/api/v1/legal/hearings +/api/v1/legal/deadlines +/api/v1/legal/conflict-checks +/api/v1/legal/retainers +/api/v1/legal/time-entries +``` + +Healthcare: + +```text +/api/v1/healthcare/patients +/api/v1/healthcare/practitioners +/api/v1/healthcare/appointments +/api/v1/healthcare/encounters +/api/v1/healthcare/clinical-records +/api/v1/healthcare/diagnoses +/api/v1/healthcare/prescriptions +/api/v1/healthcare/insurance +``` + +--- + +## 8. REST Conventions + +All APIs use JSON over HTTPS. + +Typical tenant-scoped request: + +```http +Authorization: Bearer +X-Organization-Id: org_123 +X-Request-Id: req_123 +Content-Type: application/json +``` + +### Organization Context + +`X-Organization-Id` is mandatory for every tenant-scoped endpoint. + +It is deliberately explicit even when a user currently belongs to only one organization. Silent organization selection creates ambiguous clients and becomes dangerous the moment the user later joins a second organization. + +Global endpoints such as these do not require tenant context: + +```http +POST /api/v1/auth/login +POST /api/v1/auth/token/refresh +GET /api/v1/me +GET /api/v1/me/organizations +GET /api/v1/auth/sessions +``` + +Tenant-context resolution rules: + +```yaml +Organization Context: + header_missing_on_tenant_endpoint: + status: 400 + code: ORGANIZATION_CONTEXT_REQUIRED + + organization_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + + membership_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + + membership_inactive: + status: 403 + code: AUTHZ_MEMBERSHIP_INACTIVE + + organization_inactive: + status: 403 + code: AUTHZ_ORGANIZATION_INACTIVE + + resource.organization_id_mismatch: + status: 404 + code: RESOURCE_NOT_FOUND +``` + +Do not return another organization's name or membership details in tenant-error responses. + +### Idempotency + +Use: + +```http +Idempotency-Key: 8f7d6c5e-4b3a-2b1c-9d8e-7f6a5b4c3d2e +``` + +Idempotency is required for commands where duplicate execution can create financial, regulated, external, or otherwise material side effects. + +Examples: + +```http +POST /api/v1/invoices +POST /api/v1/invoices/{id}/payments +POST /api/v1/payments/{id}/refund + +POST /api/v1/engineering/designs/{id}/approve +POST /api/v1/engineering/inspections/{id}/complete + +POST /api/v1/legal/retainers +POST /api/v1/legal/conflict-checks/{id}/approve + +POST /api/v1/healthcare/prescriptions +POST /api/v1/healthcare/clinical-records/{id}/sign +``` + +Do not require idempotency on every ordinary PATCH by default. + +Idempotency records must include: + +```text +organization_id +actor_id +route/action +idempotency_key +canonical_request_hash +response_status +response_body or resource reference +created_at +expires_at +``` + +Rules: + +```yaml +Same key + same operation + same request hash: + return: original result + +Same key + different request hash: + status: 409 + code: IDEMPOTENCY_KEY_CONFLICT +``` + +Durability: + +- PostgreSQL is authoritative for critical idempotency records. +- Redis may cache recent records for speed. +- Redis eviction must not make a payment or regulated command executable twice. +- Downstream providers should receive their own idempotency key where supported. + +## 9. Standard Response Format + +Single resource: + +```json +{ + "data": { + "id": "project_123", + "name": "Central Tower" + } +} +``` + +Collection: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Standard error: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "req_123" + } +} +``` + +Clients depend on `error.code`, not message text. + +### Error Taxonomy + +Authentication: + +```text +AUTH_INVALID_CREDENTIALS +AUTH_TOKEN_EXPIRED +AUTH_TOKEN_INVALID +AUTH_MFA_REQUIRED +AUTH_SESSION_REVOKED +AUTH_REFRESH_TOKEN_REUSED +``` + +Authorization: + +```text +AUTHZ_PERMISSION_DENIED +AUTHZ_ORGANIZATION_INACTIVE +AUTHZ_MEMBERSHIP_INACTIVE +AUTHZ_CREDENTIAL_INVALID +AUTHZ_SCOPE_MISMATCH +``` + +Tenant context: + +```text +ORGANIZATION_CONTEXT_REQUIRED +``` + +Resource/state: + +```text +RESOURCE_NOT_FOUND +RESOURCE_ALREADY_EXISTS +RESOURCE_CONCURRENT_MODIFICATION +RESOURCE_INVALID_STATE +RESOURCE_ARCHIVED +``` + +Validation: + +```text +VALIDATION_ERROR +VALIDATION_REQUIRED_FIELD +VALIDATION_INVALID_FORMAT +VALIDATION_BUSINESS_RULE +``` + +Idempotency: + +```text +IDEMPOTENCY_KEY_REQUIRED +IDEMPOTENCY_KEY_CONFLICT +``` + +Rate limiting: + +```text +RATE_LIMIT_EXCEEDED +``` + +System/dependency: + +```text +INTERNAL_ERROR +SERVICE_UNAVAILABLE +DATABASE_UNAVAILABLE +DEPENDENCY_FAILED +``` + +Validation example: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed.", + "requestId": "req_123", + "details": { + "fields": [ + { + "field": "email", + "code": "INVALID_FORMAT", + "message": "Must be a valid email address" + } + ] + } + } +} +``` + +Business-state example: + +```json +{ + "error": { + "code": "RESOURCE_INVALID_STATE", + "message": "Cannot approve design in current state.", + "requestId": "req_123", + "details": { + "resourceType": "engineering_design", + "resourceId": "design_123", + "currentState": "draft", + "requiredState": "under_review", + "allowedActions": [ + "submit_review" + ] + } + } +} +``` + +Do not expose internal stack traces, SQL, policy internals, secrets, or cross-tenant information. + +## 10. HTTP Status Rules + +```text +200 Success +201 Created +202 Accepted +204 No Content +400 Bad Request +401 Unauthorized +403 Forbidden +404 Not Found +409 Conflict +422 Validation Error +429 Too Many Requests +500 Internal Server Error +``` + +Cross-tenant resource access should return 404. + +--- + +## 11. API Versioning + +Current API: + +```text +/api/v1 +``` + +Breaking changes require: + +```text +/api/v2 +``` + +Additive fields generally do not require a new version. + +--- + +## 12. Authentication + +Initial authentication: + +```text +Email ++ +Password ++ +Access Token ++ +Refresh Token ++ +Server-side Session +``` + +Endpoints: + +```http +POST /api/v1/auth/register +POST /api/v1/auth/login + +POST /api/v1/auth/token/refresh +POST /api/v1/auth/token/revoke +POST /api/v1/auth/token/revoke-all + +GET /api/v1/auth/sessions +DELETE /api/v1/auth/sessions/{sessionId} + +GET /api/v1/me +``` + +### Access Token Policy + +```yaml +Access Token: + lifetime: 15 minutes + format: signed JWT + encrypted: false + preferred_signing: asymmetric key or managed signing service + claims: + - subject/userId + - sessionId + - issuer + - audience + - issuedAt + - expiresAt + organizationId: + optional: true + authority: false +``` + +If `organizationId` appears in the token, it is a convenience hint only. The request header and active membership still determine tenant context. + +Do not embed the user's full permission set into long-lived access tokens. + +### Refresh Token Policy + +```yaml +Refresh Token: + lifetime: 7 days + format: cryptographically random opaque token + storage: hashed + rotation: every successful refresh + sliding_expiration: configurable + reuse_detection: required +``` + +If a previously rotated refresh token is reused: + +1. treat it as possible token theft +2. revoke the token family/session +3. optionally revoke all user sessions according to risk policy +4. generate a security audit event +5. require reauthentication + +### Session Model + +Suggested fields: + +```text +id +user_id +refresh_token_family_id +refresh_token_hash +device_id +device_type +device_os +app_version +ip_address +user_agent +created_at +last_active_at +expires_at +revoked_at +revocation_reason +``` + +Example response: + +```json +{ + "data": { + "id": "sess_123", + "userId": "user_456", + "deviceInfo": { + "type": "mobile", + "os": "iOS", + "appVersion": "2.1.0", + "deviceId": "device_789" + }, + "createdAt": "2026-08-20T12:00:00Z", + "lastActiveAt": "2026-08-26T01:30:00Z", + "expiresAt": "2026-08-27T12:00:00Z", + "isActive": true + } +} +``` + +Do not trust user-supplied device metadata as security proof. It is session context and audit information. + +Future authentication capabilities: + +- MFA +- passkeys/WebAuthn +- SSO +- OAuth/OIDC +- enterprise identity providers +- risk-based authentication + +## 13. Shared Core Backend + +Recommended modules: + +```text +core/ +├── auth/ +├── users/ +├── organizations/ +├── memberships/ +├── roles/ +├── permissions/ +├── authorization/ +├── documents/ +├── billing/ +├── notifications/ +├── audit/ +└── events/ +``` + +Dependency rule: + +```text +Profession module → Core +``` + +Never: + +```text +Core → Profession module +``` + +--- + +## 14. Organizations + +Organizations are tenants. + +Examples: + +```text +Atlas Structural Engineering +Smith & Associates Law +North Shore Medical Practice +``` + +Suggested fields: + +```text +id +name +slug +status +country_code +timezone +currency_code +created_at +updated_at +``` + +--- + +## 15. Profession Enablement + +Use: + +```text +organization_professions +``` + +Suggested fields: + +```text +organization_id +profession +enabled_at +configuration +``` + +Possible professions: + +```text +engineering +legal +healthcare +``` + +An organization may eventually enable more than one profession module. + +--- + +## 16. Users and Memberships + +Users are global identities. + +A user gains tenant access through membership. + +```text +User + │ + ▼ +Membership + │ + ▼ +Organization +``` + +Suggested `users` fields: + +```text +id +email +first_name +last_name +phone +avatar_url +status +created_at +updated_at +``` + +Suggested `memberships` fields: + +```text +id +organization_id +user_id +status +joined_at +created_at +updated_at +``` + +--- + +## 17. Membership Invitations + +Keep invitations separate from memberships. + +Suggested table: + +```text +membership_invitations +``` + +Fields: + +```text +id +organization_id +email +invited_by_user_id +expires_at +accepted_at +revoked_at +created_at +``` + +Flow: + +```text +Invitation + ↓ +Accepted + ↓ +User + ↓ +Membership +``` + +--- + +## 18. Authorization + +Use: + +```text +RBAC ++ +Permission Scope ++ +Resource Policies ++ +Professional Qualification Policies ++ +Domain State Rules +``` + +Decision flow: + +```text +Authenticated User + ↓ +Explicit Organization Context + ↓ +Active Membership + ↓ +Enabled Profession Module + ↓ +Roles + ↓ +Permissions + ↓ +Permission Scope + ↓ +Tenant-scoped Resource Query + ↓ +Resource Policy + ↓ +Credential/Jurisdiction Policy + ↓ +Domain State Rule + ↓ +ALLOW / DENY +``` + +Default decision: + +```text +DENY +``` + +Authorization rules: + +1. Controllers never perform ad-hoc role comparisons. +2. Tenant resource queries always include `organization_id`. +3. Do not load an arbitrary resource first and then discover it belongs to another tenant. +4. High-risk professional actions perform credential checks at command execution time. +5. A permission grants the ability to attempt an action, not a guarantee the domain state allows it. +6. Cross-tenant resources appear nonexistent. +7. Profession module enablement is checked before profession-specific authorization. + +## 19. Roles and Permissions + +Roles are organization-scoped collections of permissions. + +Example roles: + +```text +Owner +Administrator +Project Manager +Engineer +Reviewer +Inspector +Lawyer +Paralegal +Doctor +Nurse +Billing Manager +Viewer +``` + +Roles are not professional credentials. + +### Engineering Permissions + +```text +engineering.clients.read +engineering.clients.create +engineering.clients.update +engineering.clients.archive + +engineering.projects.read +engineering.projects.create +engineering.projects.update +engineering.projects.activate +engineering.projects.close +engineering.projects.archive + +engineering.project_members.manage +engineering.phases.manage +engineering.tasks.manage +engineering.sites.manage + +engineering.documents.read +engineering.documents.upload +engineering.documents.delete + +engineering.designs.read +engineering.designs.create +engineering.designs.update +engineering.designs.review +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede + +engineering.inspections.read +engineering.inspections.manage +engineering.inspections.complete + +engineering.time_entries.manage +engineering.reports.read +``` + +### Legal Permissions + +```text +legal.clients.read +legal.clients.create +legal.clients.update + +legal.matters.read +legal.matters.create +legal.matters.update +legal.matters.close +legal.matters.reopen + +legal.cases.read +legal.cases.manage +legal.hearings.manage +legal.deadlines.manage + +legal.documents.read +legal.documents.upload + +legal.conflicts.manage +legal.conflicts.approve + +legal.retainers.manage +legal.time_entries.manage +``` + +### Healthcare Permissions + +```text +healthcare.patients.read +healthcare.patients.create +healthcare.patients.update + +healthcare.appointments.read +healthcare.appointments.manage + +healthcare.encounters.read +healthcare.encounters.manage + +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.records.access_log.read + +healthcare.prescriptions.read +healthcare.prescriptions.write +healthcare.prescriptions.sign + +healthcare.insurance.read +healthcare.insurance.manage +``` + +### Shared Permissions + +```text +documents.read +documents.upload + +billing.read +invoices.create +invoices.issue +invoices.void +payments.record +payments.refund + +members.read +members.invite +members.update +members.remove + +roles.read +roles.manage + +audit.read +``` + +Avoid vague permissions such as `admin_everything` in normal tenant RBAC. + +## 20. Permission Scopes + +Initial scopes: + +```text +assigned +organization +``` + +Examples: + +```text +Engineer: +engineering.projects.read = assigned + +Principal Engineer: +engineering.projects.read = organization +``` + +Potential future scopes: + +```text +owned +team +department +restricted +``` + +Do not implement until required. + +--- + +## 21. Professional Credentials + +Professional qualification is separate from RBAC. + +Suggested shared profile: + +```text +professional_profiles +``` + +Fields: + +```text +id +organization_id +user_id +profession +title +credential_status +primary_license_number +primary_license_jurisdiction +valid_from +expires_at +created_at +updated_at +``` + +Profession modules may add dedicated credential tables when one generic profile is insufficient. + +### Credential Policy Examples + +Engineering design approval may require: + +```yaml +permission: engineering.designs.approve +credential: + profession_family: engineering + status: verified + active_license: true + jurisdiction_match: when required + discipline_match: when required +``` + +Healthcare record signing may require: + +```yaml +permission: healthcare.records.sign +credential: + profession_allowed_by_policy: true + status: verified + active_license: true + scope_of_practice_allows_action: true + jurisdiction_match: true +``` + +Prescribing must **not** be hard-coded to `profession = doctor` or to a single U.S. credential such as a DEA number. + +Prescribing authority varies by: + +- jurisdiction +- profession +- drug class +- supervising relationship +- organization policy +- credential status + +Therefore use a policy concept such as: + +```text +PrescribingAuthorityPolicy +``` + +rather than a permanent global rule. + +### Cache Safety + +Credential status may be cached briefly for ordinary reads, but high-risk writes such as: + +```text +engineering.designs.approve +healthcare.records.sign +healthcare.prescriptions.sign +``` + +must use authoritative or revocation-aware credential validation. A five-minute stale cache is unacceptable if a license was just suspended. + +## 22. Database Architecture + +Use PostgreSQL. + +Start with: + +```text +One database ++ +Shared schema ++ +Profession-specific tables +``` + +Do not begin with database-per-profession or database-per-customer unless compliance or residency requirements force that choice. + +--- + +## 23. Shared Tables + +Recommended shared tables: + +```text +organizations +organization_professions + +users +user_credentials +sessions + +memberships +membership_invitations + +roles +permissions +role_permissions +membership_roles + +professional_profiles + +documents +document_versions + +invoices +invoice_items +payments + +notifications +notification_deliveries + +audit_events +outbox_events +``` + +--- + +## 24. Multi-Tenancy Rule + +Every tenant-owned row must contain: + +```text +organization_id +``` + +Examples: + +```text +engineering_projects.organization_id +legal_matters.organization_id +healthcare_patients.organization_id +``` + +Enforce tenant boundaries at: + +- API layer +- authorization layer +- repository/query layer +- database constraints + +--- + +## 25. Tenant-Safe Foreign Keys + +Use composite tenant-aware foreign keys when possible. + +Example: + +```text +engineering_projects +organization_id +client_id +``` + +references: + +```text +engineering_clients +organization_id +id +``` + +This prevents linking a resource from one organization to another organization's data. + +--- + +# Engineering Domain + +## 26. Engineering Tables + +Initial tables: + +```text +engineering_clients +engineering_projects +engineering_project_members +engineering_project_phases +engineering_sites +engineering_tasks +engineering_designs +engineering_design_versions +engineering_design_reviews +engineering_inspections +engineering_inspection_findings +engineering_specifications +engineering_change_requests +engineering_time_entries +``` + +--- + +## 27. Engineering Clients + +Suggested fields: + +```text +id +organization_id +client_type +display_name +legal_name +contact_name +email +phone +address_line_1 +address_line_2 +city +region +postal_code +country_code +status +created_at +updated_at +version +``` + +REST: + +```http +GET /api/v1/engineering/clients +POST /api/v1/engineering/clients +GET /api/v1/engineering/clients/{clientId} +PATCH /api/v1/engineering/clients/{clientId} + +POST /api/v1/engineering/clients/{clientId}/archive +POST /api/v1/engineering/clients/{clientId}/restore + +GET /api/v1/engineering/clients/{clientId}/projects +GET /api/v1/engineering/clients/{clientId}/invoices +``` + +A client may only be restored if retention and organization policy permit it. + +Example response: + +```json +{ + "data": { + "id": "client_123", + "organizationId": "org_456", + "clientType": "commercial", + "displayName": "Riverside Development Corp", + "legalName": "Riverside Development Corporation LLC", + "contactName": "Jane Williams", + "email": "jwilliams@example.com", + "phone": "+15125551234", + "address": { + "line1": "789 Riverside Dr", + "city": "Austin", + "region": "TX", + "postalCode": "78701", + "country": "US" + }, + "status": "active", + "version": 1, + "createdAt": "2026-08-20T10:00:00Z", + "updatedAt": "2026-08-26T01:00:00Z" + } +} +``` + +## 28. Engineering Projects + +Suggested fields: + +```text +id +organization_id +client_id +project_number +name +description +discipline +stage +status +project_manager_user_id +start_date +expected_completion_date +completed_date +budget_minor +currency_code +created_at +updated_at +version +``` + +REST: + +```http +GET /api/v1/engineering/projects +POST /api/v1/engineering/projects +GET /api/v1/engineering/projects/{projectId} +PATCH /api/v1/engineering/projects/{projectId} + +POST /api/v1/engineering/projects/{projectId}/activate +POST /api/v1/engineering/projects/{projectId}/close +POST /api/v1/engineering/projects/{projectId}/archive +``` + +Purpose-built read models may be added when the frontend requires them: + +```http +GET /api/v1/engineering/projects/{projectId}/summary +GET /api/v1/engineering/projects/{projectId}/timeline +GET /api/v1/engineering/projects/{projectId}/budget +``` + +These are read-model endpoints, not necessarily separate aggregate tables. + +Do not put arbitrary budget-breakdown JSON into the core project row merely because the response can display it. Model detailed budget data in dedicated tables when that feature is implemented. + +## 29. Engineering Project Members + +Suggested fields: + +```text +id +organization_id +project_id +user_id +project_role +joined_at +left_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/members +POST /api/v1/engineering/projects/{projectId}/members +PATCH /api/v1/engineering/projects/{projectId}/members/{memberId} +DELETE /api/v1/engineering/projects/{projectId}/members/{memberId} +``` + +--- + +## 30. Engineering Project Phases + +Suggested fields: + +```text +id +organization_id +project_id +name +sequence +status +start_date +end_date +created_at +updated_at +``` + +Typical phases: + +```text +Concept +Preliminary Design +Detailed Design +Construction +Inspection +Closeout +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/phases +POST /api/v1/engineering/projects/{projectId}/phases +PATCH /api/v1/engineering/projects/{projectId}/phases/{phaseId} +POST /api/v1/engineering/projects/{projectId}/phases/{phaseId}/complete +``` + +--- + +## 31. Engineering Sites + +Suggested fields: + +```text +id +organization_id +project_id +name +address +latitude +longitude +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/sites/{siteId} +PATCH /api/v1/engineering/sites/{siteId} +``` + +--- + +## 32. Engineering Tasks + +Suggested fields: + +```text +id +organization_id +project_id +title +description +status +priority +created_by_user_id +assigned_to_user_id +due_at +completed_at +created_at +updated_at +version +``` + +REST: + +```http +POST /api/v1/engineering/tasks +GET /api/v1/engineering/tasks +GET /api/v1/engineering/tasks/{taskId} +PATCH /api/v1/engineering/tasks/{taskId} + +POST /api/v1/engineering/tasks/{taskId}/complete +POST /api/v1/engineering/tasks/{taskId}/reopen +POST /api/v1/engineering/tasks/{taskId}/cancel +``` + +--- + +## 33. Engineering Designs + +Suggested fields: + +```text +id +organization_id +project_id +design_number +title +description +discipline +status +owner_user_id +prepared_by_user_id +approved_by_user_id +approved_at +created_at +updated_at +version +``` + +States: + +```text +draft +under_review +changes_requested +approved +rejected +superseded +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/designs +POST /api/v1/engineering/projects/{projectId}/designs + +GET /api/v1/engineering/designs/{designId} +PATCH /api/v1/engineering/designs/{designId} + +POST /api/v1/engineering/designs/{designId}/submit-review +POST /api/v1/engineering/designs/{designId}/request-changes +POST /api/v1/engineering/designs/{designId}/approve +POST /api/v1/engineering/designs/{designId}/reject +POST /api/v1/engineering/designs/{designId}/supersede + +GET /api/v1/engineering/designs/{designId}/versions +POST /api/v1/engineering/designs/{designId}/versions + +GET /api/v1/engineering/designs/{designId}/reviews +POST /api/v1/engineering/designs/{designId}/reviews +``` + +### Design State Machine + +```text +draft + └── submit-review ───────────────► under_review + +under_review + ├── request-changes ─────────────► changes_requested + ├── approve ─────────────────────► approved + └── reject ──────────────────────► rejected + +changes_requested + └── submit-review ───────────────► under_review + +approved + └── supersede ───────────────────► superseded + +rejected + └── revise ──────────────────────► draft +``` + +Authorization policy examples: + +```yaml +submit_review: + permissions: + - engineering.designs.review + resource_policy: + - design owner OR project manager + +request_changes: + permissions: + - engineering.designs.review + +approve: + permissions: + - engineering.designs.approve + requires: + - project access + - valid professional qualification + - valid design state + - organization approval policy + +reject: + permissions: + - engineering.designs.reject + +supersede: + permissions: + - engineering.designs.supersede +``` + +Design approval must be audited and idempotent. + +Do not approve by generic PATCH of `status`. + +## 34. Design Versions and Reviews + +`engineering_design_versions`: + +```text +id +design_id +version_number +document_id +created_by_user_id +created_at +``` + +`engineering_design_reviews`: + +```text +id +organization_id +design_id +reviewer_user_id +status +comments +reviewed_at +``` + +Possible review statuses: + +```text +pending +approved +changes_requested +rejected +``` + +--- + +## 35. Engineering Inspections + +Suggested fields: + +```text +id +organization_id +project_id +site_id +inspection_type +inspector_user_id +status +scheduled_at +started_at +performed_at +cancelled_at +summary +created_at +updated_at +version +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/inspections +POST /api/v1/engineering/projects/{projectId}/inspections + +GET /api/v1/engineering/inspections/{inspectionId} +PATCH /api/v1/engineering/inspections/{inspectionId} + +POST /api/v1/engineering/inspections/{inspectionId}/schedule +POST /api/v1/engineering/inspections/{inspectionId}/start +POST /api/v1/engineering/inspections/{inspectionId}/complete +POST /api/v1/engineering/inspections/{inspectionId}/cancel + +GET /api/v1/engineering/inspections/{inspectionId}/findings +POST /api/v1/engineering/inspections/{inspectionId}/findings +``` + +Example state model: + +```text +draft + └── schedule ─────► scheduled +scheduled + ├── start ────────► in_progress + └── cancel ───────► cancelled +in_progress + ├── complete ─────► completed + └── cancel ───────► cancelled +``` + +Inspection completion is a domain command that should: + +1. validate inspector/project access +2. validate required findings/fields +3. update state +4. write audit event +5. write outbox event +6. trigger follow-up workflows if required + +## 36. Inspection Findings + +Suggested fields: + +```text +id +inspection_id +severity +description +status +resolved_at +``` + +Possible severities: + +```text +observation +minor +major +critical +``` + +REST: + +```http +POST /api/v1/engineering/inspections/{inspectionId}/findings +PATCH /api/v1/engineering/inspection-findings/{findingId} +POST /api/v1/engineering/inspection-findings/{findingId}/resolve +``` + +--- + +## 37. Engineering Specifications + +Suggested fields: + +```text +id +organization_id +project_id +specification_number +title +version +status +document_id +created_at +updated_at +``` + +--- + +## 38. Engineering Change Requests + +Suggested fields: + +```text +id +organization_id +project_id +request_number +title +description +status +requested_by_user_id +approved_by_user_id +estimated_cost_minor +created_at +updated_at +``` + +--- + +## 39. Engineering Time Entries + +Suggested fields: + +```text +id +organization_id +project_id +user_id +work_date +duration_minutes +description +billable +billing_rate_minor +currency_code +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries/{id} +PATCH /api/v1/engineering/time-entries/{id} +``` + +Store duration as integer minutes. + +--- + +# Legal Domain + +## 40. Legal Tables + +Initial tables: + +```text +legal_clients +legal_matters +legal_matter_members +legal_cases +legal_case_parties +legal_courts +legal_hearings +legal_deadlines +legal_documents +legal_time_entries +legal_retainers +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +REST namespace: + +```text +/api/v1/legal +``` + +Core examples: + +```http +GET /api/v1/legal/matters +POST /api/v1/legal/matters +GET /api/v1/legal/matters/{matterId} +PATCH /api/v1/legal/matters/{matterId} +POST /api/v1/legal/matters/{matterId}/close +POST /api/v1/legal/matters/{matterId}/reopen + +GET /api/v1/legal/matters/{matterId}/cases +GET /api/v1/legal/matters/{matterId}/documents +GET /api/v1/legal/matters/{matterId}/time-entries +GET /api/v1/legal/matters/{matterId}/invoices + +POST /api/v1/legal/conflict-checks +GET /api/v1/legal/conflict-checks/{conflictCheckId} +POST /api/v1/legal/conflict-checks/{conflictCheckId}/approve +POST /api/v1/legal/conflict-checks/{conflictCheckId}/decline +``` + +Legal remains a later vertical. These endpoints define intended boundaries, not a P0 build commitment. + +## 41. Legal Matters + +Suggested fields: + +```text +id +organization_id +client_id +matter_number +title +practice_area +responsible_lawyer_user_id +status +opened_date +closed_date +created_at +updated_at +``` + +--- + +## 42. Legal Cases + +Suggested fields: + +```text +id +organization_id +matter_id +case_number +court_id +jurisdiction +case_type +status +filed_date +created_at +updated_at +``` + +--- + +## 43. Legal Hearings + +Suggested fields: + +```text +id +organization_id +case_id +hearing_type +scheduled_at +courtroom +judge +status +notes +``` + +--- + +## 44. Legal Conflict Checks + +Suggested tables: + +```text +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +Conflict-check fields: + +```text +id +organization_id +potential_client_name +matter_description +requested_by_user_id +reviewed_by_user_id +status +decision +decision_reason +created_at +reviewed_at +version +``` + +Request example: + +```json +{ + "potentialClientName": "Acme Corporation", + "relatedParties": [ + { + "name": "John Smith", + "relationship": "CEO" + }, + { + "name": "Acme Subsidiary LLC", + "relationship": "Subsidiary" + } + ], + "matterDescription": "Corporate acquisition" +} +``` + +Response may contain possible matches: + +```json +{ + "data": { + "id": "conflict_123", + "status": "pending_review", + "potentialConflicts": [ + { + "type": "possible_direct_adversity", + "partyName": "Acme Corporation", + "existingMatterId": "matter_456", + "existingMatterNumber": "MAT-2026-089" + } + ] + } +} +``` + +The system should distinguish: + +```text +automated possible match +``` + +from: + +```text +lawyer-approved conflict determination +``` + +The software may assist discovery; it should not silently make the professional judgment. + +Approvals and declines are auditable commands. + +## 45. Healthcare Tables + +Initial tables: + +```text +healthcare_patients +healthcare_patient_contacts +healthcare_patient_addresses +healthcare_practitioners +healthcare_appointments +healthcare_encounters +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +healthcare_diagnoses +healthcare_prescriptions +healthcare_insurance_policies +healthcare_allergies +healthcare_medications +``` + +REST namespace: + +```text +/api/v1/healthcare +``` + +Examples: + +```http +GET /api/v1/healthcare/patients +POST /api/v1/healthcare/patients +GET /api/v1/healthcare/patients/{patientId} +PATCH /api/v1/healthcare/patients/{patientId} +POST /api/v1/healthcare/patients/{patientId}/archive + +GET /api/v1/healthcare/patients/{patientId}/appointments +GET /api/v1/healthcare/patients/{patientId}/encounters +GET /api/v1/healthcare/patients/{patientId}/clinical-records +GET /api/v1/healthcare/patients/{patientId}/prescriptions +GET /api/v1/healthcare/patients/{patientId}/allergies + +POST /api/v1/healthcare/encounters +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend +``` + +Healthcare is intentionally not treated as ordinary CRM plus extra columns. + +## 46. Healthcare Patients + +Core patient fields: + +```text +id +organization_id +patient_number +first_name +middle_name +last_name +date_of_birth +sex_or_administrative_gender_as_required +status +created_at +updated_at +version +``` + +Do not make a single default patient DTO return every available PHI field. + +Use minimum-necessary response shapes. + +Example general patient response: + +```json +{ + "data": { + "id": "patient_123", + "patientNumber": "PAT-2026-001", + "name": { + "firstName": "Alice", + "middleName": "Marie", + "lastName": "Johnson" + }, + "dateOfBirth": "1985-03-15", + "status": "active", + "version": 2 + } +} +``` + +More sensitive subresources should have separate permissions and endpoints where useful: + +```text +contact information +addresses +emergency contacts +insurance policies +clinical records +prescriptions +``` + +Do not return insurance member IDs or emergency contact details on every patient read merely because the database has them. + +## 47. Healthcare Practitioners + +Suggested fields: + +```text +id +organization_id +user_id +specialty +license_number +license_jurisdiction +credential_status +created_at +updated_at +``` + +--- + +## 48. Healthcare Appointments + +Suggested fields: + +```text +id +organization_id +patient_id +practitioner_id +appointment_type +starts_at +ends_at +status +reason +created_at +updated_at +``` + +--- + +## 49. Healthcare Encounters + +Suggested fields: + +```text +id +organization_id +patient_id +practitioner_id +appointment_id +encounter_type +started_at +ended_at +status +``` + +--- + +## 50. Clinical Records + +Suggested tables: + +```text +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +``` + +Core record fields: + +```text +id +organization_id +patient_id +encounter_id +author_practitioner_id +record_type +sensitivity_level +status +signed_by_practitioner_id +signed_at +created_at +updated_at +version +``` + +Draft content may be editable according to workflow. + +Once signed/finalized: + +- do not overwrite history +- create amendments or new versions +- preserve previous signed content +- audit reads when policy requires +- audit all writes/signatures/amendments + +REST: + +```http +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} + +PATCH /api/v1/healthcare/clinical-records/{recordId} +# Only when editable/draft according to policy. + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend + +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log +``` + +Clinical content representation should be designed around actual healthcare requirements and interoperability needs rather than permanently committing to one ad-hoc JSON SOAP-note structure. + +Sensitive record access should support an `accessReason` when organization or regulatory policy requires it. + +## 51. Documents + +Use shared object storage. + +Database: + +```text +documents +document_versions +``` + +Actual binary files: + +```text +S3-compatible Object Storage +``` + +Suggested `documents` fields: + +```text +id +organization_id +name +mime_type +size_bytes +created_by_user_id +created_at +updated_at +``` + +Suggested `document_versions` fields: + +```text +id +document_id +version_number +storage_key +checksum +size_bytes +uploaded_by_user_id +created_at +``` + +--- + +## 52. Document Upload Flow + +```text +Frontend + ↓ +Request upload URL + ↓ +Backend authorizes + ↓ +Signed upload URL + ↓ +Frontend uploads to object storage + ↓ +Backend finalizes document + ↓ +Virus/security scan + ↓ +Document becomes available +``` + +REST: + +```http +POST /api/v1/documents/upload-url +POST /api/v1/documents/{documentId}/complete-upload +GET /api/v1/documents/{documentId} +GET /api/v1/documents/{documentId}/download-url +POST /api/v1/documents/{documentId}/versions +``` + +--- + +## 53. Profession-Specific Document Links + +Use explicit tables where possible. + +Engineering: + +```text +engineering_project_documents +engineering_design_documents +engineering_inspection_documents +``` + +Legal: + +```text +legal_matter_documents +legal_case_documents +``` + +Healthcare: + +```text +healthcare_patient_documents +healthcare_encounter_documents +``` + +This gives stronger foreign-key integrity than generic polymorphic document links. + +--- + +## 54. Billing + +Shared financial core: + +```text +invoices +invoice_items +payments +``` + +Profession-specific modules may extend billing workflows. + +Engineering examples: + +```text +project billing +hourly billing +milestone billing +``` + +Legal examples: + +```text +matter billing +time billing +retainers +trust accounting +``` + +Healthcare examples: + +```text +insurance +claims +patient billing +``` + +REST: + +```http +POST /api/v1/invoices +GET /api/v1/invoices +GET /api/v1/invoices/{invoiceId} +PATCH /api/v1/invoices/{invoiceId} + +POST /api/v1/invoices/{invoiceId}/issue +POST /api/v1/invoices/{invoiceId}/void +POST /api/v1/invoices/{invoiceId}/payments +POST /api/v1/payments/{paymentId}/refund +``` + +--- + +## 55. Money Representation + +Use integer minor units: + +```json +{ + "amountMinor": 12550, + "currency": "USD" +} +``` + +Meaning: + +```text +$125.50 +``` + +Never use floating point for money. + +--- + +## 56. Audit Logging + +Table: + +```text +audit_events +``` + +Suggested fields: + +```text +id +organization_id +actor_type +actor_user_id +actor_service_account_id +action +resource_type +resource_id +request_id +correlation_id +ip_address +user_agent +metadata +occurred_at +``` + +Audit events are append-only. + +### Mandatory Engineering Audit Events + +```text +engineering.projects.create +engineering.projects.close +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede +engineering.inspections.complete +``` + +### Mandatory Legal Audit Events + +```text +legal.matters.create +legal.matters.close +legal.matters.reopen +legal.conflicts.approve +legal.conflicts.decline +legal.retainers.manage +``` + +### Mandatory Healthcare Audit Events + +```text +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.prescriptions.write +healthcare.prescriptions.sign +``` + +Example: + +```json +{ + "id": "audit_123", + "organizationId": "org_456", + "actorUserId": "user_789", + "action": "healthcare.records.read", + "resourceType": "healthcare_clinical_record", + "resourceId": "record_456", + "requestId": "req_abc", + "ipAddress": "192.0.2.10", + "userAgent": "Mozilla/5.0", + "metadata": { + "patientId": "patient_123", + "recordType": "progress_note", + "accessReason": "clinical_review" + }, + "occurredAt": "2026-08-26T01:30:00Z" +} +``` + +Audit metadata must never contain: + +- passwords +- access or refresh tokens +- full clinical note content +- secret keys +- unnecessary payment data + +REST: + +```http +GET /api/v1/audit-events +``` + +No public create/update/delete endpoints. + +## 57. Domain Events and Transactional Outbox + +Profession modules produce internal domain events. + +Examples: + +```text +engineering.project.created +engineering.design.approved +engineering.inspection.completed + +legal.matter.closed +legal.conflict_check.approved + +healthcare.appointment.created +healthcare.clinical_record.signed + +invoice.issued +payment.recorded +``` + +Consumers: + +```text +notifications +webhooks +analytics +search indexing +integrations +background workflows +``` + +Use: + +```text +outbox_events +``` + +Suggested fields: + +```text +id +organization_id +event_type +aggregate_type +aggregate_id +payload +occurred_at +available_at +processed_at +attempt_count +last_error +dead_lettered_at +``` + +Correct transaction pattern: + +```text +BEGIN + +business change +audit event +outbox event + +COMMIT +``` + +The outbox component does **not** own or prematurely commit the caller's business transaction. + +Workers claim committed outbox rows using a safe concurrency strategy such as: + +```sql +SELECT ... +FROM outbox_events +WHERE processed_at IS NULL + AND available_at <= now() +ORDER BY occurred_at +FOR UPDATE SKIP LOCKED +LIMIT ... +``` + +Worker behavior: + +1. claim event +2. execute handler +3. mark processed on success +4. increment attempt count on failure +5. apply exponential/backoff policy +6. dead-letter after configured failure threshold +7. preserve enough metadata for replay and diagnosis + +A best-effort post-commit notification may wake workers, but workers must also poll. Otherwise a lost wake-up can strand committed events forever. + +Event consumers must be idempotent. + +## 58. Background Jobs + +Workers handle: + +```text +Email +SMS +Notifications +Report generation +PDF generation +File scanning +Document processing +Imports +Exports +Webhook delivery +Search indexing +Large data operations +``` + +Architecture: + +```text +API + ↓ +Queue + ↓ +Worker +``` + +--- + +## 59. Redis + +Use Redis as an acceleration and coordination layer, not the authoritative system of record. + +Appropriate uses: + +```text +job queue +rate-limit counters +short-lived authorization caches +organization configuration cache +session lookup acceleration +idempotency lookup acceleration +distributed locks when justified +``` + +### Cache Layers + +L1 optional application-memory cache: + +```text +static permission definitions +non-sensitive configuration +``` + +L2 Redis shared cache: + +```text +organization settings +membership snapshots +role permission snapshots +rate-limit counters +session lookup cache +recent idempotency lookups +``` + +CDN: + +```text +frontend static assets +explicitly public assets only +``` + +Do not cache private professional API responses at a CDN by default. + +### Cache Invalidation + +Invalidate or version caches when: + +```text +membership changes +role permissions change +organization settings change +professional credentials change +session is revoked +profession module enablement changes +``` + +High-risk authorization decisions must not depend solely on stale cached credential state. + +### Idempotency Durability + +Redis may improve idempotency lookup latency, but PostgreSQL remains authoritative for high-risk commands. + +## 60. Pagination + +Use cursor pagination. + +Example: + +```http +GET /api/v1/engineering/projects?limit=25 +``` + +Response: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": "...", + "hasMore": true + } + } +} +``` + +Maximum page size: + +```text +100 +``` + +--- + +## 61. Filtering + +Use explicit resource-specific filters. + +Examples: + +```http +GET /api/v1/engineering/projects?status=active&discipline=structural +GET /api/v1/engineering/tasks?status=todo&assignedToUserId=user_123 +``` + +Do not build a generic query DSL in v1. + +--- + +## 62. Sorting + +Examples: + +```http +GET /api/v1/engineering/projects?sort=createdAt +GET /api/v1/engineering/projects?sort=-createdAt +``` + +Only explicitly supported fields may be sorted. + +--- + +## 63. Search + +Start with PostgreSQL search. + +Engineering search may cover: + +```text +project number +project name +client name +``` + +Legal: + +```text +matter number +client +case number +``` + +Healthcare: + +```text +patient number +patient identity +``` + +Healthcare search requires stricter privacy and authorization controls. + +Potential PostgreSQL capabilities: + +- B-tree indexes for exact/filter queries +- PostgreSQL full-text search where appropriate +- `pg_trgm` only when fuzzy search requirements justify it + +Do not introduce Elasticsearch/OpenSearch until real query volume, relevance requirements, or indexing features justify another distributed system. + +Do not create every conceivable search index on day one. Indexes cost memory, storage, and write performance. + +## 64. Optimistic Concurrency + +Important mutable resources should use a version field. + +Example: + +```json +{ + "id": "project_123", + "version": 6 +} +``` + +Update: + +```json +{ + "version": 6, + "name": "Central Tower Phase II" +} +``` + +If the current database version differs: + +```text +409 CONCURRENT_MODIFICATION +``` + +--- + +## 65. Domain-Oriented REST + +Important state transitions use explicit command endpoints. + +Good: + +```http +POST /engineering/projects/{id}/close +POST /engineering/designs/{id}/approve +POST /engineering/tasks/{id}/complete +POST /engineering/inspections/{id}/complete +POST /invoices/{id}/issue +``` + +Avoid: + +```http +PATCH /resource/{id} +{ + "status": "approved" +} +``` + +when the change has significant rules or side effects. + +--- + +## 66. Transaction Boundaries + +Create project: + +```text +BEGIN + +create project +assign project manager +write audit event +write outbox event + +COMMIT +``` + +Approve design: + +```text +BEGIN + +validate permission +validate project access +validate credentials +validate design state +create review result +mark approved +write audit event +write outbox event + +COMMIT +``` + +--- + +## 67. Request Context + +Every authenticated request should resolve: + +```text +RequestContext +{ + requestId + userId + sessionId + organizationId + membershipId + permissions +} +``` + +Profession modules consume this context. + +--- + +## 68. Request IDs + +Every request has: + +```http +X-Request-Id +``` + +If missing, the server generates one. + +Use it in: + +- logs +- audit context +- error diagnostics +- asynchronous correlation + +--- + +## 69. OpenAPI + +Maintain: + +```text +openapi.yaml +``` + +Use OpenAPI 3.1. + +Production server example: + +```yaml +servers: + - url: https://api.example.com/api/v1 +``` + +The server URL and path definitions must remain consistent with the platform base path. + +OpenAPI defines: + +- routes +- request DTOs +- response DTOs +- security schemes +- organization header +- request IDs +- idempotency header +- pagination +- filters +- error schemas +- examples +- profession tags + +Security scheme: + +```yaml +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +``` + +Reusable headers/parameters: + +```text +X-Organization-Id +X-Request-Id +Idempotency-Key +limit +cursor +``` + +CI must validate the OpenAPI document. + +Contract tests should detect drift between implementation and specification. + +Generated clients may be used by the separate frontends, but generated transport code should not dictate frontend domain architecture. + +## 70. DTO Rule + +Database models are not public API contracts. + +Use: + +```text +Request DTO +Response DTO +``` + +A database migration should not accidentally change the public API. + +--- + +## 71. Backend Module Structure + +Recommended: + +```text +src/ +├── core/ +│ ├── auth/ +│ ├── organizations/ +│ ├── memberships/ +│ ├── authorization/ +│ ├── documents/ +│ ├── billing/ +│ ├── audit/ +│ └── events/ +│ +├── engineering/ +│ ├── clients/ +│ ├── projects/ +│ ├── project-members/ +│ ├── phases/ +│ ├── sites/ +│ ├── tasks/ +│ ├── designs/ +│ ├── inspections/ +│ └── specifications/ +│ +├── legal/ +│ ├── clients/ +│ ├── matters/ +│ ├── cases/ +│ ├── hearings/ +│ ├── conflicts/ +│ └── retainers/ +│ +└── healthcare/ + ├── patients/ + ├── practitioners/ + ├── appointments/ + ├── encounters/ + ├── records/ + └── prescriptions/ +``` + +--- + +## 72. Internal Module Structure + +Example: + +```text +projects/ +├── domain/ +│ ├── project.entity.ts +│ ├── project-status.ts +│ └── project.errors.ts +│ +├── application/ +│ ├── commands/ +│ │ ├── create-project.ts +│ │ ├── update-project.ts +│ │ └── close-project.ts +│ │ +│ └── queries/ +│ ├── get-project.ts +│ └── list-projects.ts +│ +├── infrastructure/ +│ └── project.repository.ts +│ +└── api/ + ├── project.controller.ts + ├── project.request.ts + └── project.response.ts +``` + +--- + +## 73. Controllers + +Controllers should handle: + +```text +HTTP +authentication context +input DTO parsing +application command/query invocation +response mapping +``` + +Controllers should not contain: + +```text +business rules +raw SQL +role logic +transaction orchestration +email sending +audit implementation +``` + +--- + +## 74. Commands and Queries + +Mutations use commands. + +Examples: + +```text +CreateEngineeringProjectCommand +ApproveEngineeringDesignCommand +CloseLegalMatterCommand +CompleteHealthcareEncounterCommand +``` + +Reads use queries. + +Examples: + +```text +GetEngineeringProjectQuery +ListLegalMattersQuery +GetHealthcarePatientQuery +``` + +--- + +## 75. Repositories + +Use domain-specific repositories. + +Examples: + +```text +EngineeringProjectRepository +LegalMatterRepository +HealthcarePatientRepository +``` + +Avoid one massive generic repository abstraction that eventually needs dozens of flags. + +--- + +## 76. Security Baseline + +Minimum controls: + +```text +TLS everywhere +strong password hashing +short-lived access tokens +refresh-token rotation +refresh-token reuse detection +server-side session revocation +rate limiting +RBAC +resource policies +credential-aware authorization +tenant isolation +input validation +SQL injection protection +signed object-storage URLs +virus/malware scanning for uploaded files +audit trails +secret management +encryption at rest +dependency scanning +security headers +request/correlation IDs +backup and restore testing +``` + +### Rate Limiting Framework + +Rate limits are endpoint-specific policy, not permanent architecture constants. + +Required categories: + +```text +authentication attempts +password reset/recovery +general authenticated API traffic +search +file upload initiation +expensive report generation +clinical record access +webhook/API integration traffic +``` + +Use Redis-backed counters or a managed gateway. + +Responses: + +```http +429 Too Many Requests +Retry-After: ... +``` + +Use: + +```text +RATE_LIMIT_EXCEEDED +``` + +in the error body. + +Authentication endpoints should have substantially stricter anti-abuse controls than ordinary reads. + +Healthcare record access may require anomaly detection beyond simple rate limits. + +Exact numeric limits are established through load testing, product usage, and security analysis, not copied from a design review. + +### Secrets + +Production secrets should live in a managed secret/key system where possible. + +Avoid treating a checked-in `.env` file as secret management. + +Prefer asymmetric JWT signing or managed signing keys where practical, with rotation support. + +## 77. Data Classification + +Suggested classes: + +### Public + +```text +marketing configuration +``` + +### Internal + +```text +organization settings +tasks +``` + +### Confidential + +```text +engineering documents +legal matters +billing +``` + +### Highly Sensitive + +```text +clinical records +professional credentials +authentication secrets +``` + +--- + +## 78. Healthcare Security + +Before healthcare production use, define: + +```text +privacy model +minimum-necessary access model +clinical access policies +break-glass/emergency access policy if required +audit policy +record-signing policy +amendment policy +retention policy +credential policy +scope-of-practice policy +jurisdiction requirements +encryption strategy +consent requirements +data residency requirements +backup/restore handling +export/portability requirements +breach-response requirements +``` + +Healthcare is a stricter security tier. + +Key rules: + +1. default patient responses do not contain all available PHI +2. clinical record reads may be auditable events +3. signed records are immutable except through explicit amendment/version workflows +4. prescribing authorization is jurisdiction-specific +5. privileged clinical commands revalidate professional authority +6. caches must not allow revoked credentials to remain effective for high-risk writes +7. healthcare search results themselves are protected data +8. access logs may require dedicated permissions +9. do not claim regulatory compliance from architecture alone + +## 79. Observability + +Use: + +```text +structured logs +metrics +distributed tracing +request IDs +correlation IDs +``` + +Recommended: + +```text +OpenTelemetry +``` + +Track: + +```text +API latency +error rate +database latency +database connection pool saturation +queue depth +outbox backlog +worker failures +authentication failures +refresh-token reuse detections +authorization denials +credential-policy denials +rate-limit events +external dependency failures +file-processing failures +``` + +### Performance SLOs + +Define performance targets per environment and endpoint class. + +Do not hard-code claims such as "100k-row list query under 100 ms" or "100 MB upload under 30 seconds" into architecture without measurement. + +Direct-to-object-storage upload performance depends heavily on client network and storage provider. + +Establish: + +```text +p50 +p95 +p99 +error budget +throughput +concurrency +``` + +from realistic load tests. + +Reporting endpoints may have different SLOs from interactive CRUD endpoints. + +## 80. Logging + +Useful fields: + +```text +request_id +route +method +status +duration +user_id when appropriate +organization_id when appropriate +``` + +Never log: + +```text +passwords +tokens +clinical record text +full sensitive documents +payment secrets +``` + +--- + +## 81. Testing Strategy + +### Unit Tests + +Test: + +```text +domain rules +state transitions +authorization policies +credential policies +money calculations +idempotency request hashing +``` + +### Integration Tests + +Test: + +```text +repositories +PostgreSQL constraints +tenant-aware foreign keys +transactions +outbox persistence +idempotency persistence +cache invalidation hooks +``` + +### API Tests + +Every important endpoint should cover: + +```text +happy path +request validation +authentication +organization context +permission denial +scope denial +credential denial where relevant +cross-tenant access +concurrent modification +invalid state transition +idempotent replay +idempotency conflict +audit event creation +outbox event creation +``` + +### Engineering Design Approval Tests + +At minimum: + +```text +qualified assigned approver → success +missing permission → 403 +invalid credential → 403 +wrong organization → 404 +wrong state → 422/409 according to contract +duplicate idempotency key + same payload → same result +duplicate idempotency key + different payload → 409 +approval creates audit event +approval creates outbox event +``` + +### Healthcare Record Tests + +At minimum: + +```text +authorized record read → success + audit where required +unauthorized practitioner → deny +wrong organization → 404 +signed record direct overwrite → deny +amendment creates preserved history +access-log permission enforced +credential revocation blocks high-risk command immediately +``` + +### Performance Tests + +Create workload profiles rather than one universal test: + +```text +interactive reads +interactive writes +search +dashboard read models +reporting +file-upload orchestration +outbox processing +notification bursts +``` + +Record p50/p95/p99 latency and database saturation. + +Targets become release gates only after a realistic baseline is established. + +## 82. Tenant Security Tests + +For every major resource, attempt: + +```text +Organization A resource +using Organization B context +``` + +Test: + +```text +read +update +delete/action +list filtering +search +documents +``` + +Expected result: + +```text +404 / denied +``` + +--- + +## 83. Engineering MVP + +Engineering is the first vertical. + +Initial features: + +```text +Authentication +Organization management +Users / memberships / roles +Engineering clients +Projects +Project members +Project phases +Tasks +Sites +Documents +Basic design records +Inspections +Time entries +Basic billing +Audit history +``` + +Do not initially build: + +```text +advanced CAD integration +BIM integration +full document markup +advanced resource planning +procurement +complex accounting +AI design analysis +IoT integrations +``` + +--- + +## 84. Engineering MVP Workflow + +```text +User registers + ↓ +Creates engineering organization + ↓ +Invites engineer + ↓ +Assigns role + ↓ +Creates client + ↓ +Creates project + ↓ +Assigns project team + ↓ +Creates project phases + ↓ +Creates tasks + ↓ +Uploads documents + ↓ +Creates design + ↓ +Reviews / approves design + ↓ +Schedules inspection + ↓ +Records inspection findings + ↓ +Records engineering time + ↓ +Creates invoice + ↓ +Records payment + ↓ +Closes project + ↓ +Audit history contains lifecycle +``` + +--- + +## 85. Development Phases + +### Phase 0: Architecture Foundation + +Deliver: + +```text +domain boundaries +database conventions +REST conventions +authorization model +organization-context rules +session/token policy +idempotency strategy +error taxonomy +OpenAPI skeleton +engineering state machines +migration conventions +threat model +``` + +### Phase 1: Shared Platform Core + +Build: + +```text +auth +sessions +token rotation and revocation +users +organizations +organization professions +membership invitations +memberships +roles +permissions +authorization +audit +outbox +request context +idempotency persistence +rate-limit framework +``` + +### Phase 2: Engineering CRM + +Build: + +```text +engineering_clients +client archive/restore +client-project relationship +``` + +### Phase 3: Engineering Projects + +Build: + +```text +engineering_projects +engineering_project_members +engineering_project_phases +project activation/close/archive commands +``` + +### Phase 4: Work Management + +Build: + +```text +engineering_tasks +engineering_sites +``` + +### Phase 5: Documents + +Build: + +```text +documents +document_versions +object storage +signed uploads +malware scanning +engineering document links +``` + +### Phase 6: Designs + +Build: + +```text +engineering_designs +engineering_design_versions +engineering_design_reviews +explicit design state machine +credential-aware approval +approval audit/outbox/idempotency +``` + +### Phase 7: Inspections + +Build: + +```text +engineering_inspections +inspection state machine +engineering_inspection_findings +follow-ups +attachments +completion audit/outbox/idempotency +``` + +### Phase 8: Time and Billing + +Build: + +```text +engineering_time_entries +invoices +invoice_items +payments +financial idempotency +provider reconciliation +``` + +### Phase 9: Notifications + +Build: + +```text +in-app notifications +email +worker processing +retry/dead-letter handling +``` + +### Phase 10: Reporting and Search + +Initial reports: + +```text +project status +overdue tasks +inspection status +billable time +revenue +outstanding invoices +``` + +Add advanced indexes, materialized views, read replicas, or external search only if measurement justifies them. + +### Phase 11: Legal Vertical + +Implement legal domain only after shared core assumptions survive the engineering product. + +### Phase 12: Healthcare Readiness + +Before implementation: + +```text +healthcare threat model +privacy review +jurisdiction analysis +credential/scope-of-practice policy +record signing/amendment model +retention model +audit requirements +``` + +Then build the healthcare vertical. + +## 86. Legal Expansion + +Only after engineering proves the shared platform assumptions. + +Build: + +```text +Legal Client + ↓ +Matter + ↓ +Case + ↓ +Hearings / Deadlines / Documents +``` + +Do not redesign engineering around legal terminology. + +Extract only genuinely reusable infrastructure. + +--- + +## 87. Healthcare Expansion + +Healthcare comes after: + +- core platform is stable +- audit model is proven +- permission model is proven +- tenant isolation is tested +- retention and encryption strategies are defined + +Healthcare should be treated as its own security and compliance workstream. + +--- + +## 88. Deployment Environments + +Use: + +```text +development +testing +staging +production +``` + +Each environment has independent: + +```text +database +object storage +secrets +queues +API keys +``` + +--- + +## 89. Initial Deployment Architecture + +```text +CDN + │ + ├── Engineering Web + ├── Legal Web + └── Healthcare Web + +Load Balancer + │ + Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Queue + │ + Workers +``` + +Prefer managed infrastructure where practical. + +--- + +## 90. Backup Strategy + +Database: + +```text +automated backups +point-in-time recovery +tested restores +``` + +Object storage: + +```text +versioning +retention policies +backup or replication where required +``` + +A backup strategy is incomplete until restoration is tested. + +--- + +## 91. Migration Strategy + +Use explicit immutable migration files. + +Recommended naming: + +```text +YYYYMMDDHHMMSS_description.sql +``` + +Example: + +```text +20260826010000_create_organizations.sql +20260826011000_create_users.sql +20260826012000_create_memberships.sql +20260826013000_create_rbac.sql +20260826014000_create_audit_outbox.sql +20260826015000_create_engineering_clients.sql +``` + +### UUID Standard + +The platform standard remains UUIDv7. + +Do not silently switch to PostgreSQL `gen_random_uuid()` if that produces UUIDv4 and violates the identifier convention. + +Generate UUIDv7 in the application or use a database UUIDv7 implementation whose behavior is explicitly controlled and tested. + +### Production Migration Rules + +Use expand/contract migration patterns: + +```text +1. add backward-compatible schema +2. deploy code supporting old + new schema +3. migrate/backfill data +4. switch reads/writes +5. remove obsolete schema in a later deployment +``` + +Do not assume every production migration can be cleanly rolled back by executing a reverse SQL file. + +For destructive changes: + +- backup/restore plan +- compatibility window +- dry run on production-like data +- explicit operational approval + +Never use automatic ORM schema synchronization in production. + +## 92. Technology Recommendation + +Backend: + +```text +TypeScript +NestJS or Fastify-based architecture +``` + +Database: + +```text +PostgreSQL +``` + +ORM/query layer candidates: + +```text +Prisma +Drizzle +Kysely +``` + +Frontend: + +```text +React / Next.js +``` + +Queue: + +```text +Redis + BullMQ +``` + +Storage: + +```text +S3-compatible storage +``` + +Observability: + +```text +OpenTelemetry +``` + +Containers: + +```text +Docker +``` + +--- + +## 93. REST API Milestones + +### Milestone 1: Platform Access and Security + +```http +POST /auth/register +POST /auth/login + +POST /auth/token/refresh +POST /auth/token/revoke +POST /auth/token/revoke-all + +GET /auth/sessions +DELETE /auth/sessions/{sessionId} + +GET /me + +POST /organizations +GET /me/organizations + +POST /membership-invitations +GET /memberships + +GET /roles +POST /roles +GET /permissions +``` + +Includes: + +```text +explicit organization context +session revocation +refresh-token reuse detection +audit foundation +outbox foundation +idempotency foundation +rate limiting +``` + +### Milestone 2: Engineering Clients + +```http +GET /engineering/clients +POST /engineering/clients +GET /engineering/clients/{id} +PATCH /engineering/clients/{id} +POST /engineering/clients/{id}/archive +POST /engineering/clients/{id}/restore +GET /engineering/clients/{id}/projects +``` + +### Milestone 3: Engineering Projects + +```http +GET /engineering/projects +POST /engineering/projects +GET /engineering/projects/{id} +PATCH /engineering/projects/{id} + +POST /engineering/projects/{id}/activate +POST /engineering/projects/{id}/close +POST /engineering/projects/{id}/archive + +GET /engineering/projects/{id}/summary +``` + +Timeline and budget read models follow when the frontend requires them. + +### Milestone 4: Collaboration + +```http +POST /engineering/projects/{id}/members +GET /engineering/projects/{id}/members + +POST /engineering/tasks +GET /engineering/tasks +POST /engineering/tasks/{id}/complete +``` + +### Milestone 5: Sites and Documents + +Build: + +```text +engineering sites +signed file uploads +document versions +malware scanning +project document links +``` + +### Milestone 6: Designs + +Build: + +```text +design lifecycle +versions +reviews +submit-review +request-changes +approve +reject +supersede +credential validation +audit + outbox + idempotency +``` + +### Milestone 7: Inspections + +Build: + +```text +schedule +start +complete +cancel +findings +finding resolution +audit + outbox + idempotency +``` + +### Milestone 8: Commercial Workflows + +Build: + +```text +time entries +invoices +payments +refunds +financial idempotency +reports +``` + +## 94. Architecture Rules to Freeze + +1. REST is the primary frontend and integration API. +2. Base path is `/api/v1`. +3. OpenAPI 3.1 is the public REST contract. +4. GraphQL is not part of v1. +5. One shared backend platform is deployed initially as a modular monolith. +6. Each profession has a separate frontend application. +7. Each profession owns its domain tables and state machines. +8. Shared modules contain infrastructure, not forced cross-profession business semantics. +9. Every tenant-owned row has `organization_id`. +10. Tenant-scoped endpoints require explicit `X-Organization-Id`. +11. The API never silently chooses a tenant. +12. Tenant isolation is enforced in queries and database relationships. +13. Cross-tenant resources appear nonexistent. +14. Authorization is server-side and deny-by-default. +15. Roles and professional qualifications are separate. +16. High-risk professional actions revalidate authoritative credential state. +17. Healthcare prescribing authority is jurisdiction/policy driven, not hard-coded to one profession. +18. Signed clinical records use version/amendment workflows, not destructive overwrite. +19. Important state changes use explicit REST command endpoints. +20. Important/regulated mutations are audited. +21. Clinical record reads are auditable where policy requires. +22. Domain events use a transactional outbox. +23. Outbox consumers are idempotent. +24. High-risk POST commands use durable idempotency records. +25. Redis may accelerate idempotency but is not the sole source of truth for financial/regulated commands. +26. PostgreSQL is the authoritative application datastore. +27. UUIDv7 is the identifier standard. +28. Files live in object storage and use signed access. +29. File uploads support security scanning before availability. +30. Background side effects run through workers. +31. Cache invalidation is explicit for memberships, roles, sessions, module settings, and credentials. +32. Do not trust stale credential caches for regulated write authorization. +33. Rate limits are endpoint-specific policies calibrated by testing. +34. Search starts in PostgreSQL. +35. Materialized views, read replicas, external search, and specialized indexes require workload evidence. +36. Database models and public DTOs are separate contracts. +37. API collections use cursor pagination. +38. Mutable important resources use optimistic concurrency. +39. Do not replace proper domain modeling with arbitrary JSON blobs. +40. Do not hard-delete professional or financial records without explicit retention rules. +41. Production migrations follow expand/contract compatibility. +42. Do not assume destructive migrations are trivially reversible. +43. Secrets are managed outside source control. +44. CI validates OpenAPI, tests, migrations, types, and security checks. +45. Engineering remains the first product vertical. +46. Legal follows after the engineering platform proves shared assumptions. +47. Healthcare requires an explicit security/privacy/compliance readiness review before implementation. +48. Architecture documentation must distinguish "designed for" from "certified/compliant". + +## 95. Required Design Artifacts + +Prepare and maintain: + +```text +01_PROJECT_ARCHITECTURE.md +02_DATABASE_CONVENTIONS.md +03_AUTHORIZATION_MODEL.md +04_ENGINEERING_DOMAIN.md +05_ENGINEERING_DATABASE_SCHEMA.md +06_API_CONVENTIONS.md +07_ENGINEERING_API_SPEC.md +08_FRONTEND_ARCHITECTURE.md +09_SECURITY_MODEL.md +10_DEPLOYMENT_ARCHITECTURE.md +11_TESTING_STRATEGY.md +12_MVP_BACKLOG.md +13_OPENAPI.yaml +``` + +--- + +## 96. Recommended Implementation Order + +```text +Foundation + ↓ +Authentication + ↓ +Organizations + ↓ +Memberships + ↓ +RBAC + ↓ +Engineering Clients + ↓ +Engineering Projects + ↓ +Project Team + ↓ +Tasks + ↓ +Sites + ↓ +Documents + ↓ +Designs + ↓ +Inspections + ↓ +Time Tracking + ↓ +Billing + ↓ +Notifications + ↓ +Reports + ↓ +Legal Vertical + ↓ +Healthcare Vertical +``` + +--- + +## 97A. Database Indexing Strategy + +All tenant-owned tables should have an index supporting tenant lookup. + +Baseline patterns: + +```text +(organization_id, id) +(organization_id, created_at) +``` + +Add query-specific indexes based on real access patterns: + +```text +(organization_id, status) +(organization_id, client_id) +(organization_id, project_id) +(organization_id, assigned_to_user_id) +``` + +Rules: + +1. every index must correspond to a known query or constraint +2. composite index order follows the actual WHERE/ORDER BY pattern +3. verify with `EXPLAIN (ANALYZE, BUFFERS)` on representative data +4. do not index every column +5. index write cost is part of the decision +6. full-text/trigram indexes are introduced when search requirements are known + +Read replicas and materialized views are later scaling tools, not baseline dependencies. + +--- + +## 97B. CI/CD and Deployment Gates + +Pipeline stages: + +```text +lint/typecheck + ↓ +unit tests + ↓ +integration tests + ↓ +OpenAPI validation + contract tests + ↓ +security/dependency scan + ↓ +container build + image scan + ↓ +migration compatibility check + ↓ +deploy development + ↓ +smoke tests + ↓ +deploy staging + ↓ +E2E + performance/security baseline + ↓ +manual production approval + ↓ +production deployment + ↓ +post-deploy verification +``` + +Production deployment should support: + +```text +rolling or blue/green application deployment +backward-compatible database migrations +health checks +fast application rollback +feature flags for incomplete features +observability gates +``` + +Database schema rollback is not treated as equivalent to application rollback. + +### Configuration and Secrets + +Non-secret configuration may use environment variables. + +Secrets should use a managed secret store where possible: + +```text +database credentials +Redis credentials +JWT/private signing keys +object storage credentials +SMTP/API provider credentials +monitoring credentials +``` + +Do not publish real secrets in sample configuration. + +Organization profession enablement remains primarily data-driven through `organization_professions`. + +Global feature flags may be used for staged rollout, kill switches, or incomplete features. + +--- + +## 97C. Review-Driven Deferred Decisions + +The following ideas are valid possibilities but are explicitly **not frozen into v1**: + +```text +read replicas +materialized views +Elasticsearch/OpenSearch +universal 100 MB file limit +fixed 100 req/min user limit +fixed 1000 req/hour organization limit +specific cache-hit-ratio target +specific p95 latency promise +database-per-tenant +microservices +GraphQL +``` + +These require evidence from: + +```text +load tests +security analysis +customer requirements +compliance requirements +real production workloads +``` + +This prevents benchmark-shaped guesses from becoming architecture law. + +--- + +## 97. Final Design Position + +The platform is not a generic management system with profession names painted on top. + +It is: + +```text +One Shared Platform + │ + ├── Shared Identity + ├── Shared Security + ├── Shared Infrastructure + ├── Shared Documents + ├── Shared Financial Core + ├── Shared Audit/Event Platform + │ + ├── Engineering Product + │ ├── Engineering Frontend + │ ├── Engineering REST APIs + │ └── Engineering Tables + │ + ├── Legal Product + │ ├── Legal Frontend + │ ├── Legal REST APIs + │ └── Legal Tables + │ + └── Healthcare Product + ├── Healthcare Frontend + ├── Healthcare REST APIs + └── Healthcare Tables +``` + +The system shares infrastructure where reuse is valuable while preserving independent domain models where professional workflows differ. + +This document is an implementation-ready architectural baseline. It is **not** by itself proof of production readiness, regulatory compliance, security certification, or performance at a particular scale. Those claims require implementation evidence, threat modeling, testing, operational controls, and profession/jurisdiction-specific review. diff --git a/professional_management_platform_rest_plan_v3.md b/professional_management_platform_rest_plan_v3.md new file mode 100644 index 0000000..be182b3 --- /dev/null +++ b/professional_management_platform_rest_plan_v3.md @@ -0,0 +1,5679 @@ +# Professional Management Platform +## Full REST-First System Design Plan + +> **Revision:** v3 — Implementation Architecture Baseline +> **Status:** Engineering-MVP implementation baseline; production readiness still requires measured evidence, security review, and profession/jurisdiction-specific validation. +> **Primary vertical:** Engineering +> **API style:** REST + JSON + OpenAPI 3.1 +> **Backend style:** Modular monolith +> **Data:** PostgreSQL + profession-specific tables +> **Tenant model:** Organization-scoped, explicit tenant context + +### v3 Integration Notes + +v3 incorporates the strongest additions from the second design review while correcting several implementation traps. + +Added or strengthened: + +- webhook configuration, delivery history, retries, secret rotation, and signing +- asynchronous import/export/report job model +- engineering client contact management +- project document-link APIs +- design assignment, cancellation/withdrawal, and richer lifecycle rules +- engineering project budget and budget-item modeling +- document classification, retention, content hashing, and categories +- refresh-token family lineage with separate session and token records +- rate-limit policy framework without freezing arbitrary limits +- property-based testing for state machines +- chaos/reliability testing for transactional outbox consumers +- operational and security metrics +- explicit pre-production quality gates +- expand/contract migration requirements +- PostgreSQL 18 native UUIDv7 option +- minimum viable file-policy framework +- production checklist and observability requirements + +Corrected rather than copied literally: + +- refresh-token rotation does not create a schema conflict with `UNIQUE(refresh_token_family_id)` +- webhook HMAC secrets are not stored as one-way hashes if the server needs them for signing +- async export is modeled as a job resource rather than a side-effecting `GET` +- inspection follow-up is modeled as an outcome/linked workflow rather than overloading inspection lifecycle state +- document checksum is authoritative on document versions, not the parent document +- document confidentiality is modeled as classification rather than a single boolean +- nullable document-category uniqueness must use explicit PostgreSQL null semantics or partial indexes +- budget spent/committed values must not become uncontrolled duplicate sources of financial truth +- property-based tests distinguish valid and invalid transitions +- outbox processing assumes at-least-once delivery and therefore requires idempotent consumers +- tenant attack signals are distinct from internal tenant-isolation invariant failures +- implementation phases are milestones, not a fictional calendar commitment +- coverage percentage is a diagnostic metric, not a substitute for critical-path tests + +--- +--- + +## 2. Core Architecture Decision + +The platform will use: + +- REST +- JSON +- OpenAPI +- Versioned endpoints +- PostgreSQL +- Modular monolith backend +- Profession-specific frontends +- Profession-specific database tables +- Shared identity, security, billing, documents, audit, and infrastructure + +Base API path: + +```text +/api/v1 +``` + +GraphQL is not part of v1. + +--- + +## 3. High-Level Architecture + +```text + FRONTENDS + + ┌──────────────────┼──────────────────┐ + │ │ │ + Engineering Web Legal Web Healthcare Web + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ + REST API + /api/v1 + │ + ┌───────────┼───────────┐ + │ │ │ + Core Engineering Legal + │ │ │ + │ Healthcare │ + │ │ │ + └───────────┼───────────┘ + │ + PostgreSQL + │ + ┌───────────────┼────────────────┐ + │ │ │ + Shared Tables Profession Tables Audit/Event Tables +``` + +Shared infrastructure: + +```text +PostgreSQL +Redis +Object Storage +Queue / Workers +Audit +Notifications +Billing +Observability +``` + +--- + +## 4. System Architecture Strategy + +Start with a modular monolith. + +Do not start with microservices. + +Initial deployment: + +```text +Frontend Apps + │ + ▼ +Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Worker Queue +``` + +Benefits: + +- simpler transactions +- easier development +- easier deployment +- clearer domain boundaries +- lower operational burden +- easier refactoring +- future service extraction remains possible + +--- + +## 5. Repository Structure + +Recommended monorepo: + +```text +professional-platform/ +│ +├── apps/ +│ ├── engineering-web/ +│ ├── legal-web/ +│ ├── healthcare-web/ +│ ├── platform-admin/ +│ ├── api/ +│ └── workers/ +│ +├── packages/ +│ ├── ui/ +│ ├── api-client/ +│ ├── auth-client/ +│ ├── validation/ +│ ├── types/ +│ ├── config/ +│ └── testing/ +│ +├── database/ +│ ├── migrations/ +│ ├── seeds/ +│ └── scripts/ +│ +├── infrastructure/ +│ ├── docker/ +│ ├── deployment/ +│ └── monitoring/ +│ +└── docs/ + ├── architecture/ + ├── api/ + ├── security/ + └── domains/ +``` + +--- + +## 6. Frontend Strategy + +Every profession receives its own frontend application. + +Avoid one giant frontend filled with profession checks. + +### Engineering Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Projects +Project Phases +Project Team +Sites +Designs +Design Reviews +Inspections +Specifications +Tasks +Documents +Timesheets +Billing +Reports +Administration +``` + +### Legal Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Matters +Cases +Hearings +Courts +Deadlines +Documents +Conflict Checks +Time Tracking +Retainers +Billing +Reports +Administration +``` + +### Healthcare Frontend + +Suggested navigation: + +```text +Dashboard +Patients +Appointments +Practitioners +Encounters +Clinical Records +Diagnoses +Prescriptions +Insurance +Documents +Billing +Reports +Administration +``` + +### Platform Admin Frontend + +Suggested functions: + +```text +Organizations +Users +Profession Modules +Subscriptions +System Health +Audit +Support +Global Configuration +``` + +Platform administrators and organization administrators are separate concepts. + +--- + +## 7. REST API Structure + +Shared endpoints: + +```text +/api/v1/auth +/api/v1/me +/api/v1/organizations +/api/v1/memberships +/api/v1/membership-invitations +/api/v1/roles +/api/v1/permissions +/api/v1/documents +/api/v1/invoices +/api/v1/payments +/api/v1/audit-events +``` + +Engineering: + +```text +/api/v1/engineering/clients +/api/v1/engineering/projects +/api/v1/engineering/project-members +/api/v1/engineering/phases +/api/v1/engineering/sites +/api/v1/engineering/tasks +/api/v1/engineering/designs +/api/v1/engineering/inspections +/api/v1/engineering/specifications +/api/v1/engineering/time-entries +``` + +Legal: + +```text +/api/v1/legal/clients +/api/v1/legal/matters +/api/v1/legal/cases +/api/v1/legal/hearings +/api/v1/legal/deadlines +/api/v1/legal/conflict-checks +/api/v1/legal/retainers +/api/v1/legal/time-entries +``` + +Healthcare: + +```text +/api/v1/healthcare/patients +/api/v1/healthcare/practitioners +/api/v1/healthcare/appointments +/api/v1/healthcare/encounters +/api/v1/healthcare/clinical-records +/api/v1/healthcare/diagnoses +/api/v1/healthcare/prescriptions +/api/v1/healthcare/insurance +``` + +--- + +## 8. REST Conventions + +All APIs use JSON over HTTPS. + +Typical tenant-scoped request: + +```http +Authorization: Bearer +X-Organization-Id: org_123 +X-Request-Id: req_123 +Content-Type: application/json +``` + +### Organization Context + +`X-Organization-Id` is mandatory for every tenant-scoped endpoint. + +It is deliberately explicit even when a user currently belongs to only one organization. Silent organization selection creates ambiguous clients and becomes dangerous the moment the user later joins a second organization. + +Global endpoints such as these do not require tenant context: + +```http +POST /api/v1/auth/login +POST /api/v1/auth/token/refresh +GET /api/v1/me +GET /api/v1/me/organizations +GET /api/v1/auth/sessions +``` + +Tenant-context resolution rules: + +```yaml +Organization Context: + header_missing_on_tenant_endpoint: + status: 400 + code: ORGANIZATION_CONTEXT_REQUIRED + + organization_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + + membership_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + + membership_inactive: + status: 403 + code: AUTHZ_MEMBERSHIP_INACTIVE + + organization_inactive: + status: 403 + code: AUTHZ_ORGANIZATION_INACTIVE + + resource.organization_id_mismatch: + status: 404 + code: RESOURCE_NOT_FOUND +``` + +Do not return another organization's name or membership details in tenant-error responses. + +### Idempotency + +Use: + +```http +Idempotency-Key: 8f7d6c5e-4b3a-2b1c-9d8e-7f6a5b4c3d2e +``` + +Idempotency is required for commands where duplicate execution can create financial, regulated, external, or otherwise material side effects. + +Examples: + +```http +POST /api/v1/invoices +POST /api/v1/invoices/{id}/payments +POST /api/v1/payments/{id}/refund + +POST /api/v1/engineering/designs/{id}/approve +POST /api/v1/engineering/inspections/{id}/complete + +POST /api/v1/legal/retainers +POST /api/v1/legal/conflict-checks/{id}/approve + +POST /api/v1/healthcare/prescriptions +POST /api/v1/healthcare/clinical-records/{id}/sign +``` + +Do not require idempotency on every ordinary PATCH by default. + +Idempotency records must include: + +```text +organization_id +actor_id +route/action +idempotency_key +canonical_request_hash +response_status +response_body or resource reference +created_at +expires_at +``` + +Rules: + +```yaml +Same key + same operation + same request hash: + return: original result + +Same key + different request hash: + status: 409 + code: IDEMPOTENCY_KEY_CONFLICT +``` + +Durability: + +- PostgreSQL is authoritative for critical idempotency records. +- Redis may cache recent records for speed. +- Redis eviction must not make a payment or regulated command executable twice. +- Downstream providers should receive their own idempotency key where supported. + +## 9. Standard Response Format + +Single resource: + +```json +{ + "data": { + "id": "project_123", + "name": "Central Tower" + } +} +``` + +Collection: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Standard error: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "req_123" + } +} +``` + +Clients depend on `error.code`, not message text. + +### Error Taxonomy + +Authentication: + +```text +AUTH_INVALID_CREDENTIALS +AUTH_TOKEN_EXPIRED +AUTH_TOKEN_INVALID +AUTH_MFA_REQUIRED +AUTH_SESSION_REVOKED +AUTH_REFRESH_TOKEN_REUSED +``` + +Authorization: + +```text +AUTHZ_PERMISSION_DENIED +AUTHZ_ORGANIZATION_INACTIVE +AUTHZ_MEMBERSHIP_INACTIVE +AUTHZ_CREDENTIAL_INVALID +AUTHZ_SCOPE_MISMATCH +``` + +Tenant context: + +```text +ORGANIZATION_CONTEXT_REQUIRED +``` + +Resource/state: + +```text +RESOURCE_NOT_FOUND +RESOURCE_ALREADY_EXISTS +RESOURCE_CONCURRENT_MODIFICATION +RESOURCE_INVALID_STATE +RESOURCE_ARCHIVED +``` + +Validation: + +```text +VALIDATION_ERROR +VALIDATION_REQUIRED_FIELD +VALIDATION_INVALID_FORMAT +VALIDATION_BUSINESS_RULE +``` + +Idempotency: + +```text +IDEMPOTENCY_KEY_REQUIRED +IDEMPOTENCY_KEY_CONFLICT +``` + +Rate limiting: + +```text +RATE_LIMIT_EXCEEDED +``` + +System/dependency: + +```text +INTERNAL_ERROR +SERVICE_UNAVAILABLE +DATABASE_UNAVAILABLE +DEPENDENCY_FAILED +``` + +Validation example: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed.", + "requestId": "req_123", + "details": { + "fields": [ + { + "field": "email", + "code": "INVALID_FORMAT", + "message": "Must be a valid email address" + } + ] + } + } +} +``` + +Business-state example: + +```json +{ + "error": { + "code": "RESOURCE_INVALID_STATE", + "message": "Cannot approve design in current state.", + "requestId": "req_123", + "details": { + "resourceType": "engineering_design", + "resourceId": "design_123", + "currentState": "draft", + "requiredState": "under_review", + "allowedActions": [ + "submit_review" + ] + } + } +} +``` + +Do not expose internal stack traces, SQL, policy internals, secrets, or cross-tenant information. + +## 10. HTTP Status Rules + +```text +200 Success +201 Created +202 Accepted +204 No Content +400 Bad Request +401 Unauthorized +403 Forbidden +404 Not Found +409 Conflict +422 Validation Error +429 Too Many Requests +500 Internal Server Error +``` + +Cross-tenant resource access should return 404. + +--- + +## 11. API Versioning + +Current API: + +```text +/api/v1 +``` + +Breaking changes require: + +```text +/api/v2 +``` + +Additive fields generally do not require a new version. + +--- + +## 12. Authentication + +Initial authentication: + +```text +Email ++ +Password ++ +Short-Lived Access Token ++ +Opaque Refresh Token ++ +Server-Side Session +``` + +REST endpoints: + +```http +POST /api/v1/auth/register +POST /api/v1/auth/login + +POST /api/v1/auth/token/refresh +POST /api/v1/auth/token/revoke +POST /api/v1/auth/token/revoke-all + +GET /api/v1/auth/sessions +DELETE /api/v1/auth/sessions/{sessionId} + +GET /api/v1/me +``` + +### Access Token + +```yaml +format: JWT +lifetime: 15 minutes by default +signed: true +encrypted: false +preferred signing: asymmetric key or managed signing service +claims: + - sub / userId + - sessionId + - issuer + - audience + - issuedAt + - expiresAt +organizationId: + optional_hint: true + authorization_authority: false +``` + +The organization header and active membership remain authoritative for tenant access. + +Do not embed the complete permission set in access tokens. + +### Session and Refresh-Token Model + +A login session and a refresh token are different resources. + +Use: + +```text +sessions +refresh_tokens +``` + +Suggested `sessions` fields: + +```text +id +user_id + +device_id +device_type +device_os +app_version + +ip_address +user_agent + +created_at +last_active_at +expires_at + +revoked_at +revocation_reason +``` + +Suggested `refresh_tokens` fields: + +```text +id +session_id +family_id + +token_hash + +issued_at +expires_at + +rotated_at +replaced_by_token_id + +revoked_at +revocation_reason +``` + +Indexes/constraints: + +```text +UNIQUE(refresh_tokens.token_hash) + +INDEX(refresh_tokens.family_id) +INDEX(refresh_tokens.session_id) +INDEX(sessions.user_id, sessions.revoked_at) +``` + +Do **not** make `family_id` unique. Every rotated refresh token in the same lineage shares the same family. + +Conceptually: + +```text +Session + │ + └── Refresh Token Family + │ + ├── Token A [rotated] + │ ↓ + ├── Token B [rotated] + │ ↓ + └── Token C [current] +``` + +### Refresh Rotation + +On successful refresh: + +1. hash supplied refresh token +2. load token and session +3. validate token/session status and expiry +4. issue replacement token in same family +5. mark old token rotated +6. link `replaced_by_token_id` +7. return new access + refresh tokens + +### Reuse Detection + +If a previously rotated token is used again: + +```text +possible token theft + ↓ +revoke token family + ↓ +revoke affected session + ↓ +security audit event + ↓ +reauthentication required +``` + +Policy may escalate to revoking all user sessions for higher-risk environments. + +Audit event: + +```text +auth.refresh_token.reuse_detected +``` + +### Device Metadata + +Device metadata is useful for: + +```text +session display +security alerts +audit context +user-initiated revocation +``` + +It is not identity proof. + +Future authentication: + +- MFA +- passkeys / WebAuthn +- OIDC / SSO +- enterprise identity providers +- risk-based authentication + +## 13. Shared Core Backend + +Recommended modules: + +```text +core/ +├── auth/ +├── users/ +├── organizations/ +├── memberships/ +├── roles/ +├── permissions/ +├── authorization/ +├── documents/ +├── billing/ +├── notifications/ +├── audit/ +└── events/ +``` + +Dependency rule: + +```text +Profession module → Core +``` + +Never: + +```text +Core → Profession module +``` + +--- + +## 14. Organizations + +Organizations are tenants. + +Examples: + +```text +Atlas Structural Engineering +Smith & Associates Law +North Shore Medical Practice +``` + +Suggested fields: + +```text +id +name +slug +status +country_code +timezone +currency_code +created_at +updated_at +``` + +--- + +## 15. Profession Enablement + +Use: + +```text +organization_professions +``` + +Suggested fields: + +```text +organization_id +profession +enabled_at +configuration +``` + +Possible professions: + +```text +engineering +legal +healthcare +``` + +An organization may eventually enable more than one profession module. + +--- + +## 16. Users and Memberships + +Users are global identities. + +A user gains tenant access through membership. + +```text +User + │ + ▼ +Membership + │ + ▼ +Organization +``` + +Suggested `users` fields: + +```text +id +email +first_name +last_name +phone +avatar_url +status +created_at +updated_at +``` + +Suggested `memberships` fields: + +```text +id +organization_id +user_id +status +joined_at +created_at +updated_at +``` + +--- + +## 17. Membership Invitations + +Keep invitations separate from memberships. + +Suggested table: + +```text +membership_invitations +``` + +Fields: + +```text +id +organization_id +email +invited_by_user_id +expires_at +accepted_at +revoked_at +created_at +``` + +Flow: + +```text +Invitation + ↓ +Accepted + ↓ +User + ↓ +Membership +``` + +--- + +## 18. Authorization + +Use: + +```text +RBAC ++ +Permission Scope ++ +Resource Policies ++ +Professional Qualification Policies ++ +Domain State Rules +``` + +Decision flow: + +```text +Authenticated User + ↓ +Explicit Organization Context + ↓ +Active Membership + ↓ +Enabled Profession Module + ↓ +Roles + ↓ +Permissions + ↓ +Permission Scope + ↓ +Tenant-scoped Resource Query + ↓ +Resource Policy + ↓ +Credential/Jurisdiction Policy + ↓ +Domain State Rule + ↓ +ALLOW / DENY +``` + +Default decision: + +```text +DENY +``` + +Authorization rules: + +1. Controllers never perform ad-hoc role comparisons. +2. Tenant resource queries always include `organization_id`. +3. Do not load an arbitrary resource first and then discover it belongs to another tenant. +4. High-risk professional actions perform credential checks at command execution time. +5. A permission grants the ability to attempt an action, not a guarantee the domain state allows it. +6. Cross-tenant resources appear nonexistent. +7. Profession module enablement is checked before profession-specific authorization. + +## 19. Roles and Permissions + +Roles are organization-scoped collections of permissions. + +Example roles: + +```text +Owner +Administrator +Project Manager +Engineer +Reviewer +Inspector +Lawyer +Paralegal +Doctor +Nurse +Billing Manager +Viewer +``` + +Roles are not professional credentials. + +### Engineering Permissions + +```text +engineering.clients.read +engineering.clients.create +engineering.clients.update +engineering.clients.archive + +engineering.projects.read +engineering.projects.create +engineering.projects.update +engineering.projects.activate +engineering.projects.close +engineering.projects.archive + +engineering.project_members.manage +engineering.phases.manage +engineering.tasks.manage +engineering.sites.manage + +engineering.documents.read +engineering.documents.upload +engineering.documents.delete + +engineering.designs.read +engineering.designs.create +engineering.designs.update +engineering.designs.review +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede + +engineering.inspections.read +engineering.inspections.manage +engineering.inspections.complete + +engineering.time_entries.manage +engineering.reports.read +``` + +### Legal Permissions + +```text +legal.clients.read +legal.clients.create +legal.clients.update + +legal.matters.read +legal.matters.create +legal.matters.update +legal.matters.close +legal.matters.reopen + +legal.cases.read +legal.cases.manage +legal.hearings.manage +legal.deadlines.manage + +legal.documents.read +legal.documents.upload + +legal.conflicts.manage +legal.conflicts.approve + +legal.retainers.manage +legal.time_entries.manage +``` + +### Healthcare Permissions + +```text +healthcare.patients.read +healthcare.patients.create +healthcare.patients.update + +healthcare.appointments.read +healthcare.appointments.manage + +healthcare.encounters.read +healthcare.encounters.manage + +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.records.access_log.read + +healthcare.prescriptions.read +healthcare.prescriptions.write +healthcare.prescriptions.sign + +healthcare.insurance.read +healthcare.insurance.manage +``` + +### Shared Permissions + +```text +documents.read +documents.upload + +billing.read +invoices.create +invoices.issue +invoices.void +payments.record +payments.refund + +members.read +members.invite +members.update +members.remove + +roles.read +roles.manage + +audit.read +``` + +Avoid vague permissions such as `admin_everything` in normal tenant RBAC. + +## 20. Permission Scopes + +Initial scopes: + +```text +assigned +organization +``` + +Examples: + +```text +Engineer: +engineering.projects.read = assigned + +Principal Engineer: +engineering.projects.read = organization +``` + +Potential future scopes: + +```text +owned +team +department +restricted +``` + +Do not implement until required. + +--- + +## 21. Professional Credentials + +Professional qualification is separate from RBAC. + +Suggested shared profile: + +```text +professional_profiles +``` + +Fields: + +```text +id +organization_id +user_id +profession +title +credential_status +primary_license_number +primary_license_jurisdiction +valid_from +expires_at +created_at +updated_at +``` + +Profession modules may add dedicated credential tables when one generic profile is insufficient. + +### Credential Policy Examples + +Engineering design approval may require: + +```yaml +permission: engineering.designs.approve +credential: + profession_family: engineering + status: verified + active_license: true + jurisdiction_match: when required + discipline_match: when required +``` + +Healthcare record signing may require: + +```yaml +permission: healthcare.records.sign +credential: + profession_allowed_by_policy: true + status: verified + active_license: true + scope_of_practice_allows_action: true + jurisdiction_match: true +``` + +Prescribing must **not** be hard-coded to `profession = doctor` or to a single U.S. credential such as a DEA number. + +Prescribing authority varies by: + +- jurisdiction +- profession +- drug class +- supervising relationship +- organization policy +- credential status + +Therefore use a policy concept such as: + +```text +PrescribingAuthorityPolicy +``` + +rather than a permanent global rule. + +### Cache Safety + +Credential status may be cached briefly for ordinary reads, but high-risk writes such as: + +```text +engineering.designs.approve +healthcare.records.sign +healthcare.prescriptions.sign +``` + +must use authoritative or revocation-aware credential validation. A five-minute stale cache is unacceptable if a license was just suspended. + +## 22. Database Architecture + +Use PostgreSQL. + +Start with: + +```text +One database ++ +Shared schema ++ +Profession-specific tables +``` + +Do not begin with database-per-profession or database-per-customer unless compliance or residency requirements force that choice. + +--- + +## 23. Shared Tables + +Recommended shared tables: + +```text +organizations +organization_professions + +users +user_credentials +sessions + +memberships +membership_invitations + +roles +permissions +role_permissions +membership_roles + +professional_profiles + +documents +document_versions + +invoices +invoice_items +payments + +notifications +notification_deliveries + +audit_events +outbox_events +``` + +--- + +## 24. Multi-Tenancy Rule + +Every tenant-owned row must contain: + +```text +organization_id +``` + +Examples: + +```text +engineering_projects.organization_id +legal_matters.organization_id +healthcare_patients.organization_id +``` + +Enforce tenant boundaries at: + +- API layer +- authorization layer +- repository/query layer +- database constraints + +--- + +## 25. Tenant-Safe Foreign Keys + +Use composite tenant-aware foreign keys when possible. + +Example: + +```text +engineering_projects +organization_id +client_id +``` + +references: + +```text +engineering_clients +organization_id +id +``` + +This prevents linking a resource from one organization to another organization's data. + +--- + +# Engineering Domain + +## 26. Engineering Tables + +Initial tables: + +```text +engineering_clients +engineering_projects +engineering_project_members +engineering_project_phases +engineering_sites +engineering_tasks +engineering_designs +engineering_design_versions +engineering_design_reviews +engineering_inspections +engineering_inspection_findings +engineering_specifications +engineering_change_requests +engineering_time_entries +``` + +--- + +## 27. Engineering Clients + +Suggested core client fields: + +```text +id +organization_id +client_type +display_name +legal_name +status +created_at +updated_at +version +``` + +Do not permanently squeeze all contacts into one `email`, one `phone`, and one `contact_name`. + +Engineering customers commonly have multiple: + +```text +technical contacts +billing contacts +executive contacts +site contacts +contract contacts +``` + +Use: + +```text +engineering_client_contacts +``` + +Suggested contact fields: + +```text +id +organization_id +client_id + +name +title +department + +email +phone + +contact_type +is_primary + +created_at +updated_at +``` + +Client REST: + +```http +GET /api/v1/engineering/clients +POST /api/v1/engineering/clients +GET /api/v1/engineering/clients/{clientId} +PATCH /api/v1/engineering/clients/{clientId} + +POST /api/v1/engineering/clients/{clientId}/archive +POST /api/v1/engineering/clients/{clientId}/restore + +GET /api/v1/engineering/clients/{clientId}/projects +GET /api/v1/engineering/clients/{clientId}/invoices +``` + +Contact REST: + +```http +GET /api/v1/engineering/clients/{clientId}/contacts +POST /api/v1/engineering/clients/{clientId}/contacts +PATCH /api/v1/engineering/clients/{clientId}/contacts/{contactId} +DELETE /api/v1/engineering/clients/{clientId}/contacts/{contactId} +``` + +Delete may be implemented as archival when contact history matters. + +Client restore is allowed only when organization policy and retention rules permit it. + +## 28. Engineering Projects + +Suggested fields: + +```text +id +organization_id +client_id +project_number +name +description +discipline +stage +status +project_manager_user_id +start_date +expected_completion_date +completed_date +budget_minor +currency_code +created_at +updated_at +version +``` + +REST: + +```http +GET /api/v1/engineering/projects +POST /api/v1/engineering/projects +GET /api/v1/engineering/projects/{projectId} +PATCH /api/v1/engineering/projects/{projectId} + +POST /api/v1/engineering/projects/{projectId}/activate +POST /api/v1/engineering/projects/{projectId}/close +POST /api/v1/engineering/projects/{projectId}/archive +``` + +Purpose-built read models may be added when the frontend requires them: + +```http +GET /api/v1/engineering/projects/{projectId}/summary +GET /api/v1/engineering/projects/{projectId}/timeline +GET /api/v1/engineering/projects/{projectId}/budget +``` + +These are read-model endpoints, not necessarily separate aggregate tables. + +Do not put arbitrary budget-breakdown JSON into the core project row merely because the response can display it. Model detailed budget data in dedicated tables when that feature is implemented. + +## 29. Engineering Project Members + +Suggested fields: + +```text +id +organization_id +project_id +user_id +project_role +joined_at +left_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/members +POST /api/v1/engineering/projects/{projectId}/members +PATCH /api/v1/engineering/projects/{projectId}/members/{memberId} +DELETE /api/v1/engineering/projects/{projectId}/members/{memberId} +``` + +--- + +## 30. Engineering Project Phases + +Suggested fields: + +```text +id +organization_id +project_id +name +sequence +status +start_date +end_date +created_at +updated_at +``` + +Typical phases: + +```text +Concept +Preliminary Design +Detailed Design +Construction +Inspection +Closeout +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/phases +POST /api/v1/engineering/projects/{projectId}/phases +PATCH /api/v1/engineering/projects/{projectId}/phases/{phaseId} +POST /api/v1/engineering/projects/{projectId}/phases/{phaseId}/complete +``` + +--- + +## 31. Engineering Sites + +Suggested fields: + +```text +id +organization_id +project_id +name +address +latitude +longitude +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/sites/{siteId} +PATCH /api/v1/engineering/sites/{siteId} +``` + +--- + +## 32. Engineering Tasks + +Suggested fields: + +```text +id +organization_id +project_id +title +description +status +priority +created_by_user_id +assigned_to_user_id +due_at +completed_at +created_at +updated_at +version +``` + +REST: + +```http +POST /api/v1/engineering/tasks +GET /api/v1/engineering/tasks +GET /api/v1/engineering/tasks/{taskId} +PATCH /api/v1/engineering/tasks/{taskId} + +POST /api/v1/engineering/tasks/{taskId}/complete +POST /api/v1/engineering/tasks/{taskId}/reopen +POST /api/v1/engineering/tasks/{taskId}/cancel +``` + +--- + +## 33. Engineering Designs + +Suggested fields: + +```text +id +organization_id +project_id +design_number +title +description +discipline +status +owner_user_id +prepared_by_user_id +approved_by_user_id +approved_at +created_at +updated_at +version +``` + +Suggested states: + +```text +draft +under_review +changes_requested +approved +rejected +cancelled +withdrawn +superseded +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/designs +POST /api/v1/engineering/projects/{projectId}/designs + +GET /api/v1/engineering/designs/{designId} +PATCH /api/v1/engineering/designs/{designId} + +POST /api/v1/engineering/designs/{designId}/submit-review +POST /api/v1/engineering/designs/{designId}/request-changes +POST /api/v1/engineering/designs/{designId}/approve +POST /api/v1/engineering/designs/{designId}/reject +POST /api/v1/engineering/designs/{designId}/cancel +POST /api/v1/engineering/designs/{designId}/withdraw +POST /api/v1/engineering/designs/{designId}/supersede + +POST /api/v1/engineering/designs/{designId}/assign +POST /api/v1/engineering/designs/{designId}/unassign + +GET /api/v1/engineering/designs/{designId}/versions +POST /api/v1/engineering/designs/{designId}/versions + +GET /api/v1/engineering/designs/{designId}/reviews +POST /api/v1/engineering/designs/{designId}/reviews +``` + +### Assignment Model + +Use: + +```text +engineering_design_assignments +``` + +Possible assignment roles: + +```text +owner +designer +reviewer +approver +checker +``` + +Suggested fields: + +```text +id +organization_id +design_id +user_id +assignment_role +notes +assigned_by_user_id +assigned_at +unassigned_at +``` + +Assignment does not automatically grant platform permission. Both RBAC and resource policy still apply. + +### Design State Machine + +```text +draft + ├── submit-review ───────────────► under_review + └── cancel ──────────────────────► cancelled + +under_review + ├── request-changes ─────────────► changes_requested + ├── approve ─────────────────────► approved + ├── reject ──────────────────────► rejected + └── withdraw ────────────────────► withdrawn + +changes_requested + ├── submit-review ───────────────► under_review + └── withdraw ────────────────────► withdrawn + +rejected + └── revise ──────────────────────► draft + +approved + └── supersede ───────────────────► superseded +``` + +Use `cancelled` for work stopped before formal review. + +Use `withdrawn` for work intentionally removed after review workflow has started. + +Approval requires: + +```text +permission ++ +project access ++ +appropriate assignment/policy ++ +valid professional qualification ++ +valid design state ++ +organization approval policy +``` + +Approval, rejection, withdrawal, and supersession are audited. + +Approval is idempotent. + +Do not approve by PATCHing `status`. + +## 34. Design Versions and Reviews + +`engineering_design_versions`: + +```text +id +design_id +version_number +document_id +created_by_user_id +created_at +``` + +`engineering_design_reviews`: + +```text +id +organization_id +design_id +reviewer_user_id +status +comments +reviewed_at +``` + +Possible review statuses: + +```text +pending +approved +changes_requested +rejected +``` + +--- + +## 35. Engineering Inspections + +Suggested fields: + +```text +id +organization_id +project_id +site_id + +inspection_type +inspector_user_id + +status +outcome + +scheduled_at +started_at +performed_at +cancelled_at + +summary + +created_at +updated_at +version +``` + +Lifecycle status: + +```text +draft +scheduled +in_progress +completed +cancelled +``` + +Outcome is separate: + +```text +passed +passed_with_observations +followup_required +failed +``` + +This distinction matters. + +An inspection can be fully completed and still require corrective work. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/inspections +POST /api/v1/engineering/projects/{projectId}/inspections + +GET /api/v1/engineering/inspections/{inspectionId} +PATCH /api/v1/engineering/inspections/{inspectionId} + +POST /api/v1/engineering/inspections/{inspectionId}/schedule +POST /api/v1/engineering/inspections/{inspectionId}/start +POST /api/v1/engineering/inspections/{inspectionId}/complete +POST /api/v1/engineering/inspections/{inspectionId}/cancel + +GET /api/v1/engineering/inspections/{inspectionId}/findings +POST /api/v1/engineering/inspections/{inspectionId}/findings + +POST /api/v1/engineering/inspections/{inspectionId}/followups +GET /api/v1/engineering/inspections/{inspectionId}/followups +``` + +A follow-up may be: + +```text +corrective task +new inspection +or both +``` + +Do not encode all follow-up workflow into the original inspection's lifecycle state. + +Inspection completion: + +1. validate inspector and project access +2. validate required fields +3. validate findings +4. calculate or confirm outcome +5. complete inspection +6. create corrective work/follow-up records when required +7. audit +8. write outbox event +9. notify appropriate participants + +Completion is idempotent. + +## 36. Inspection Findings + +Suggested fields: + +```text +id +inspection_id +severity +description +status +resolved_at +``` + +Possible severities: + +```text +observation +minor +major +critical +``` + +REST: + +```http +POST /api/v1/engineering/inspections/{inspectionId}/findings +PATCH /api/v1/engineering/inspection-findings/{findingId} +POST /api/v1/engineering/inspection-findings/{findingId}/resolve +``` + +--- + +## 37. Engineering Specifications + +Suggested fields: + +```text +id +organization_id +project_id +specification_number +title +version +status +document_id +created_at +updated_at +``` + +--- + +## 38. Engineering Change Requests + +Suggested fields: + +```text +id +organization_id +project_id +request_number +title +description +status +requested_by_user_id +approved_by_user_id +estimated_cost_minor +created_at +updated_at +``` + +--- + +## 38A. Engineering Project Budgets + +A single `budget_minor` column is sufficient only for a very early project total. + +When budget management enters scope, introduce: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries +``` + +### Budget + +Suggested fields: + +```text +id +organization_id +project_id + +name +currency_code +status + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +### Budget Item + +Suggested fields: + +```text +id +organization_id +budget_id + +category +description + +allocated_amount_minor + +created_at +updated_at +``` + +Do not casually store mutable: + +```text +spent_amount_minor +committed_amount_minor +``` + +as independent sources of truth if those values are derived from time entries, expenses, purchase commitments, or invoices. + +Prefer: + +```text +authoritative cost/commitment records + ↓ +derived budget projections +``` + +If denormalized totals are needed for performance, update them transactionally and reconcile them. + +Potential REST: + +```http +GET /api/v1/engineering/projects/{projectId}/budgets +POST /api/v1/engineering/projects/{projectId}/budgets +GET /api/v1/engineering/budgets/{budgetId} +PATCH /api/v1/engineering/budgets/{budgetId} + +POST /api/v1/engineering/budgets/{budgetId}/approve +GET /api/v1/engineering/budgets/{budgetId}/items +POST /api/v1/engineering/budgets/{budgetId}/items +``` + +Budget approval is an explicit command. + +--- + +## 39. Engineering Time Entries + +Suggested fields: + +```text +id +organization_id +project_id +user_id +work_date +duration_minutes +description +billable +billing_rate_minor +currency_code +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries/{id} +PATCH /api/v1/engineering/time-entries/{id} +``` + +Store duration as integer minutes. + +--- + +# Legal Domain + +## 40. Legal Tables + +Initial tables: + +```text +legal_clients +legal_matters +legal_matter_members +legal_cases +legal_case_parties +legal_courts +legal_hearings +legal_deadlines +legal_documents +legal_time_entries +legal_retainers +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +REST namespace: + +```text +/api/v1/legal +``` + +Core examples: + +```http +GET /api/v1/legal/matters +POST /api/v1/legal/matters +GET /api/v1/legal/matters/{matterId} +PATCH /api/v1/legal/matters/{matterId} +POST /api/v1/legal/matters/{matterId}/close +POST /api/v1/legal/matters/{matterId}/reopen + +GET /api/v1/legal/matters/{matterId}/cases +GET /api/v1/legal/matters/{matterId}/documents +GET /api/v1/legal/matters/{matterId}/time-entries +GET /api/v1/legal/matters/{matterId}/invoices + +POST /api/v1/legal/conflict-checks +GET /api/v1/legal/conflict-checks/{conflictCheckId} +POST /api/v1/legal/conflict-checks/{conflictCheckId}/approve +POST /api/v1/legal/conflict-checks/{conflictCheckId}/decline +``` + +Legal remains a later vertical. These endpoints define intended boundaries, not a P0 build commitment. + +## 41. Legal Matters + +Suggested fields: + +```text +id +organization_id +client_id +matter_number +title +practice_area +responsible_lawyer_user_id +status +opened_date +closed_date +created_at +updated_at +``` + +--- + +## 42. Legal Cases + +Suggested fields: + +```text +id +organization_id +matter_id +case_number +court_id +jurisdiction +case_type +status +filed_date +created_at +updated_at +``` + +--- + +## 43. Legal Hearings + +Suggested fields: + +```text +id +organization_id +case_id +hearing_type +scheduled_at +courtroom +judge +status +notes +``` + +--- + +## 44. Legal Conflict Checks + +Suggested tables: + +```text +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +Conflict-check fields: + +```text +id +organization_id +potential_client_name +matter_description +requested_by_user_id +reviewed_by_user_id +status +decision +decision_reason +created_at +reviewed_at +version +``` + +Request example: + +```json +{ + "potentialClientName": "Acme Corporation", + "relatedParties": [ + { + "name": "John Smith", + "relationship": "CEO" + }, + { + "name": "Acme Subsidiary LLC", + "relationship": "Subsidiary" + } + ], + "matterDescription": "Corporate acquisition" +} +``` + +Response may contain possible matches: + +```json +{ + "data": { + "id": "conflict_123", + "status": "pending_review", + "potentialConflicts": [ + { + "type": "possible_direct_adversity", + "partyName": "Acme Corporation", + "existingMatterId": "matter_456", + "existingMatterNumber": "MAT-2026-089" + } + ] + } +} +``` + +The system should distinguish: + +```text +automated possible match +``` + +from: + +```text +lawyer-approved conflict determination +``` + +The software may assist discovery; it should not silently make the professional judgment. + +Approvals and declines are auditable commands. + +## 45. Healthcare Tables + +Initial tables: + +```text +healthcare_patients +healthcare_patient_contacts +healthcare_patient_addresses +healthcare_practitioners +healthcare_appointments +healthcare_encounters +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +healthcare_diagnoses +healthcare_prescriptions +healthcare_insurance_policies +healthcare_allergies +healthcare_medications +``` + +REST namespace: + +```text +/api/v1/healthcare +``` + +Examples: + +```http +GET /api/v1/healthcare/patients +POST /api/v1/healthcare/patients +GET /api/v1/healthcare/patients/{patientId} +PATCH /api/v1/healthcare/patients/{patientId} +POST /api/v1/healthcare/patients/{patientId}/archive + +GET /api/v1/healthcare/patients/{patientId}/appointments +GET /api/v1/healthcare/patients/{patientId}/encounters +GET /api/v1/healthcare/patients/{patientId}/clinical-records +GET /api/v1/healthcare/patients/{patientId}/prescriptions +GET /api/v1/healthcare/patients/{patientId}/allergies + +POST /api/v1/healthcare/encounters +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend +``` + +Healthcare is intentionally not treated as ordinary CRM plus extra columns. + +## 46. Healthcare Patients + +Core patient fields: + +```text +id +organization_id +patient_number +first_name +middle_name +last_name +date_of_birth +sex_or_administrative_gender_as_required +status +created_at +updated_at +version +``` + +Do not make a single default patient DTO return every available PHI field. + +Use minimum-necessary response shapes. + +Example general patient response: + +```json +{ + "data": { + "id": "patient_123", + "patientNumber": "PAT-2026-001", + "name": { + "firstName": "Alice", + "middleName": "Marie", + "lastName": "Johnson" + }, + "dateOfBirth": "1985-03-15", + "status": "active", + "version": 2 + } +} +``` + +More sensitive subresources should have separate permissions and endpoints where useful: + +```text +contact information +addresses +emergency contacts +insurance policies +clinical records +prescriptions +``` + +Do not return insurance member IDs or emergency contact details on every patient read merely because the database has them. + +## 47. Healthcare Practitioners + +Suggested fields: + +```text +id +organization_id +user_id +specialty +license_number +license_jurisdiction +credential_status +created_at +updated_at +``` + +--- + +## 48. Healthcare Appointments + +Suggested fields: + +```text +id +organization_id +patient_id +practitioner_id +appointment_type +starts_at +ends_at +status +reason +created_at +updated_at +``` + +--- + +## 49. Healthcare Encounters + +Suggested fields: + +```text +id +organization_id +patient_id +practitioner_id +appointment_id +encounter_type +started_at +ended_at +status +``` + +--- + +## 50. Clinical Records + +Suggested tables: + +```text +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +``` + +Core record fields: + +```text +id +organization_id +patient_id +encounter_id +author_practitioner_id +record_type +sensitivity_level +status +signed_by_practitioner_id +signed_at +created_at +updated_at +version +``` + +Draft content may be editable according to workflow. + +Once signed/finalized: + +- do not overwrite history +- create amendments or new versions +- preserve previous signed content +- audit reads when policy requires +- audit all writes/signatures/amendments + +REST: + +```http +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} + +PATCH /api/v1/healthcare/clinical-records/{recordId} +# Only when editable/draft according to policy. + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend + +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log +``` + +Clinical content representation should be designed around actual healthcare requirements and interoperability needs rather than permanently committing to one ad-hoc JSON SOAP-note structure. + +Sensitive record access should support an `accessReason` when organization or regulatory policy requires it. + +## 51. Documents + +Use shared object storage. + +Database: + +```text +documents +document_versions +document_categories +retention_policies +``` + +Binary data: + +```text +S3-compatible object storage +``` + +### Document + +Suggested fields: + +```text +id +organization_id + +name +category_id + +classification + +retention_policy_id + +current_version_id + +created_by_user_id +created_at +updated_at +``` + +Classification examples: + +```text +public +internal +confidential +restricted +regulated +``` + +Avoid a single `is_confidential` boolean as the long-term security model. + +### Document Version + +Suggested fields: + +```text +id +organization_id +document_id + +version_number + +storage_key + +mime_type +size_bytes + +content_hash +hash_algorithm + +uploaded_by_user_id +created_at +``` + +The authoritative checksum belongs on the version because each binary revision has different content. + +Optional document-level metadata may include: + +```text +current_version_id +current_version_number +``` + +but should not replace version-level integrity data. + +### Metadata + +Use JSONB only for genuinely extensible metadata that does not deserve stable relational columns. + +Examples: + +```text +CAD-specific extraction results +scanner metadata +non-authoritative document properties +``` + +Do not place access control, retention state, ownership, or lifecycle rules inside arbitrary metadata JSON. + +### Document Categories + +Suggested fields: + +```text +id +organization_id +profession nullable +name +parent_category_id +created_at +``` + +If `profession` is nullable and shared categories must remain unique, PostgreSQL uniqueness must explicitly handle nulls. + +Options include: + +```text +UNIQUE NULLS NOT DISTINCT +``` + +where supported, or separate partial unique indexes for: + +```text +profession IS NULL +profession IS NOT NULL +``` + +Do not rely on a plain nullable composite unique constraint and assume NULL behaves like a normal value. + +### Upload Security + +Validate: + +```text +declared MIME +extension +magic bytes/content signature +file size +malware scan +organization quota +classification policy +``` + +A renamed executable is not a PDF merely because the filename developed ambition. + +## 52. Document Upload Flow + +```text +Frontend + ↓ +Request upload authorization + ↓ +Backend validates tenant + permission + upload policy + ↓ +Create pending document/version + ↓ +Return signed upload URL + ↓ +Frontend uploads directly to object storage + ↓ +Backend finalizes upload + ↓ +Verify size / checksum / content type + ↓ +Malware and security scanning + ↓ +Apply classification / retention + ↓ +Mark version available +``` + +REST: + +```http +POST /api/v1/documents/upload-url +POST /api/v1/documents/{documentId}/complete-upload + +GET /api/v1/documents/{documentId} +GET /api/v1/documents/{documentId}/download-url + +POST /api/v1/documents/{documentId}/versions +``` + +### File Policy + +Do not freeze arbitrary product quotas into architecture. + +Model a policy: + +```text +document_upload_policy +├── max_file_size_bytes +├── allowed_file_classes +├── organization_storage_quota_bytes +├── profession_overrides +└── plan/tier overrides +``` + +Engineering may eventually allow file classes such as: + +```text +PDF +images +DWG/DXF +spreadsheets +office documents +``` + +Healthcare may later have different file policies. + +Exact limits are product/configuration decisions validated against storage cost, threat model, and customer needs. + +## 53. Profession-Specific Document Links + +Use explicit relationship tables. + +Engineering: + +```text +engineering_project_documents +engineering_design_documents +engineering_inspection_documents +``` + +Legal: + +```text +legal_matter_documents +legal_case_documents +``` + +Healthcare: + +```text +healthcare_patient_documents +healthcare_encounter_documents +``` + +### Engineering Project Documents + +Suggested link fields: + +```text +id +organization_id +project_id +document_id + +category +classification_override nullable + +linked_by_user_id +linked_at +unlinked_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/documents +POST /api/v1/engineering/projects/{projectId}/documents + +DELETE /api/v1/engineering/project-documents/{documentLinkId} +``` + +Link deletion may preserve historical linkage through `unlinked_at` when required. + +Example response: + +```json +{ + "data": [ + { + "documentLinkId": "projdoc_123", + "category": "calculations", + "document": { + "id": "doc_456", + "name": "structural_calculations.pdf", + "classification": "confidential", + "currentVersion": 2, + "mimeType": "application/pdf", + "sizeBytes": 2457600 + } + } + ] +} +``` + +Explicit link resources give stronger referential integrity than generic polymorphic foreign keys. + +## 54. Billing + +Shared financial core: + +```text +invoices +invoice_items +payments +``` + +Profession-specific modules may extend billing workflows. + +Engineering examples: + +```text +project billing +hourly billing +milestone billing +``` + +Legal examples: + +```text +matter billing +time billing +retainers +trust accounting +``` + +Healthcare examples: + +```text +insurance +claims +patient billing +``` + +REST: + +```http +POST /api/v1/invoices +GET /api/v1/invoices +GET /api/v1/invoices/{invoiceId} +PATCH /api/v1/invoices/{invoiceId} + +POST /api/v1/invoices/{invoiceId}/issue +POST /api/v1/invoices/{invoiceId}/void +POST /api/v1/invoices/{invoiceId}/payments +POST /api/v1/payments/{paymentId}/refund +``` + +--- + +## 55. Money Representation + +Use integer minor units: + +```json +{ + "amountMinor": 12550, + "currency": "USD" +} +``` + +Meaning: + +```text +$125.50 +``` + +Never use floating point for money. + +--- + +## 56. Audit Logging + +Table: + +```text +audit_events +``` + +Suggested fields: + +```text +id +organization_id +actor_type +actor_user_id +actor_service_account_id +action +resource_type +resource_id +request_id +correlation_id +ip_address +user_agent +metadata +occurred_at +``` + +Audit events are append-only. + +### Mandatory Engineering Audit Events + +```text +engineering.projects.create +engineering.projects.close +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede +engineering.inspections.complete +``` + +### Mandatory Legal Audit Events + +```text +legal.matters.create +legal.matters.close +legal.matters.reopen +legal.conflicts.approve +legal.conflicts.decline +legal.retainers.manage +``` + +### Mandatory Healthcare Audit Events + +```text +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.prescriptions.write +healthcare.prescriptions.sign +``` + +Example: + +```json +{ + "id": "audit_123", + "organizationId": "org_456", + "actorUserId": "user_789", + "action": "healthcare.records.read", + "resourceType": "healthcare_clinical_record", + "resourceId": "record_456", + "requestId": "req_abc", + "ipAddress": "192.0.2.10", + "userAgent": "Mozilla/5.0", + "metadata": { + "patientId": "patient_123", + "recordType": "progress_note", + "accessReason": "clinical_review" + }, + "occurredAt": "2026-08-26T01:30:00Z" +} +``` + +Audit metadata must never contain: + +- passwords +- access or refresh tokens +- full clinical note content +- secret keys +- unnecessary payment data + +REST: + +```http +GET /api/v1/audit-events +``` + +No public create/update/delete endpoints. + +## 57. Domain Events and Transactional Outbox + +Profession modules produce internal domain events. + +Examples: + +```text +engineering.project.created +engineering.design.approved +engineering.inspection.completed + +legal.matter.closed +legal.conflict_check.approved + +healthcare.appointment.created +healthcare.clinical_record.signed + +invoice.issued +payment.recorded +``` + +Consumers: + +```text +notifications +webhooks +analytics +search indexing +integrations +background workflows +``` + +Use: + +```text +outbox_events +``` + +Suggested fields: + +```text +id +organization_id +event_type +aggregate_type +aggregate_id +payload +occurred_at +available_at +processed_at +attempt_count +last_error +dead_lettered_at +``` + +Transaction: + +```text +BEGIN + +business change +audit event +outbox event + +COMMIT +``` + +The outbox is **at-least-once delivery**, not magically exactly-once. + +Worker claim example: + +```sql +SELECT id +FROM outbox_events +WHERE processed_at IS NULL + AND dead_lettered_at IS NULL + AND available_at <= now() +ORDER BY occurred_at +FOR UPDATE SKIP LOCKED +LIMIT 100; +``` + +Worker responsibilities: + +1. claim committed event +2. process consumer action +3. mark processed on success +4. increment attempts on failure +5. schedule retry with backoff +6. dead-letter after policy threshold +7. emit metrics +8. preserve replay/debug metadata + +### Critical Failure Case + +A worker may: + +```text +perform external side effect + ↓ +crash + ↓ +fail to mark event processed + ↓ +event is retried +``` + +Therefore every external consumer must support idempotency. + +Examples: + +```text +payment provider command → provider idempotency key +webhook delivery → delivery/event ID +email notification → dedupe key if duplicate mail is unacceptable +search indexing → upsert by entity/version +``` + +`FOR UPDATE SKIP LOCKED` prevents concurrent claims. It does **not** prevent duplicate side effects after a crash. + +Workers may be awakened by queue notifications, but must still poll durable outbox state so lost wake-ups do not strand events. + +## 57A. Webhooks and External Integrations + +Webhooks are a shared platform capability, not profession-specific transport code. + +Configuration REST: + +```http +GET /api/v1/webhooks +POST /api/v1/webhooks +GET /api/v1/webhooks/{webhookId} +PATCH /api/v1/webhooks/{webhookId} +DELETE /api/v1/webhooks/{webhookId} + +POST /api/v1/webhooks/{webhookId}/test +POST /api/v1/webhooks/{webhookId}/rotate-secret +``` + +Delivery REST: + +```http +GET /api/v1/webhook-deliveries +GET /api/v1/webhook-deliveries/{deliveryId} +POST /api/v1/webhook-deliveries/{deliveryId}/retry +``` + +Suggested tables: + +```text +webhooks +webhook_event_subscriptions +webhook_deliveries +``` + +Webhook fields: + +```text +id +organization_id +url +status +secret_ciphertext or signing_key_reference +created_by_user_id +created_at +updated_at +``` + +Do not return a secret hash to the client. + +### Secret Handling + +If using symmetric HMAC signing: + +```text +generate secret + ↓ +show plaintext once + ↓ +encrypt using KMS/key-management system + ↓ +store ciphertext + ↓ +decrypt only for signing +``` + +A one-way hash alone is insufficient because the server must possess the signing material. + +Alternative: + +```text +asymmetric signing ++ +published verification key +``` + +### Delivery Model + +Each delivery records: + +```text +id +organization_id +webhook_id +event_id + +attempt_number +request_timestamp +response_status +response_summary + +delivered_at +failed_at +next_attempt_at +``` + +Webhook workers require: + +```text +timeouts +retry with backoff +dead-letter/failure state +request signing +event IDs +idempotency guidance for consumers +delivery history +manual replay +``` + +Events should include stable identifiers so consumers can deduplicate. + +Example: + +```json +{ + "id": "evt_123", + "type": "engineering.design.approved", + "organizationId": "org_456", + "occurredAt": "2026-08-26T12:00:00Z", + "data": { + "designId": "design_789" + } +} +``` + +--- + +## 58. Background Jobs + +Workers handle: + +```text +Email +SMS +Notifications + +PDF/report generation + +File security scanning +Document processing + +Imports +Exports +Bulk updates + +Webhook delivery +Search indexing + +Large data operations +``` + +Architecture: + +```text +API + ↓ +Queue + ↓ +Worker +``` + +### Async Job Resource + +Use a shared job model for long-running user-requested operations. + +Suggested table: + +```text +jobs +``` + +Fields: + +```text +id +organization_id +requested_by_user_id + +job_type +status + +input_reference +result_reference + +progress_percent + +created_at +started_at +completed_at +failed_at + +error_code +error_summary +``` + +States: + +```text +queued +running +completed +failed +cancelled +``` + +REST: + +```http +GET /api/v1/jobs/{jobId} +GET /api/v1/jobs/{jobId}/result +POST /api/v1/jobs/{jobId}/cancel +``` + +### Import / Export + +Do not create asynchronous side effects with `GET`. + +Engineering examples: + +```http +POST /api/v1/engineering/project-imports +POST /api/v1/engineering/project-exports + +POST /api/v1/engineering/time-entry-imports +POST /api/v1/engineering/time-entry-exports +``` + +Response: + +```http +202 Accepted +``` + +```json +{ + "data": { + "jobId": "job_123", + "status": "queued" + } +} +``` + +Initial formats may include: + +```text +CSV +JSON +``` + +Import requirements: + +```text +validation report +row-level errors +all-or-partial mode explicitly defined +idempotency strategy +audit event +job result artifact +``` + +Export requirements: + +```text +authorization applied before generation +signed result URL +expiration +audit where data sensitivity requires it +``` + +## 59. Redis + +Use Redis as an acceleration and coordination layer, not the authoritative system of record. + +Appropriate uses: + +```text +job queue +rate-limit counters +short-lived authorization caches +organization configuration cache +session lookup acceleration +idempotency lookup acceleration +distributed locks when justified +``` + +### Cache Layers + +L1 optional application-memory cache: + +```text +static permission definitions +non-sensitive configuration +``` + +L2 Redis shared cache: + +```text +organization settings +membership snapshots +role permission snapshots +rate-limit counters +session lookup cache +recent idempotency lookups +``` + +CDN: + +```text +frontend static assets +explicitly public assets only +``` + +Do not cache private professional API responses at a CDN by default. + +### Cache Invalidation + +Invalidate or version caches when: + +```text +membership changes +role permissions change +organization settings change +professional credentials change +session is revoked +profession module enablement changes +``` + +High-risk authorization decisions must not depend solely on stale cached credential state. + +### Idempotency Durability + +Redis may improve idempotency lookup latency, but PostgreSQL remains authoritative for high-risk commands. + +## 60. Pagination + +Use cursor pagination. + +Example: + +```http +GET /api/v1/engineering/projects?limit=25 +``` + +Response: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": "...", + "hasMore": true + } + } +} +``` + +Maximum page size: + +```text +100 +``` + +--- + +## 61. Filtering + +Use explicit resource-specific filters. + +Examples: + +```http +GET /api/v1/engineering/projects?status=active&discipline=structural +GET /api/v1/engineering/tasks?status=todo&assignedToUserId=user_123 +``` + +Do not build a generic query DSL in v1. + +--- + +## 62. Sorting + +Examples: + +```http +GET /api/v1/engineering/projects?sort=createdAt +GET /api/v1/engineering/projects?sort=-createdAt +``` + +Only explicitly supported fields may be sorted. + +--- + +## 63. Search + +Start with PostgreSQL search. + +Engineering search may cover: + +```text +project number +project name +client name +``` + +Legal: + +```text +matter number +client +case number +``` + +Healthcare: + +```text +patient number +patient identity +``` + +Healthcare search requires stricter privacy and authorization controls. + +Potential PostgreSQL capabilities: + +- B-tree indexes for exact/filter queries +- PostgreSQL full-text search where appropriate +- `pg_trgm` only when fuzzy search requirements justify it + +Do not introduce Elasticsearch/OpenSearch until real query volume, relevance requirements, or indexing features justify another distributed system. + +Do not create every conceivable search index on day one. Indexes cost memory, storage, and write performance. + +## 64. Optimistic Concurrency + +Important mutable resources should use a version field. + +Example: + +```json +{ + "id": "project_123", + "version": 6 +} +``` + +Update: + +```json +{ + "version": 6, + "name": "Central Tower Phase II" +} +``` + +If the current database version differs: + +```text +409 CONCURRENT_MODIFICATION +``` + +--- + +## 65. Domain-Oriented REST + +Important state transitions use explicit command endpoints. + +Good: + +```http +POST /engineering/projects/{id}/close +POST /engineering/designs/{id}/approve +POST /engineering/tasks/{id}/complete +POST /engineering/inspections/{id}/complete +POST /invoices/{id}/issue +``` + +Avoid: + +```http +PATCH /resource/{id} +{ + "status": "approved" +} +``` + +when the change has significant rules or side effects. + +--- + +## 66. Transaction Boundaries + +Create project: + +```text +BEGIN + +create project +assign project manager +write audit event +write outbox event + +COMMIT +``` + +Approve design: + +```text +BEGIN + +validate permission +validate project access +validate credentials +validate design state +create review result +mark approved +write audit event +write outbox event + +COMMIT +``` + +--- + +## 67. Request Context + +Every authenticated request should resolve: + +```text +RequestContext +{ + requestId + userId + sessionId + organizationId + membershipId + permissions +} +``` + +Profession modules consume this context. + +--- + +## 68. Request IDs + +Every request has: + +```http +X-Request-Id +``` + +If missing, the server generates one. + +Use it in: + +- logs +- audit context +- error diagnostics +- asynchronous correlation + +--- + +## 69. OpenAPI + +Maintain: + +```text +openapi.yaml +``` + +Use OpenAPI 3.1. + +Production server example: + +```yaml +servers: + - url: https://api.example.com/api/v1 +``` + +The server URL and path definitions must remain consistent with the platform base path. + +OpenAPI defines: + +- routes +- request DTOs +- response DTOs +- security schemes +- organization header +- request IDs +- idempotency header +- pagination +- filters +- error schemas +- examples +- profession tags + +Security scheme: + +```yaml +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +``` + +Reusable headers/parameters: + +```text +X-Organization-Id +X-Request-Id +Idempotency-Key +limit +cursor +``` + +CI must validate the OpenAPI document. + +Contract tests should detect drift between implementation and specification. + +Generated clients may be used by the separate frontends, but generated transport code should not dictate frontend domain architecture. + +## 70. DTO Rule + +Database models are not public API contracts. + +Use: + +```text +Request DTO +Response DTO +``` + +A database migration should not accidentally change the public API. + +--- + +## 71. Backend Module Structure + +Recommended: + +```text +src/ +├── core/ +│ ├── auth/ +│ ├── organizations/ +│ ├── memberships/ +│ ├── authorization/ +│ ├── documents/ +│ ├── billing/ +│ ├── audit/ +│ └── events/ +│ +├── engineering/ +│ ├── clients/ +│ ├── projects/ +│ ├── project-members/ +│ ├── phases/ +│ ├── sites/ +│ ├── tasks/ +│ ├── designs/ +│ ├── inspections/ +│ └── specifications/ +│ +├── legal/ +│ ├── clients/ +│ ├── matters/ +│ ├── cases/ +│ ├── hearings/ +│ ├── conflicts/ +│ └── retainers/ +│ +└── healthcare/ + ├── patients/ + ├── practitioners/ + ├── appointments/ + ├── encounters/ + ├── records/ + └── prescriptions/ +``` + +--- + +## 72. Internal Module Structure + +Example: + +```text +projects/ +├── domain/ +│ ├── project.entity.ts +│ ├── project-status.ts +│ └── project.errors.ts +│ +├── application/ +│ ├── commands/ +│ │ ├── create-project.ts +│ │ ├── update-project.ts +│ │ └── close-project.ts +│ │ +│ └── queries/ +│ ├── get-project.ts +│ └── list-projects.ts +│ +├── infrastructure/ +│ └── project.repository.ts +│ +└── api/ + ├── project.controller.ts + ├── project.request.ts + └── project.response.ts +``` + +--- + +## 73. Controllers + +Controllers should handle: + +```text +HTTP +authentication context +input DTO parsing +application command/query invocation +response mapping +``` + +Controllers should not contain: + +```text +business rules +raw SQL +role logic +transaction orchestration +email sending +audit implementation +``` + +--- + +## 74. Commands and Queries + +Mutations use commands. + +Examples: + +```text +CreateEngineeringProjectCommand +ApproveEngineeringDesignCommand +CloseLegalMatterCommand +CompleteHealthcareEncounterCommand +``` + +Reads use queries. + +Examples: + +```text +GetEngineeringProjectQuery +ListLegalMattersQuery +GetHealthcarePatientQuery +``` + +--- + +## 75. Repositories + +Use domain-specific repositories. + +Examples: + +```text +EngineeringProjectRepository +LegalMatterRepository +HealthcarePatientRepository +``` + +Avoid one massive generic repository abstraction that eventually needs dozens of flags. + +--- + +## 76. Security Baseline + +Minimum controls: + +```text +TLS everywhere +Argon2id or equivalent strong password hashing +short-lived access tokens +refresh-token rotation +refresh-token reuse detection +server-side session revocation + +rate limiting +anti-automation controls + +RBAC +resource policies +credential-aware authorization +tenant isolation + +input validation +SQL injection protection + +signed object-storage URLs +file-content validation +malware scanning + +audit trails +secret management +encryption at rest + +dependency scanning +container/image scanning +security headers + +request/correlation IDs +backup and restore testing +``` + +### Rate Limiting + +Model policy rather than baking arbitrary numbers into architecture. + +Example: + +```typescript +interface RateLimitRule { + routePattern: string; + method: string; + windowSeconds: number; + maxRequests: number; + scope: 'user' | 'organization' | 'ip' | 'email' | 'session'; +} +``` + +Policy classes: + +```text +authentication +password recovery +general API +search +upload authorization +report generation +webhooks/integrations +clinical record reads +``` + +Rate-limit values are configuration derived from: + +```text +security testing +load testing +observed traffic +customer tier +endpoint cost +abuse risk +``` + +Do not grant normal tenant roles blanket rate-limit bypass. + +Administrative exceptions, if any, require explicit trusted-system policy. + +Return: + +```http +429 Too Many Requests +Retry-After: ... +``` + +Error: + +```text +RATE_LIMIT_EXCEEDED +``` + +### Secrets + +Use managed secrets/key management where possible. + +Never put real secrets in source-controlled examples. + +Prefer JWT asymmetric signing or managed signing keys with rotation capability. + +## 77. Data Classification + +Suggested classes: + +### Public + +```text +marketing configuration +``` + +### Internal + +```text +organization settings +tasks +``` + +### Confidential + +```text +engineering documents +legal matters +billing +``` + +### Highly Sensitive + +```text +clinical records +professional credentials +authentication secrets +``` + +--- + +## 78. Healthcare Security + +Before healthcare production use, define: + +```text +privacy model +minimum-necessary access model +clinical access policies +break-glass/emergency access policy if required +audit policy +record-signing policy +amendment policy +retention policy +credential policy +scope-of-practice policy +jurisdiction requirements +encryption strategy +consent requirements +data residency requirements +backup/restore handling +export/portability requirements +breach-response requirements +``` + +Healthcare is a stricter security tier. + +Key rules: + +1. default patient responses do not contain all available PHI +2. clinical record reads may be auditable events +3. signed records are immutable except through explicit amendment/version workflows +4. prescribing authorization is jurisdiction-specific +5. privileged clinical commands revalidate professional authority +6. caches must not allow revoked credentials to remain effective for high-risk writes +7. healthcare search results themselves are protected data +8. access logs may require dedicated permissions +9. do not claim regulatory compliance from architecture alone + +## 79. Observability + +Use: + +```text +structured logs +metrics +distributed tracing +request IDs +correlation IDs +``` + +Recommended: + +```text +OpenTelemetry +``` + +### Core Metrics + +API: + +```text +api_requests_total +api_errors_total +api_request_duration_seconds +``` + +Authentication: + +```text +auth_login_attempts_total +auth_token_refresh_total +auth_refresh_reuse_detections_total +auth_sessions_revoked_total +``` + +Authorization/security: + +```text +cross_tenant_access_attempts_total +tenant_isolation_invariant_failures_total +authorization_denials_total +credential_policy_denials_total +rate_limit_events_total +``` + +Important distinction: + +```text +cross_tenant_access_attempt += +request attempted another tenant's resource +``` + +This may be a stale link, mistake, or attack. + +```text +tenant_isolation_invariant_failure += +our system nearly or actually created/returned cross-tenant data +``` + +That is a high-severity internal correctness/security incident. + +Outbox/jobs/webhooks: + +```text +outbox_events_pending +outbox_events_failed_total +outbox_processing_duration_seconds + +jobs_queued +jobs_failed_total +job_duration_seconds + +webhook_delivery_attempts_total +webhook_delivery_failures_total +webhook_delivery_latency_seconds +``` + +Database: + +```text +db_pool_active +db_pool_waiting +db_query_duration_seconds +db_transaction_duration_seconds +``` + +Business metrics may include: + +```text +engineering_projects_created_total +engineering_designs_approved_total +engineering_inspections_completed_total +invoices_issued_total +``` + +Avoid patient-specific or sensitive identifiers in metric labels. + +### Alerts + +Examples: + +```text +refresh token reuse detected +tenant isolation invariant failure +outbox backlog exceeds SLO +webhook failure spike +database pool saturation +error-rate spike +latency regression +backup failure +malware scanner unavailable +``` + +Thresholds are calibrated from real environments rather than copied from a review document. + +### SLOs + +Define by endpoint class. + +Interactive CRUD, reports, file orchestration, and background jobs should not share one arbitrary latency target. + +## 80. Logging + +Useful fields: + +```text +request_id +route +method +status +duration +user_id when appropriate +organization_id when appropriate +``` + +Never log: + +```text +passwords +tokens +clinical record text +full sensitive documents +payment secrets +``` + +--- + +## 81. Testing Strategy + +### Unit Tests + +Test: + +```text +domain rules +state transitions +authorization policies +credential policies +money calculations +idempotency request hashing +``` + +### Property-Based Tests + +Use property-based testing for high-value domain state machines. + +Candidates: + +```text +engineering design lifecycle +engineering inspection lifecycle +invoice lifecycle +payment state transitions +membership/role invariants +``` + +Correct properties: + +```text +every successful transition ends in a valid state + +every forbidden transition is rejected + +terminal states reject prohibited actions + +required invariants survive every valid transition + +transition sequences never bypass required approval/credential rules +``` + +Do not assert that every random state/action pair succeeds. Many are supposed to fail. + +### Integration Tests + +Test: + +```text +repositories +tenant-aware foreign keys +PostgreSQL constraints +transactions +outbox persistence +idempotency persistence +cache invalidation +job persistence +webhook delivery persistence +``` + +### API Tests + +Every important endpoint covers: + +```text +happy path +request validation +authentication +organization context +permission denial +scope denial +credential denial where relevant +cross-tenant access +concurrent modification +invalid state transition +idempotent replay +idempotency conflict +audit creation +outbox creation +``` + +### Outbox Reliability / Chaos Tests + +Test: + +```text +worker crash before side effect +worker crash after side effect but before marking processed +two workers competing for same row +temporary dependency outage +retry/backoff behavior +dead-letter behavior +consumer idempotency +lost worker wake-up +replay +``` + +The dangerous scenario is: + +```text +external side effect succeeds +worker dies +event retries +``` + +Tests must prove the consumer does not create an unacceptable duplicate. + +### Tenant Security Tests + +Test both: + +```text +external cross-tenant access attempts +``` + +and: + +```text +internal cross-tenant data invariant failures +``` + +These are different classes of failure. + +### Performance Tests + +Create realistic profiles: + +```text +interactive reads +interactive writes +search +dashboard read models +reporting +file upload orchestration +outbox processing +webhook bursts +notification bursts +``` + +Measure: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +``` + +Set production SLO gates only after a realistic baseline exists. + +### Coverage + +Track code coverage. + +Do not treat a single percentage such as `90%` as proof of quality. + +Critical-path expectations are stronger: + +```text +all tenant-isolation paths tested +all financial commands tested +all regulated commands tested +all state transitions tested +all critical authorization policies tested +``` + +## 82. Tenant Security Tests + +For every major resource, attempt: + +```text +Organization A resource +using Organization B context +``` + +Test: + +```text +read +update +delete/action +list filtering +search +documents +``` + +Expected result: + +```text +404 / denied +``` + +--- + +## 83. Engineering MVP + +Engineering is the first vertical. + +Initial features: + +```text +Authentication +Organization management +Users / memberships / roles +Engineering clients +Projects +Project members +Project phases +Tasks +Sites +Documents +Basic design records +Inspections +Time entries +Basic billing +Audit history +``` + +Do not initially build: + +```text +advanced CAD integration +BIM integration +full document markup +advanced resource planning +procurement +complex accounting +AI design analysis +IoT integrations +``` + +--- + +## 84. Engineering MVP Workflow + +```text +User registers + ↓ +Creates engineering organization + ↓ +Invites engineer + ↓ +Assigns role + ↓ +Creates client + ↓ +Creates project + ↓ +Assigns project team + ↓ +Creates project phases + ↓ +Creates tasks + ↓ +Uploads documents + ↓ +Creates design + ↓ +Reviews / approves design + ↓ +Schedules inspection + ↓ +Records inspection findings + ↓ +Records engineering time + ↓ +Creates invoice + ↓ +Records payment + ↓ +Closes project + ↓ +Audit history contains lifecycle +``` + +--- + +## 85. Development Phases + +### Phase 0: Architecture Foundation + +Deliver: + +```text +domain boundaries +database conventions +REST conventions +authorization model +organization-context rules +session/token model +idempotency strategy +error taxonomy +OpenAPI skeleton +engineering state machines +migration conventions +threat model +``` + +### Phase 1: Shared Platform Core + +Build: + +```text +auth +sessions +refresh-token families +token rotation/revocation + +users +organizations +organization professions + +membership invitations +memberships +roles +permissions +authorization + +audit +outbox + +request context +idempotency persistence +rate-limit framework + +observability foundation +``` + +### Phase 2: Engineering CRM + +Build: + +```text +engineering_clients +engineering_client_contacts +client archive/restore +``` + +### Phase 3: Engineering Projects + +Build: + +```text +engineering_projects +engineering_project_members +engineering_project_phases +activation/close/archive +``` + +### Phase 4: Work and Site Management + +Build: + +```text +engineering_tasks +engineering_sites +``` + +### Phase 5: Documents + +Build: + +```text +documents +document_versions +document categories +classification +retention policy references +signed uploads +content verification +malware scanning +engineering document links +``` + +### Phase 6: Engineering Designs + +Build: + +```text +engineering_designs +design assignments +design versions +design reviews +state machine +credential-aware approval +audit +outbox +idempotency +``` + +### Phase 7: Engineering Inspections + +Build: + +```text +inspection lifecycle +inspection outcome +findings +corrective work +follow-up inspections +attachments +audit +outbox +idempotency +``` + +### Phase 8: Time, Budgets, and Billing + +Build: + +```text +engineering_time_entries +project budgets when in product scope +invoices +invoice items +payments +financial idempotency +reconciliation +``` + +### Phase 9: Notifications, Jobs, and Webhooks + +Build: + +```text +in-app notifications +email +async jobs +imports/exports +webhook configuration +webhook delivery/retry +dead-letter handling +``` + +### Phase 10: Reporting and Search + +Build: + +```text +project status +overdue work +inspection status +billable time +revenue +outstanding invoices +engineering dashboard read models +``` + +Add advanced indexes, materialized views, read replicas, or external search only if measured need justifies them. + +### Phase 11: Legal Vertical + +Validate the shared core against: + +```text +matters +cases +conflicts +legal deadlines +retainers +ethical-wall/restricted access requirements +``` + +### Phase 12: Healthcare Readiness and Vertical + +Before implementation: + +```text +healthcare threat model +privacy review +jurisdiction analysis +credential/scope-of-practice policy +record signing/amendment model +retention model +audit requirements +``` + +Then implement healthcare. + +### Estimation Rule + +These are dependency-ordered milestones, not calendar promises. + +Calendar estimates are produced only after: + +```text +team size +frontend scope +UX designs +cloud choices +third-party providers +security requirements +QA capacity +engineering-domain details +``` + +are known. + +## 86. Legal Expansion + +Only after engineering proves the shared platform assumptions. + +Build: + +```text +Legal Client + ↓ +Matter + ↓ +Case + ↓ +Hearings / Deadlines / Documents +``` + +Do not redesign engineering around legal terminology. + +Extract only genuinely reusable infrastructure. + +--- + +## 87. Healthcare Expansion + +Healthcare comes after: + +- core platform is stable +- audit model is proven +- permission model is proven +- tenant isolation is tested +- retention and encryption strategies are defined + +Healthcare should be treated as its own security and compliance workstream. + +--- + +## 88. Deployment Environments + +Use: + +```text +development +testing +staging +production +``` + +Each environment has independent: + +```text +database +object storage +secrets +queues +API keys +``` + +--- + +## 89. Initial Deployment Architecture + +```text +CDN + │ + ├── Engineering Web + ├── Legal Web + └── Healthcare Web + +Load Balancer + │ + Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Queue + │ + Workers +``` + +Prefer managed infrastructure where practical. + +--- + +## 90. Backup Strategy + +Database: + +```text +automated backups +point-in-time recovery +tested restores +``` + +Object storage: + +```text +versioning +retention policies +backup or replication where required +``` + +A backup strategy is incomplete until restoration is tested. + +--- + +## 91. Migration Strategy + +Use explicit immutable migration files. + +Recommended naming: + +```text +YYYYMMDDHHMMSS_description.sql +``` + +Example: + +```text +20260826010000_create_organizations.sql +20260826011000_create_users.sql +20260826012000_create_memberships.sql +20260826013000_create_rbac.sql +20260826014000_create_audit_outbox.sql +20260826015000_create_engineering_clients.sql +``` + +### UUID Standard + +The platform uses UUIDv7. + +Supported implementation choices: + +```text +PostgreSQL 18+: + use native uuidv7() if database-generated identifiers are desired + +Earlier PostgreSQL: + generate UUIDv7 in the application or use a controlled extension +``` + +Database columns remain PostgreSQL `UUID`. + +The rule is consistency, not ideological loyalty to one generation layer. + +Do not silently fall back to UUIDv4 while documenting UUIDv7. + +### Production Migration Rules + +Use expand/contract: + +```text +1. add backward-compatible schema +2. deploy code supporting old + new schema +3. backfill/migrate +4. switch reads/writes +5. observe +6. remove obsolete schema later +``` + +For destructive changes: + +```text +backup/restore plan +compatibility window +production-like dry run +explicit approval +post-migration verification +``` + +Do not assume a destructive database migration can always be reversed by a simple down migration. + +Never use automatic ORM schema synchronization in production. + +## 92. Technology Recommendation + +Backend: + +```text +TypeScript +NestJS or Fastify-based architecture +``` + +Database: + +```text +PostgreSQL +``` + +ORM/query layer candidates: + +```text +Prisma +Drizzle +Kysely +``` + +Frontend: + +```text +React / Next.js +``` + +Queue: + +```text +Redis + BullMQ +``` + +Storage: + +```text +S3-compatible storage +``` + +Observability: + +```text +OpenTelemetry +``` + +Containers: + +```text +Docker +``` + +--- + +## 93. REST API Milestones + +### Milestone 1: Platform Access and Security + +```http +POST /auth/register +POST /auth/login + +POST /auth/token/refresh +POST /auth/token/revoke +POST /auth/token/revoke-all + +GET /auth/sessions +DELETE /auth/sessions/{sessionId} + +GET /me + +POST /organizations +GET /me/organizations + +POST /membership-invitations +GET /memberships + +GET /roles +POST /roles +GET /permissions +``` + +Includes: + +```text +explicit organization context +session revocation +refresh-token reuse detection +audit foundation +outbox foundation +idempotency foundation +rate limiting +``` + +### Milestone 2: Engineering Clients + +```http +GET /engineering/clients +POST /engineering/clients +GET /engineering/clients/{id} +PATCH /engineering/clients/{id} +POST /engineering/clients/{id}/archive +POST /engineering/clients/{id}/restore +GET /engineering/clients/{id}/projects +``` + +### Milestone 3: Engineering Projects + +```http +GET /engineering/projects +POST /engineering/projects +GET /engineering/projects/{id} +PATCH /engineering/projects/{id} + +POST /engineering/projects/{id}/activate +POST /engineering/projects/{id}/close +POST /engineering/projects/{id}/archive + +GET /engineering/projects/{id}/summary +``` + +Timeline and budget read models follow when the frontend requires them. + +### Milestone 4: Collaboration + +```http +POST /engineering/projects/{id}/members +GET /engineering/projects/{id}/members + +POST /engineering/tasks +GET /engineering/tasks +POST /engineering/tasks/{id}/complete +``` + +### Milestone 5: Sites and Documents + +Build: + +```text +engineering sites +signed file uploads +document versions +malware scanning +project document links +``` + +### Milestone 6: Designs + +Build: + +```text +design lifecycle +versions +reviews +submit-review +request-changes +approve +reject +supersede +credential validation +audit + outbox + idempotency +``` + +### Milestone 7: Inspections + +Build: + +```text +schedule +start +complete +cancel +findings +finding resolution +audit + outbox + idempotency +``` + +### Milestone 8: Commercial Workflows + +Build: + +```text +time entries +invoices +payments +refunds +financial idempotency +reports +``` + +## 94. Architecture Rules to Freeze + +1. REST is the primary frontend and integration API. +2. Base path is `/api/v1`. +3. OpenAPI 3.1 is the public API contract. +4. GraphQL is not part of v1. +5. Begin as one modular monolith backend. +6. Each profession has its own frontend. +7. Each profession owns its domain tables and state machines. +8. Shared modules provide infrastructure rather than forced domain abstractions. +9. Every tenant-owned row contains `organization_id`. +10. Tenant-scoped requests require explicit `X-Organization-Id`. +11. The API never silently selects an organization. +12. Tenant boundaries are enforced in queries and database constraints. +13. Cross-tenant resources appear nonexistent. +14. Authorization is server-side and deny-by-default. +15. Roles and professional qualifications are separate. +16. High-risk professional commands validate authoritative credential state. +17. Sessions and refresh-token records are separate concepts. +18. Refresh-token rotation uses token families and reuse detection. +19. UUIDv7 is the identifier standard. +20. PostgreSQL 18 native `uuidv7()` may be used when PostgreSQL 18+ is the baseline. +21. Important domain transitions use explicit REST command endpoints. +22. High-risk commands use durable idempotency. +23. Redis may accelerate idempotency but is not authoritative for financial/regulated commands. +24. Profession-specific state transitions are explicitly modeled and tested. +25. Engineering design cancellation and post-review withdrawal are semantically distinct where needed. +26. Inspection lifecycle and inspection outcome are separate dimensions. +27. Follow-up inspection work is linked work, not an overloaded lifecycle status. +28. Shared document binaries live in object storage. +29. Document checksum belongs to document versions. +30. Document classification is multi-level, not a single confidentiality boolean. +31. File upload policy is configurable by organization/profession/product tier. +32. Uploads validate declared MIME, extension, content signature, size, quota, and malware status. +33. Explicit document-link tables are preferred over generic polymorphic references. +34. Domain events use a transactional outbox. +35. Outbox semantics are at-least-once. +36. Every outbox consumer that can create external side effects is idempotent. +37. Webhooks are a shared platform service with delivery history and retry. +38. HMAC webhook signing material must be recoverable securely, normally encrypted with managed key protection. +39. Async imports, exports, and reports use job resources and return `202 Accepted`. +40. `GET` endpoints do not create export jobs. +41. Engineering clients support multiple contacts. +42. Engineering budget detail uses dedicated tables when budget management enters scope. +43. Derived spent/committed budget totals must not become uncontrolled duplicate financial truth. +44. PostgreSQL remains the authoritative transactional datastore. +45. Redis is an acceleration/coordination layer. +46. Search starts with PostgreSQL. +47. External search, read replicas, materialized views, and partitioning require measured evidence. +48. API collections use cursor pagination. +49. Important mutable resources use optimistic concurrency. +50. Database entities are not serialized directly as public API contracts. +51. Errors use stable codes. +52. Important and regulated actions are audited. +53. Sensitive healthcare reads are audited when policy requires. +54. Signed clinical records use sign/amend/version workflows. +55. Prescribing authority is jurisdiction and scope-of-practice policy, not a hard-coded profession. +56. Production migrations follow expand/contract. +57. Destructive schema changes are not assumed to be trivially reversible. +58. Secrets are managed outside source control. +59. Rate limits are configurable policies calibrated by security/load evidence. +60. CI validates types, tests, OpenAPI, migrations, and security scans. +61. Property-based tests are used for high-value state machines. +62. Outbox/job/webhook reliability is tested under failure and concurrency. +63. Internal tenant-isolation invariant failures and external cross-tenant attempts are separate observability signals. +64. Critical-path tests matter more than a vanity coverage percentage. +65. Engineering is the first implemented product vertical. +66. Legal follows after the engineering product validates shared assumptions. +67. Healthcare requires explicit security/privacy/jurisdiction readiness work before implementation. +68. Architecture documentation never equates "designed for" with "certified/compliant". +69. Milestones define implementation order; calendar estimates require actual delivery context. + +## 95. Required Design Artifacts + +Maintain: + +```text +01_PROJECT_ARCHITECTURE.md +02_DATABASE_CONVENTIONS.md +03_AUTHORIZATION_MODEL.md +04_AUTH_SESSION_MODEL.md +05_ENGINEERING_DOMAIN.md +06_ENGINEERING_DATABASE_SCHEMA.md +07_API_CONVENTIONS.md +08_ENGINEERING_API_SPEC.md +09_FRONTEND_ARCHITECTURE.md +10_DOCUMENT_SECURITY_MODEL.md +11_WEBHOOK_INTEGRATION_MODEL.md +12_ASYNC_JOB_MODEL.md +13_SECURITY_MODEL.md +14_DEPLOYMENT_ARCHITECTURE.md +15_OBSERVABILITY_MODEL.md +16_TESTING_STRATEGY.md +17_MVP_BACKLOG.md +18_OPENAPI.yaml +``` + +Supporting state-machine documents should exist for: + +```text +engineering projects +engineering designs +engineering inspections +invoices/payments +clinical records when healthcare begins +``` + +## 96. Recommended Implementation Order + +```text +Foundation + ↓ +Authentication + ↓ +Organizations + ↓ +Memberships + ↓ +RBAC + ↓ +Engineering Clients + ↓ +Engineering Projects + ↓ +Project Team + ↓ +Tasks + ↓ +Sites + ↓ +Documents + ↓ +Designs + ↓ +Inspections + ↓ +Time Tracking + ↓ +Billing + ↓ +Notifications + ↓ +Reports + ↓ +Legal Vertical + ↓ +Healthcare Vertical +``` + +--- + +## 97A. Database Indexing Strategy + +All tenant-owned tables need efficient tenant scoping. + +Baseline: + +```text +(organization_id, id) +``` + +Common list access often benefits from: + +```text +(organization_id, created_at) +``` + +Query-specific examples: + +```text +(organization_id, status) +(organization_id, client_id) +(organization_id, project_id) +(organization_id, assigned_to_user_id) +``` + +### Rules + +1. every index corresponds to a known query, ordering, or constraint +2. column order follows real predicates +3. validate with `EXPLAIN (ANALYZE, BUFFERS)` +4. include production-like cardinality in testing +5. measure write amplification +6. do not index every field +7. introduce trigram/full-text indexes only for actual search requirements + +Potential later tools: + +```text +covering indexes +materialized views +read replicas +table partitioning +external search +``` + +These are evidence-driven scaling mechanisms, not baseline dependencies. + +### Document Category Uniqueness + +If a nullable field such as profession participates in uniqueness: + +```text +organization_id +profession nullable +name +``` + +do not assume plain uniqueness treats NULL as one shared value. + +Use PostgreSQL-supported null-aware uniqueness or partial unique indexes according to the selected PostgreSQL version. + +--- + +## 97B.## 97B. CI/CD and Deployment Gates + +Pipeline stages: + +```text +lint/typecheck + ↓ +unit tests + ↓ +integration tests + ↓ +OpenAPI validation + contract tests + ↓ +security/dependency scan + ↓ +container build + image scan + ↓ +migration compatibility check + ↓ +deploy development + ↓ +smoke tests + ↓ +deploy staging + ↓ +E2E + performance/security baseline + ↓ +manual production approval + ↓ +production deployment + ↓ +post-deploy verification +``` + +Production deployment should support: + +```text +rolling or blue/green application deployment +backward-compatible database migrations +health checks +fast application rollback +feature flags for incomplete features +observability gates +``` + +Database schema rollback is not treated as equivalent to application rollback. + +### Configuration and Secrets + +Non-secret configuration may use environment variables. + +Secrets should use a managed secret store where possible: + +```text +database credentials +Redis credentials +JWT/private signing keys +object storage credentials +SMTP/API provider credentials +monitoring credentials +``` + +Do not publish real secrets in sample configuration. + +Organization profession enablement remains primarily data-driven through `organization_professions`. + +Global feature flags may be used for staged rollout, kill switches, or incomplete features. + +--- + +## 97C. Review-Driven Deferred Decisions + +The following ideas are valid possibilities but are explicitly **not frozen into v1**: + +```text +read replicas +materialized views +Elasticsearch/OpenSearch +universal 100 MB file limit +fixed 100 req/min user limit +fixed 1000 req/hour organization limit +specific cache-hit-ratio target +specific p95 latency promise +database-per-tenant +microservices +GraphQL +``` + +These require evidence from: + +```text +load tests +security analysis +customer requirements +compliance requirements +real production workloads +``` + +This prevents benchmark-shaped guesses from becoming architecture law. + +--- + +## 97D. Production Readiness Gates + +Architecture being coherent does not mean production is safe. + +Before production, require evidence in these categories. + +### Security + +```text +TLS configured +password hashing configured +refresh rotation/reuse detection tested +session revocation tested +tenant isolation tests passing +authorization/credential policies tested +rate limiting active +secrets managed outside source control +file security scanning active +security review completed +``` + +### Reliability + +```text +database backups automated +restore tested +object storage recovery strategy tested +outbox monitoring active +job queue monitoring active +webhook retry/dead-letter behavior tested +health checks configured +dependency failures tested +``` + +### Data Integrity + +```text +tenant-aware foreign keys present where required +financial invariants tested +migration tested on production-like data +idempotency tested for high-risk commands +optimistic concurrency tested +audit integrity tested +``` + +### Contract / API + +```text +OpenAPI validates +contract tests pass +error schema consistent +versioning rules documented +client SDK generation validated if used +``` + +### Performance + +```text +load test executed +realistic SLOs defined +database pool configured +key queries analyzed +outbox/job backlogs remain within SLO +``` + +### Critical Domain Coverage + +Rather than a magic overall coverage number, require explicit test coverage for: + +```text +tenant boundaries +design approval +inspection completion +invoice issue +payment/refund +membership privilege changes +clinical record signing/amendment when healthcare exists +prescribing authorization when healthcare exists +``` + +### Release Gate Principle + +No single metric such as: + +```text +90% test coverage +``` + +is sufficient evidence of production readiness. + +Quality gates are based on critical behavior, not vanity percentages. + +--- + +## 97. Final Design Position + +The platform is: + +```text +One Shared Platform + │ + ├── Shared Identity / Sessions + ├── Shared Security / Authorization + ├── Shared Documents + ├── Shared Financial Core + ├── Shared Audit / Outbox + ├── Shared Jobs / Webhooks / Notifications + │ + ├── Engineering Product + │ ├── Engineering Frontend + │ ├── Engineering REST APIs + │ ├── Engineering State Machines + │ └── Engineering Tables + │ + ├── Legal Product + │ ├── Legal Frontend + │ ├── Legal REST APIs + │ └── Legal Tables + │ + └── Healthcare Product + ├── Healthcare Frontend + ├── Healthcare REST APIs + ├── Healthcare Security Policies + └── Healthcare Tables +``` + +The system shares infrastructure where reuse is valuable while preserving independent domain semantics where professional workflows differ. + +v3 is the implementation baseline for the Engineering MVP. + +It is not, by itself, evidence of: + +```text +production certification +regulatory compliance +security certification +performance at a specific scale +``` + +Those claims require implementation evidence, security testing, operational controls, measured load results, restore tests, and profession/jurisdiction-specific review. + + + +--- + +# v3 Changelog + +Compared with v2, v3 adds or changes: + +```text +✓ corrected refresh-token family persistence +✓ PostgreSQL 18 UUIDv7 option +✓ engineering client contacts +✓ engineering design assignments +✓ cancelled vs withdrawn design semantics +✓ inspection outcome separate from lifecycle +✓ linked follow-up inspection workflow +✓ project budget domain model +✓ version-level document checksums +✓ document classification and categories +✓ upload policy and content validation +✓ webhook configuration and delivery model +✓ encrypted/recoverable webhook signing material +✓ async job architecture +✓ import/export as POST + 202 job creation +✓ at-least-once outbox semantics made explicit +✓ idempotent outbox consumers required +✓ property-based state-machine testing +✓ chaos tests for outbox/job processing +✓ separate tenant-attack and invariant-failure metrics +✓ milestone-based delivery planning +✓ critical-path production readiness gates +✓ no arbitrary code-coverage production target +``` diff --git a/professional_management_platform_rest_plan_v4.md b/professional_management_platform_rest_plan_v4.md new file mode 100644 index 0000000..2bd0505 --- /dev/null +++ b/professional_management_platform_rest_plan_v4.md @@ -0,0 +1,6298 @@ +# Professional Management Platform +## Full REST-First System Design Plan + +> **Revision:** v4 — Final Broad Architecture Baseline +> **Status:** Final broad architecture baseline for Engineering MVP implementation. Further changes should move into ADRs, OpenAPI, schemas, migrations, and backlog items rather than repeatedly reopening platform architecture. +> **Primary vertical:** Engineering +> **API style:** REST + JSON + OpenAPI 3.1 +> **Backend style:** Modular monolith +> **Data:** PostgreSQL + profession-specific tables +> **Tenant model:** Organization-scoped, explicit tenant context + +### v4 Integration Notes + +v4 incorporates the remaining high-value refinements from the latest architecture review without reopening settled platform decisions. + +Added: + +- dedicated engineering client portal architecture +- external-user access grants distinct from internal organization memberships +- client review/acceptance separated from professional engineering approval +- explicit project-document publication controls for external audiences +- synchronous batch-command semantics for small batches +- asynchronous job escalation for large batch operations +- explicit atomic vs partial-success batch behavior +- native object-storage multipart upload orchestration for very large engineering files +- rate-limit metadata policy without freezing legacy `X-RateLimit-*` headers +- technology ADR requirements instead of treating framework suggestions as settled architecture +- provisional performance objectives separated from contractual SLOs +- risk register with impact, mitigation, owner, phase, and status +- API error-standard ADR to evaluate RFC 9457 Problem Details compatibility +- final architecture-change governance to prevent endless review churn + +Explicitly rejected as permanent architecture: + +- client contacts becoming ordinary internal organization memberships +- external client acceptance being represented as professional design approval +- API servers proxying multi-gigabyte file chunks +- fixed `X-RateLimit-Limit/Remaining/Reset` as permanent contract +- framework/ORM choices being declared "confirmed" without an ADR +- arbitrary maturity percentages such as 95% architecture readiness +- fixed calendar promises before delivery context exists + +--- +--- +--- + +## 2. Core Architecture Decision + +The platform will use: + +- REST +- JSON +- OpenAPI +- Versioned endpoints +- PostgreSQL +- Modular monolith backend +- Profession-specific frontends +- Profession-specific database tables +- Shared identity, security, billing, documents, audit, and infrastructure + +Base API path: + +```text +/api/v1 +``` + +GraphQL is not part of v1. + +--- + +## 3. High-Level Architecture + +```text + FRONTENDS + + ┌──────────────────┼──────────────────┐ + │ │ │ + Engineering Web Legal Web Healthcare Web + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ + REST API + /api/v1 + │ + ┌───────────┼───────────┐ + │ │ │ + Core Engineering Legal + │ │ │ + │ Healthcare │ + │ │ │ + └───────────┼───────────┘ + │ + PostgreSQL + │ + ┌───────────────┼────────────────┐ + │ │ │ + Shared Tables Profession Tables Audit/Event Tables +``` + +Shared infrastructure: + +```text +PostgreSQL +Redis +Object Storage +Queue / Workers +Audit +Notifications +Billing +Observability +``` + +--- + +## 4. System Architecture Strategy + +Start with a modular monolith. + +Do not start with microservices. + +Initial deployment: + +```text +Frontend Apps + │ + ▼ +Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Worker Queue +``` + +Benefits: + +- simpler transactions +- easier development +- easier deployment +- clearer domain boundaries +- lower operational burden +- easier refactoring +- future service extraction remains possible + +--- + +## 5. Repository Structure + +Recommended monorepo: + +```text +professional-platform/ +│ +├── apps/ +│ ├── engineering-web/ +│ ├── legal-web/ +│ ├── healthcare-web/ +│ ├── platform-admin/ +│ ├── api/ +│ └── workers/ +│ +├── packages/ +│ ├── ui/ +│ ├── api-client/ +│ ├── auth-client/ +│ ├── validation/ +│ ├── types/ +│ ├── config/ +│ └── testing/ +│ +├── database/ +│ ├── migrations/ +│ ├── seeds/ +│ └── scripts/ +│ +├── infrastructure/ +│ ├── docker/ +│ ├── deployment/ +│ └── monitoring/ +│ +└── docs/ + ├── architecture/ + ├── api/ + ├── security/ + └── domains/ +``` + +--- + +## 6. Frontend Strategy + +Every profession receives its own frontend application. + +Avoid one giant frontend filled with profession checks. + +### Engineering Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Projects +Project Phases +Project Team +Sites +Designs +Design Reviews +Inspections +Specifications +Tasks +Documents +Timesheets +Billing +Reports +Administration +``` + +### Legal Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Matters +Cases +Hearings +Courts +Deadlines +Documents +Conflict Checks +Time Tracking +Retainers +Billing +Reports +Administration +``` + +### Healthcare Frontend + +Suggested navigation: + +```text +Dashboard +Patients +Appointments +Practitioners +Encounters +Clinical Records +Diagnoses +Prescriptions +Insurance +Documents +Billing +Reports +Administration +``` + +### Platform Admin Frontend + +Suggested functions: + +```text +Organizations +Users +Profession Modules +Subscriptions +System Health +Audit +Support +Global Configuration +``` + +Platform administrators and organization administrators are separate concepts. + +--- + +## 7. REST API Structure + +Shared endpoints: + +```text +/api/v1/auth +/api/v1/me +/api/v1/organizations +/api/v1/memberships +/api/v1/membership-invitations +/api/v1/roles +/api/v1/permissions +/api/v1/documents +/api/v1/invoices +/api/v1/payments +/api/v1/audit-events +``` + +Engineering: + +```text +/api/v1/engineering/clients +/api/v1/engineering/projects +/api/v1/engineering/project-members +/api/v1/engineering/phases +/api/v1/engineering/sites +/api/v1/engineering/tasks +/api/v1/engineering/designs +/api/v1/engineering/inspections +/api/v1/engineering/specifications +/api/v1/engineering/time-entries +``` + +Legal: + +```text +/api/v1/legal/clients +/api/v1/legal/matters +/api/v1/legal/cases +/api/v1/legal/hearings +/api/v1/legal/deadlines +/api/v1/legal/conflict-checks +/api/v1/legal/retainers +/api/v1/legal/time-entries +``` + +Healthcare: + +```text +/api/v1/healthcare/patients +/api/v1/healthcare/practitioners +/api/v1/healthcare/appointments +/api/v1/healthcare/encounters +/api/v1/healthcare/clinical-records +/api/v1/healthcare/diagnoses +/api/v1/healthcare/prescriptions +/api/v1/healthcare/insurance +``` + +--- + +## 8. REST Conventions + +All APIs use JSON over HTTPS. + +Typical tenant-scoped request: + +```http +Authorization: Bearer +X-Organization-Id: org_123 +X-Request-Id: req_123 +Content-Type: application/json +``` + +### Organization Context + +`X-Organization-Id` is mandatory for every tenant-scoped endpoint. + +Global endpoints such as these do not require tenant context: + +```http +POST /api/v1/auth/login +POST /api/v1/auth/token/refresh +GET /api/v1/me +GET /api/v1/me/organizations +GET /api/v1/auth/sessions +``` + +Tenant-context resolution rules: + +```yaml +Organization Context: + header_missing_on_tenant_endpoint: + status: 400 + code: ORGANIZATION_CONTEXT_REQUIRED + + organization_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + + membership_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + + membership_inactive: + status: 403 + code: AUTHZ_MEMBERSHIP_INACTIVE + + organization_inactive: + status: 403 + code: AUTHZ_ORGANIZATION_INACTIVE + + resource.organization_id_mismatch: + status: 404 + code: RESOURCE_NOT_FOUND +``` + +Do not expose another organization's identity in tenant-error responses. + +### Idempotency + +Use: + +```http +Idempotency-Key: 8f7d6c5e-4b3a-2b1c-9d8e-7f6a5b4c3d2e +``` + +Idempotency is required where duplicate execution can create material side effects. + +Examples: + +```http +POST /api/v1/invoices +POST /api/v1/invoices/{id}/payments +POST /api/v1/payments/{id}/refund + +POST /api/v1/engineering/designs/{id}/approve +POST /api/v1/engineering/inspections/{id}/complete +``` + +Idempotency records include: + +```text +organization_id +actor_id +route/action +idempotency_key +canonical_request_hash +response_status +response_body or result reference +created_at +expires_at +``` + +Rules: + +```yaml +same_key_same_request: + return: original result + +same_key_different_request: + status: 409 + code: IDEMPOTENCY_KEY_CONFLICT +``` + +PostgreSQL is authoritative for critical idempotency records. + +Redis may accelerate lookup. + +### Rate-Limit Responses + +Rate-limited requests return: + +```http +429 Too Many Requests +Retry-After: +``` + +Additional quota metadata may be exposed. + +Do **not** freeze legacy `X-RateLimit-*` header names into the architecture. + +The exact rate-limit response-header convention is selected and documented in the API ADR/OpenAPI contract based on the gateway and adopted standard at implementation time. + +### Error Standard Decision + +The current platform error envelope remains valid: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "req_123" + } +} +``` + +Before OpenAPI v1 is frozen, create an ADR evaluating compatibility with RFC 9457 Problem Details. + +Do not silently change the error envelope during implementation. + +## 9. Standard Response Format + +Single resource: + +```json +{ + "data": { + "id": "project_123", + "name": "Central Tower" + } +} +``` + +Collection: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Standard error: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "req_123" + } +} +``` + +Clients depend on `error.code`, not message text. + +### Error Taxonomy + +Authentication: + +```text +AUTH_INVALID_CREDENTIALS +AUTH_TOKEN_EXPIRED +AUTH_TOKEN_INVALID +AUTH_MFA_REQUIRED +AUTH_SESSION_REVOKED +AUTH_REFRESH_TOKEN_REUSED +``` + +Authorization: + +```text +AUTHZ_PERMISSION_DENIED +AUTHZ_ORGANIZATION_INACTIVE +AUTHZ_MEMBERSHIP_INACTIVE +AUTHZ_CREDENTIAL_INVALID +AUTHZ_SCOPE_MISMATCH +``` + +Tenant context: + +```text +ORGANIZATION_CONTEXT_REQUIRED +``` + +Resource/state: + +```text +RESOURCE_NOT_FOUND +RESOURCE_ALREADY_EXISTS +RESOURCE_CONCURRENT_MODIFICATION +RESOURCE_INVALID_STATE +RESOURCE_ARCHIVED +``` + +Validation: + +```text +VALIDATION_ERROR +VALIDATION_REQUIRED_FIELD +VALIDATION_INVALID_FORMAT +VALIDATION_BUSINESS_RULE +``` + +Idempotency: + +```text +IDEMPOTENCY_KEY_REQUIRED +IDEMPOTENCY_KEY_CONFLICT +``` + +Rate limiting: + +```text +RATE_LIMIT_EXCEEDED +``` + +System/dependency: + +```text +INTERNAL_ERROR +SERVICE_UNAVAILABLE +DATABASE_UNAVAILABLE +DEPENDENCY_FAILED +``` + +Validation example: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed.", + "requestId": "req_123", + "details": { + "fields": [ + { + "field": "email", + "code": "INVALID_FORMAT", + "message": "Must be a valid email address" + } + ] + } + } +} +``` + +Business-state example: + +```json +{ + "error": { + "code": "RESOURCE_INVALID_STATE", + "message": "Cannot approve design in current state.", + "requestId": "req_123", + "details": { + "resourceType": "engineering_design", + "resourceId": "design_123", + "currentState": "draft", + "requiredState": "under_review", + "allowedActions": [ + "submit_review" + ] + } + } +} +``` + +Do not expose internal stack traces, SQL, policy internals, secrets, or cross-tenant information. + +## 10. HTTP Status Rules + +```text +200 Success +201 Created +202 Accepted +204 No Content +400 Bad Request +401 Unauthorized +403 Forbidden +404 Not Found +409 Conflict +422 Validation Error +429 Too Many Requests +500 Internal Server Error +``` + +Cross-tenant resource access should return 404. + +--- + +## 11. API Versioning + +Current API: + +```text +/api/v1 +``` + +Breaking changes require: + +```text +/api/v2 +``` + +Additive fields generally do not require a new version. + +--- + +## 12. Authentication + +Initial authentication: + +```text +Email ++ +Password ++ +Short-Lived Access Token ++ +Opaque Refresh Token ++ +Server-Side Session +``` + +REST endpoints: + +```http +POST /api/v1/auth/register +POST /api/v1/auth/login + +POST /api/v1/auth/token/refresh +POST /api/v1/auth/token/revoke +POST /api/v1/auth/token/revoke-all + +GET /api/v1/auth/sessions +DELETE /api/v1/auth/sessions/{sessionId} + +GET /api/v1/me +``` + +### Access Token + +```yaml +format: JWT +lifetime: 15 minutes by default +signed: true +encrypted: false +preferred signing: asymmetric key or managed signing service +claims: + - sub / userId + - sessionId + - issuer + - audience + - issuedAt + - expiresAt +organizationId: + optional_hint: true + authorization_authority: false +``` + +The organization header and active membership remain authoritative for tenant access. + +Do not embed the complete permission set in access tokens. + +### Session and Refresh-Token Model + +A login session and a refresh token are different resources. + +Use: + +```text +sessions +refresh_tokens +``` + +Suggested `sessions` fields: + +```text +id +user_id + +device_id +device_type +device_os +app_version + +ip_address +user_agent + +created_at +last_active_at +expires_at + +revoked_at +revocation_reason +``` + +Suggested `refresh_tokens` fields: + +```text +id +session_id +family_id + +token_hash + +issued_at +expires_at + +rotated_at +replaced_by_token_id + +revoked_at +revocation_reason +``` + +Indexes/constraints: + +```text +UNIQUE(refresh_tokens.token_hash) + +INDEX(refresh_tokens.family_id) +INDEX(refresh_tokens.session_id) +INDEX(sessions.user_id, sessions.revoked_at) +``` + +Do **not** make `family_id` unique. Every rotated refresh token in the same lineage shares the same family. + +Conceptually: + +```text +Session + │ + └── Refresh Token Family + │ + ├── Token A [rotated] + │ ↓ + ├── Token B [rotated] + │ ↓ + └── Token C [current] +``` + +### Refresh Rotation + +On successful refresh: + +1. hash supplied refresh token +2. load token and session +3. validate token/session status and expiry +4. issue replacement token in same family +5. mark old token rotated +6. link `replaced_by_token_id` +7. return new access + refresh tokens + +### Reuse Detection + +If a previously rotated token is used again: + +```text +possible token theft + ↓ +revoke token family + ↓ +revoke affected session + ↓ +security audit event + ↓ +reauthentication required +``` + +Policy may escalate to revoking all user sessions for higher-risk environments. + +Audit event: + +```text +auth.refresh_token.reuse_detected +``` + +### Device Metadata + +Device metadata is useful for: + +```text +session display +security alerts +audit context +user-initiated revocation +``` + +It is not identity proof. + +Future authentication: + +- MFA +- passkeys / WebAuthn +- OIDC / SSO +- enterprise identity providers +- risk-based authentication + +## 13. Shared Core Backend + +Recommended modules: + +```text +core/ +├── auth/ +├── users/ +├── organizations/ +├── memberships/ +├── roles/ +├── permissions/ +├── authorization/ +├── documents/ +├── billing/ +├── notifications/ +├── audit/ +└── events/ +``` + +Dependency rule: + +```text +Profession module → Core +``` + +Never: + +```text +Core → Profession module +``` + +--- + +## 14. Organizations + +Organizations are tenants. + +Examples: + +```text +Atlas Structural Engineering +Smith & Associates Law +North Shore Medical Practice +``` + +Suggested fields: + +```text +id +name +slug +status +country_code +timezone +currency_code +created_at +updated_at +``` + +--- + +## 15. Profession Enablement + +Use: + +```text +organization_professions +``` + +Suggested fields: + +```text +organization_id +profession +enabled_at +configuration +``` + +Possible professions: + +```text +engineering +legal +healthcare +``` + +An organization may eventually enable more than one profession module. + +--- + +## 16. Users and Memberships + +Users are global identities. + +A user gains tenant access through membership. + +```text +User + │ + ▼ +Membership + │ + ▼ +Organization +``` + +Suggested `users` fields: + +```text +id +email +first_name +last_name +phone +avatar_url +status +created_at +updated_at +``` + +Suggested `memberships` fields: + +```text +id +organization_id +user_id +status +joined_at +created_at +updated_at +``` + +--- + +## 17. Membership Invitations + +Keep invitations separate from memberships. + +Suggested table: + +```text +membership_invitations +``` + +Fields: + +```text +id +organization_id +email +invited_by_user_id +expires_at +accepted_at +revoked_at +created_at +``` + +Flow: + +```text +Invitation + ↓ +Accepted + ↓ +User + ↓ +Membership +``` + +--- + +## 18. Authorization + +Use: + +```text +RBAC ++ +Permission Scope ++ +Resource Policies ++ +Professional Qualification Policies ++ +Domain State Rules +``` + +Decision flow: + +```text +Authenticated User + ↓ +Explicit Organization Context + ↓ +Active Membership + ↓ +Enabled Profession Module + ↓ +Roles + ↓ +Permissions + ↓ +Permission Scope + ↓ +Tenant-scoped Resource Query + ↓ +Resource Policy + ↓ +Credential/Jurisdiction Policy + ↓ +Domain State Rule + ↓ +ALLOW / DENY +``` + +Default decision: + +```text +DENY +``` + +Authorization rules: + +1. Controllers never perform ad-hoc role comparisons. +2. Tenant resource queries always include `organization_id`. +3. Do not load an arbitrary resource first and then discover it belongs to another tenant. +4. High-risk professional actions perform credential checks at command execution time. +5. A permission grants the ability to attempt an action, not a guarantee the domain state allows it. +6. Cross-tenant resources appear nonexistent. +7. Profession module enablement is checked before profession-specific authorization. + +## 19. Roles and Permissions + +Roles are organization-scoped collections of permissions. + +Example roles: + +```text +Owner +Administrator +Project Manager +Engineer +Reviewer +Inspector +Lawyer +Paralegal +Doctor +Nurse +Billing Manager +Viewer +``` + +Roles are not professional credentials. + +### Engineering Permissions + +```text +engineering.clients.read +engineering.clients.create +engineering.clients.update +engineering.clients.archive + +engineering.projects.read +engineering.projects.create +engineering.projects.update +engineering.projects.activate +engineering.projects.close +engineering.projects.archive + +engineering.project_members.manage +engineering.phases.manage +engineering.tasks.manage +engineering.sites.manage + +engineering.documents.read +engineering.documents.upload +engineering.documents.delete + +engineering.designs.read +engineering.designs.create +engineering.designs.update +engineering.designs.review +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede + +engineering.inspections.read +engineering.inspections.manage +engineering.inspections.complete + +engineering.time_entries.manage +engineering.reports.read +``` + +### Legal Permissions + +```text +legal.clients.read +legal.clients.create +legal.clients.update + +legal.matters.read +legal.matters.create +legal.matters.update +legal.matters.close +legal.matters.reopen + +legal.cases.read +legal.cases.manage +legal.hearings.manage +legal.deadlines.manage + +legal.documents.read +legal.documents.upload + +legal.conflicts.manage +legal.conflicts.approve + +legal.retainers.manage +legal.time_entries.manage +``` + +### Healthcare Permissions + +```text +healthcare.patients.read +healthcare.patients.create +healthcare.patients.update + +healthcare.appointments.read +healthcare.appointments.manage + +healthcare.encounters.read +healthcare.encounters.manage + +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.records.access_log.read + +healthcare.prescriptions.read +healthcare.prescriptions.write +healthcare.prescriptions.sign + +healthcare.insurance.read +healthcare.insurance.manage +``` + +### Shared Permissions + +```text +documents.read +documents.upload + +billing.read +invoices.create +invoices.issue +invoices.void +payments.record +payments.refund + +members.read +members.invite +members.update +members.remove + +roles.read +roles.manage + +audit.read +``` + +Avoid vague permissions such as `admin_everything` in normal tenant RBAC. + +## 20. Permission Scopes + +Initial scopes: + +```text +assigned +organization +``` + +Examples: + +```text +Engineer: +engineering.projects.read = assigned + +Principal Engineer: +engineering.projects.read = organization +``` + +Potential future scopes: + +```text +owned +team +department +restricted +``` + +Do not implement until required. + +--- + +## 21. Professional Credentials + +Professional qualification is separate from RBAC. + +Suggested shared profile: + +```text +professional_profiles +``` + +Fields: + +```text +id +organization_id +user_id +profession +title +credential_status +primary_license_number +primary_license_jurisdiction +valid_from +expires_at +created_at +updated_at +``` + +Profession modules may add dedicated credential tables when one generic profile is insufficient. + +### Credential Policy Examples + +Engineering design approval may require: + +```yaml +permission: engineering.designs.approve +credential: + profession_family: engineering + status: verified + active_license: true + jurisdiction_match: when required + discipline_match: when required +``` + +Healthcare record signing may require: + +```yaml +permission: healthcare.records.sign +credential: + profession_allowed_by_policy: true + status: verified + active_license: true + scope_of_practice_allows_action: true + jurisdiction_match: true +``` + +Prescribing must **not** be hard-coded to `profession = doctor` or to a single U.S. credential such as a DEA number. + +Prescribing authority varies by: + +- jurisdiction +- profession +- drug class +- supervising relationship +- organization policy +- credential status + +Therefore use a policy concept such as: + +```text +PrescribingAuthorityPolicy +``` + +rather than a permanent global rule. + +### Cache Safety + +Credential status may be cached briefly for ordinary reads, but high-risk writes such as: + +```text +engineering.designs.approve +healthcare.records.sign +healthcare.prescriptions.sign +``` + +must use authoritative or revocation-aware credential validation. A five-minute stale cache is unacceptable if a license was just suspended. + +## 22. Database Architecture + +Use PostgreSQL. + +Start with: + +```text +One database ++ +Shared schema ++ +Profession-specific tables +``` + +Do not begin with database-per-profession or database-per-customer unless compliance or residency requirements force that choice. + +--- + +## 23. Shared Tables + +Recommended shared tables: + +```text +organizations +organization_professions + +users +user_credentials +sessions + +memberships +membership_invitations + +roles +permissions +role_permissions +membership_roles + +professional_profiles + +documents +document_versions + +invoices +invoice_items +payments + +notifications +notification_deliveries + +audit_events +outbox_events +``` + +--- + +## 24. Multi-Tenancy Rule + +Every tenant-owned row must contain: + +```text +organization_id +``` + +Examples: + +```text +engineering_projects.organization_id +legal_matters.organization_id +healthcare_patients.organization_id +``` + +Enforce tenant boundaries at: + +- API layer +- authorization layer +- repository/query layer +- database constraints + +--- + +## 25. Tenant-Safe Foreign Keys + +Use composite tenant-aware foreign keys when possible. + +Example: + +```text +engineering_projects +organization_id +client_id +``` + +references: + +```text +engineering_clients +organization_id +id +``` + +This prevents linking a resource from one organization to another organization's data. + +--- + +# Engineering Domain + +## 26. Engineering Tables + +Initial tables: + +```text +engineering_clients +engineering_projects +engineering_project_members +engineering_project_phases +engineering_sites +engineering_tasks +engineering_designs +engineering_design_versions +engineering_design_reviews +engineering_inspections +engineering_inspection_findings +engineering_specifications +engineering_change_requests +engineering_time_entries +``` + +--- + +## 27. Engineering Clients + +Suggested core client fields: + +```text +id +organization_id +client_type +display_name +legal_name +status +created_at +updated_at +version +``` + +Do not permanently squeeze all contacts into one `email`, one `phone`, and one `contact_name`. + +Engineering customers commonly have multiple: + +```text +technical contacts +billing contacts +executive contacts +site contacts +contract contacts +``` + +Use: + +```text +engineering_client_contacts +``` + +Suggested contact fields: + +```text +id +organization_id +client_id + +name +title +department + +email +phone + +contact_type +is_primary + +created_at +updated_at +``` + +Client REST: + +```http +GET /api/v1/engineering/clients +POST /api/v1/engineering/clients +GET /api/v1/engineering/clients/{clientId} +PATCH /api/v1/engineering/clients/{clientId} + +POST /api/v1/engineering/clients/{clientId}/archive +POST /api/v1/engineering/clients/{clientId}/restore + +GET /api/v1/engineering/clients/{clientId}/projects +GET /api/v1/engineering/clients/{clientId}/invoices +``` + +Contact REST: + +```http +GET /api/v1/engineering/clients/{clientId}/contacts +POST /api/v1/engineering/clients/{clientId}/contacts +PATCH /api/v1/engineering/clients/{clientId}/contacts/{contactId} +DELETE /api/v1/engineering/clients/{clientId}/contacts/{contactId} +``` + +Delete may be implemented as archival when contact history matters. + +Client restore is allowed only when organization policy and retention rules permit it. + +## 27A. Engineering Client Portal + +External clients are not internal organization members. + +Use shared authentication identities where practical, but create a separate authorization boundary. + +```text +User + │ + ├── Internal Membership + │ ↓ + │ Organization Staff Access + │ + └── Client Portal Account + ↓ + Engineering Client Contact + ↓ + Project Access Grants +``` + +Suggested tables: + +```text +engineering_client_portal_accounts +engineering_client_portal_project_grants +engineering_project_document_publications +engineering_client_review_requests +``` + +### Portal Account + +Suggested fields: + +```text +id +organization_id +user_id +engineering_client_contact_id + +status + +invited_by_user_id +invited_at +accepted_at + +revoked_at +revoked_by_user_id +``` + +Portal accounts are not placed in `memberships`. + +### Project Grant + +Suggested fields: + +```text +id +organization_id +portal_account_id +project_id + +access_profile + +granted_by_user_id +granted_at +expires_at +revoked_at +``` + +Initial access capabilities may include: + +```text +project.status.read +project.documents.read_published +project.comments.create +project.files.submit +client_review.respond +``` + +The access model may later normalize capabilities into a grant table if simple profiles become insufficient. + +### Separate Frontend + +Recommended: + +```text +apps/ +├── engineering-web/ +└── engineering-client-portal/ +``` + +The internal engineering frontend and external portal do not share authorization assumptions. + +### Client Acceptance Is Not Engineering Approval + +Never represent client acceptance with: + +```text +engineering.designs.approve +``` + +Professional engineering approval is reserved for qualified internal/authorized professionals. + +Client-facing review should use separate concepts such as: + +```text +engineering.client_reviews.request +engineering.client_reviews.respond +engineering.client_reviews.accept +engineering.client_reviews.request_changes +``` + +Example: + +```http +POST /api/v1/engineering/client-review-requests/{reviewId}/accept +POST /api/v1/engineering/client-review-requests/{reviewId}/request-changes +``` + +A client acceptance may be commercially meaningful without being a professional engineering approval. + +### Portal Security Rules + +1. portal access is deny-by-default +2. every portal request remains organization-scoped +3. portal users only access explicitly granted projects +4. project membership does not apply to portal users +5. internal RBAC roles do not automatically apply to portal users +6. portal account revocation is immediate +7. portal grants may expire +8. sensitive document access requires explicit publication +9. portal activity is audited according to organization policy +10. professional approval endpoints are never exposed through portal grants + +--- + +## 27B. External Document Publication + +A document being linked to an engineering project does **not** make it externally visible. + +Use: + +```text +engineering_project_document_publications +``` + +Suggested fields: + +```text +id +organization_id + +project_document_link_id + +audience_type +portal_account_id nullable +client_id nullable + +published_by_user_id +published_at + +expires_at +revoked_at +revoked_by_user_id +``` + +Possible audiences: + +```text +all_active_client_portal_accounts_for_project +specific_portal_account +specific_client_contact +``` + +External download checks: + +```text +authenticated portal user ++ +active portal account ++ +active project grant ++ +active document publication ++ +publication not expired/revoked ++ +document classification allows publication ++ +download permission +``` + +This prevents an internal project document from appearing in the client portal merely because it is linked to the project. + +## 28. Engineering Projects + +Suggested fields: + +```text +id +organization_id +client_id +project_number +name +description +discipline +stage +status +project_manager_user_id +start_date +expected_completion_date +completed_date +budget_minor +currency_code +created_at +updated_at +version +``` + +REST: + +```http +GET /api/v1/engineering/projects +POST /api/v1/engineering/projects +GET /api/v1/engineering/projects/{projectId} +PATCH /api/v1/engineering/projects/{projectId} + +POST /api/v1/engineering/projects/{projectId}/activate +POST /api/v1/engineering/projects/{projectId}/close +POST /api/v1/engineering/projects/{projectId}/archive +``` + +Purpose-built read models may be added when the frontend requires them: + +```http +GET /api/v1/engineering/projects/{projectId}/summary +GET /api/v1/engineering/projects/{projectId}/timeline +GET /api/v1/engineering/projects/{projectId}/budget +``` + +These are read-model endpoints, not necessarily separate aggregate tables. + +Do not put arbitrary budget-breakdown JSON into the core project row merely because the response can display it. Model detailed budget data in dedicated tables when that feature is implemented. + +## 29. Engineering Project Members + +Suggested fields: + +```text +id +organization_id +project_id +user_id +project_role +joined_at +left_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/members +POST /api/v1/engineering/projects/{projectId}/members +PATCH /api/v1/engineering/projects/{projectId}/members/{memberId} +DELETE /api/v1/engineering/projects/{projectId}/members/{memberId} +``` + +--- + +## 30. Engineering Project Phases + +Suggested fields: + +```text +id +organization_id +project_id +name +sequence +status +start_date +end_date +created_at +updated_at +``` + +Typical phases: + +```text +Concept +Preliminary Design +Detailed Design +Construction +Inspection +Closeout +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/phases +POST /api/v1/engineering/projects/{projectId}/phases +PATCH /api/v1/engineering/projects/{projectId}/phases/{phaseId} +POST /api/v1/engineering/projects/{projectId}/phases/{phaseId}/complete +``` + +--- + +## 31. Engineering Sites + +Suggested fields: + +```text +id +organization_id +project_id +name +address +latitude +longitude +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/sites/{siteId} +PATCH /api/v1/engineering/sites/{siteId} +``` + +--- + +## 32. Engineering Tasks + +Suggested fields: + +```text +id +organization_id +project_id +title +description +status +priority +created_by_user_id +assigned_to_user_id +due_at +completed_at +created_at +updated_at +version +``` + +REST: + +```http +POST /api/v1/engineering/tasks +GET /api/v1/engineering/tasks +GET /api/v1/engineering/tasks/{taskId} +PATCH /api/v1/engineering/tasks/{taskId} + +POST /api/v1/engineering/tasks/{taskId}/complete +POST /api/v1/engineering/tasks/{taskId}/reopen +POST /api/v1/engineering/tasks/{taskId}/cancel +``` + +--- + +## 32A. Engineering Batch Operations + +Batch operations are useful for repetitive engineering workflows, but they must not bypass per-resource authorization or domain rules. + +Examples: + +```http +POST /api/v1/engineering/tasks/batch-assign +POST /api/v1/engineering/tasks/batch-complete + +POST /api/v1/engineering/time-entries/batch-submit +``` + +### Batch Execution Modes + +Every batch command explicitly defines one of: + +```text +atomic +partial +``` + +Atomic: + +```text +all resources succeed +or +entire operation fails +``` + +Partial: + +```text +each resource is evaluated independently +successful items commit +failed items return individual errors +``` + +Do not leave this behavior implicit. + +Example request: + +```json +{ + "taskIds": [ + "task_1", + "task_2", + "task_3" + ], + "assigneeUserId": "user_123", + "mode": "partial" +} +``` + +Example response: + +```json +{ + "data": { + "succeeded": [ + "task_1", + "task_2" + ], + "failed": [ + { + "id": "task_3", + "code": "RESOURCE_INVALID_STATE" + } + ] + } +} +``` + +### Authorization + +Each resource is evaluated for: + +```text +tenant +permission +scope +resource access +state validity +credential policy where applicable +``` + +Never authorize the first item and assume the remaining batch is equivalent. + +### Synchronous vs Asynchronous + +Small batches may execute synchronously. + +Large batches become jobs: + +```http +202 Accepted +``` + +with: + +```text +jobId +``` + +The synchronous/asynchronous threshold is configuration based on: + +```text +batch size +operation cost +database load +side effects +product tier +``` + +Financial or regulated batch actions require stricter idempotency and audit rules than ordinary task updates. + +--- + +## 33. Engineering Designs + +Suggested fields: + +```text +id +organization_id +project_id +design_number +title +description +discipline +status +owner_user_id +prepared_by_user_id +approved_by_user_id +approved_at +created_at +updated_at +version +``` + +Suggested states: + +```text +draft +under_review +changes_requested +approved +rejected +cancelled +withdrawn +superseded +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/designs +POST /api/v1/engineering/projects/{projectId}/designs + +GET /api/v1/engineering/designs/{designId} +PATCH /api/v1/engineering/designs/{designId} + +POST /api/v1/engineering/designs/{designId}/submit-review +POST /api/v1/engineering/designs/{designId}/request-changes +POST /api/v1/engineering/designs/{designId}/approve +POST /api/v1/engineering/designs/{designId}/reject +POST /api/v1/engineering/designs/{designId}/cancel +POST /api/v1/engineering/designs/{designId}/withdraw +POST /api/v1/engineering/designs/{designId}/supersede + +POST /api/v1/engineering/designs/{designId}/assign +POST /api/v1/engineering/designs/{designId}/unassign + +GET /api/v1/engineering/designs/{designId}/versions +POST /api/v1/engineering/designs/{designId}/versions + +GET /api/v1/engineering/designs/{designId}/reviews +POST /api/v1/engineering/designs/{designId}/reviews +``` + +### Assignment Model + +Use: + +```text +engineering_design_assignments +``` + +Possible assignment roles: + +```text +owner +designer +reviewer +approver +checker +``` + +Suggested fields: + +```text +id +organization_id +design_id +user_id +assignment_role +notes +assigned_by_user_id +assigned_at +unassigned_at +``` + +Assignment does not automatically grant platform permission. Both RBAC and resource policy still apply. + +### Design State Machine + +```text +draft + ├── submit-review ───────────────► under_review + └── cancel ──────────────────────► cancelled + +under_review + ├── request-changes ─────────────► changes_requested + ├── approve ─────────────────────► approved + ├── reject ──────────────────────► rejected + └── withdraw ────────────────────► withdrawn + +changes_requested + ├── submit-review ───────────────► under_review + └── withdraw ────────────────────► withdrawn + +rejected + └── revise ──────────────────────► draft + +approved + └── supersede ───────────────────► superseded +``` + +Use `cancelled` for work stopped before formal review. + +Use `withdrawn` for work intentionally removed after review workflow has started. + +Approval requires: + +```text +permission ++ +project access ++ +appropriate assignment/policy ++ +valid professional qualification ++ +valid design state ++ +organization approval policy +``` + +Approval, rejection, withdrawal, and supersession are audited. + +Approval is idempotent. + +Do not approve by PATCHing `status`. + +## 34. Design Versions and Reviews + +`engineering_design_versions`: + +```text +id +design_id +version_number +document_id +created_by_user_id +created_at +``` + +`engineering_design_reviews`: + +```text +id +organization_id +design_id +reviewer_user_id +status +comments +reviewed_at +``` + +Possible review statuses: + +```text +pending +approved +changes_requested +rejected +``` + +--- + +## 35. Engineering Inspections + +Suggested fields: + +```text +id +organization_id +project_id +site_id + +inspection_type +inspector_user_id + +status +outcome + +scheduled_at +started_at +performed_at +cancelled_at + +summary + +created_at +updated_at +version +``` + +Lifecycle status: + +```text +draft +scheduled +in_progress +completed +cancelled +``` + +Outcome is separate: + +```text +passed +passed_with_observations +followup_required +failed +``` + +This distinction matters. + +An inspection can be fully completed and still require corrective work. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/inspections +POST /api/v1/engineering/projects/{projectId}/inspections + +GET /api/v1/engineering/inspections/{inspectionId} +PATCH /api/v1/engineering/inspections/{inspectionId} + +POST /api/v1/engineering/inspections/{inspectionId}/schedule +POST /api/v1/engineering/inspections/{inspectionId}/start +POST /api/v1/engineering/inspections/{inspectionId}/complete +POST /api/v1/engineering/inspections/{inspectionId}/cancel + +GET /api/v1/engineering/inspections/{inspectionId}/findings +POST /api/v1/engineering/inspections/{inspectionId}/findings + +POST /api/v1/engineering/inspections/{inspectionId}/followups +GET /api/v1/engineering/inspections/{inspectionId}/followups +``` + +A follow-up may be: + +```text +corrective task +new inspection +or both +``` + +Do not encode all follow-up workflow into the original inspection's lifecycle state. + +Inspection completion: + +1. validate inspector and project access +2. validate required fields +3. validate findings +4. calculate or confirm outcome +5. complete inspection +6. create corrective work/follow-up records when required +7. audit +8. write outbox event +9. notify appropriate participants + +Completion is idempotent. + +## 36. Inspection Findings + +Suggested fields: + +```text +id +inspection_id +severity +description +status +resolved_at +``` + +Possible severities: + +```text +observation +minor +major +critical +``` + +REST: + +```http +POST /api/v1/engineering/inspections/{inspectionId}/findings +PATCH /api/v1/engineering/inspection-findings/{findingId} +POST /api/v1/engineering/inspection-findings/{findingId}/resolve +``` + +--- + +## 37. Engineering Specifications + +Suggested fields: + +```text +id +organization_id +project_id +specification_number +title +version +status +document_id +created_at +updated_at +``` + +--- + +## 38. Engineering Change Requests + +Suggested fields: + +```text +id +organization_id +project_id +request_number +title +description +status +requested_by_user_id +approved_by_user_id +estimated_cost_minor +created_at +updated_at +``` + +--- + +## 38A. Engineering Project Budgets + +A single `budget_minor` column is sufficient only for a very early project total. + +When budget management enters scope, introduce: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries +``` + +### Budget + +Suggested fields: + +```text +id +organization_id +project_id + +name +currency_code +status + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +### Budget Item + +Suggested fields: + +```text +id +organization_id +budget_id + +category +description + +allocated_amount_minor + +created_at +updated_at +``` + +Do not casually store mutable: + +```text +spent_amount_minor +committed_amount_minor +``` + +as independent sources of truth if those values are derived from time entries, expenses, purchase commitments, or invoices. + +Prefer: + +```text +authoritative cost/commitment records + ↓ +derived budget projections +``` + +If denormalized totals are needed for performance, update them transactionally and reconcile them. + +Potential REST: + +```http +GET /api/v1/engineering/projects/{projectId}/budgets +POST /api/v1/engineering/projects/{projectId}/budgets +GET /api/v1/engineering/budgets/{budgetId} +PATCH /api/v1/engineering/budgets/{budgetId} + +POST /api/v1/engineering/budgets/{budgetId}/approve +GET /api/v1/engineering/budgets/{budgetId}/items +POST /api/v1/engineering/budgets/{budgetId}/items +``` + +Budget approval is an explicit command. + +--- + +## 39. Engineering Time Entries + +Suggested fields: + +```text +id +organization_id +project_id +user_id +work_date +duration_minutes +description +billable +billing_rate_minor +currency_code +created_at +updated_at +``` + +REST: + +```http +POST /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries/{id} +PATCH /api/v1/engineering/time-entries/{id} +``` + +Store duration as integer minutes. + +--- + +# Legal Domain + +## 40. Legal Tables + +Initial tables: + +```text +legal_clients +legal_matters +legal_matter_members +legal_cases +legal_case_parties +legal_courts +legal_hearings +legal_deadlines +legal_documents +legal_time_entries +legal_retainers +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +REST namespace: + +```text +/api/v1/legal +``` + +Core examples: + +```http +GET /api/v1/legal/matters +POST /api/v1/legal/matters +GET /api/v1/legal/matters/{matterId} +PATCH /api/v1/legal/matters/{matterId} +POST /api/v1/legal/matters/{matterId}/close +POST /api/v1/legal/matters/{matterId}/reopen + +GET /api/v1/legal/matters/{matterId}/cases +GET /api/v1/legal/matters/{matterId}/documents +GET /api/v1/legal/matters/{matterId}/time-entries +GET /api/v1/legal/matters/{matterId}/invoices + +POST /api/v1/legal/conflict-checks +GET /api/v1/legal/conflict-checks/{conflictCheckId} +POST /api/v1/legal/conflict-checks/{conflictCheckId}/approve +POST /api/v1/legal/conflict-checks/{conflictCheckId}/decline +``` + +Legal remains a later vertical. These endpoints define intended boundaries, not a P0 build commitment. + +## 41. Legal Matters + +Suggested fields: + +```text +id +organization_id +client_id +matter_number +title +practice_area +responsible_lawyer_user_id +status +opened_date +closed_date +created_at +updated_at +``` + +--- + +## 42. Legal Cases + +Suggested fields: + +```text +id +organization_id +matter_id +case_number +court_id +jurisdiction +case_type +status +filed_date +created_at +updated_at +``` + +--- + +## 43. Legal Hearings + +Suggested fields: + +```text +id +organization_id +case_id +hearing_type +scheduled_at +courtroom +judge +status +notes +``` + +--- + +## 44. Legal Conflict Checks + +Suggested tables: + +```text +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +Conflict-check fields: + +```text +id +organization_id +potential_client_name +matter_description +requested_by_user_id +reviewed_by_user_id +status +decision +decision_reason +created_at +reviewed_at +version +``` + +Request example: + +```json +{ + "potentialClientName": "Acme Corporation", + "relatedParties": [ + { + "name": "John Smith", + "relationship": "CEO" + }, + { + "name": "Acme Subsidiary LLC", + "relationship": "Subsidiary" + } + ], + "matterDescription": "Corporate acquisition" +} +``` + +Response may contain possible matches: + +```json +{ + "data": { + "id": "conflict_123", + "status": "pending_review", + "potentialConflicts": [ + { + "type": "possible_direct_adversity", + "partyName": "Acme Corporation", + "existingMatterId": "matter_456", + "existingMatterNumber": "MAT-2026-089" + } + ] + } +} +``` + +The system should distinguish: + +```text +automated possible match +``` + +from: + +```text +lawyer-approved conflict determination +``` + +The software may assist discovery; it should not silently make the professional judgment. + +Approvals and declines are auditable commands. + +## 45. Healthcare Tables + +Initial tables: + +```text +healthcare_patients +healthcare_patient_contacts +healthcare_patient_addresses +healthcare_practitioners +healthcare_appointments +healthcare_encounters +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +healthcare_diagnoses +healthcare_prescriptions +healthcare_insurance_policies +healthcare_allergies +healthcare_medications +``` + +REST namespace: + +```text +/api/v1/healthcare +``` + +Examples: + +```http +GET /api/v1/healthcare/patients +POST /api/v1/healthcare/patients +GET /api/v1/healthcare/patients/{patientId} +PATCH /api/v1/healthcare/patients/{patientId} +POST /api/v1/healthcare/patients/{patientId}/archive + +GET /api/v1/healthcare/patients/{patientId}/appointments +GET /api/v1/healthcare/patients/{patientId}/encounters +GET /api/v1/healthcare/patients/{patientId}/clinical-records +GET /api/v1/healthcare/patients/{patientId}/prescriptions +GET /api/v1/healthcare/patients/{patientId}/allergies + +POST /api/v1/healthcare/encounters +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend +``` + +Healthcare is intentionally not treated as ordinary CRM plus extra columns. + +## 46. Healthcare Patients + +Core patient fields: + +```text +id +organization_id +patient_number +first_name +middle_name +last_name +date_of_birth +sex_or_administrative_gender_as_required +status +created_at +updated_at +version +``` + +Do not make a single default patient DTO return every available PHI field. + +Use minimum-necessary response shapes. + +Example general patient response: + +```json +{ + "data": { + "id": "patient_123", + "patientNumber": "PAT-2026-001", + "name": { + "firstName": "Alice", + "middleName": "Marie", + "lastName": "Johnson" + }, + "dateOfBirth": "1985-03-15", + "status": "active", + "version": 2 + } +} +``` + +More sensitive subresources should have separate permissions and endpoints where useful: + +```text +contact information +addresses +emergency contacts +insurance policies +clinical records +prescriptions +``` + +Do not return insurance member IDs or emergency contact details on every patient read merely because the database has them. + +## 47. Healthcare Practitioners + +Suggested fields: + +```text +id +organization_id +user_id +specialty +license_number +license_jurisdiction +credential_status +created_at +updated_at +``` + +--- + +## 48. Healthcare Appointments + +Suggested fields: + +```text +id +organization_id +patient_id +practitioner_id +appointment_type +starts_at +ends_at +status +reason +created_at +updated_at +``` + +--- + +## 49. Healthcare Encounters + +Suggested fields: + +```text +id +organization_id +patient_id +practitioner_id +appointment_id +encounter_type +started_at +ended_at +status +``` + +--- + +## 50. Clinical Records + +Suggested tables: + +```text +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +``` + +Core record fields: + +```text +id +organization_id +patient_id +encounter_id +author_practitioner_id +record_type +sensitivity_level +status +signed_by_practitioner_id +signed_at +created_at +updated_at +version +``` + +Draft content may be editable according to workflow. + +Once signed/finalized: + +- do not overwrite history +- create amendments or new versions +- preserve previous signed content +- audit reads when policy requires +- audit all writes/signatures/amendments + +REST: + +```http +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} + +PATCH /api/v1/healthcare/clinical-records/{recordId} +# Only when editable/draft according to policy. + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend + +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log +``` + +Clinical content representation should be designed around actual healthcare requirements and interoperability needs rather than permanently committing to one ad-hoc JSON SOAP-note structure. + +Sensitive record access should support an `accessReason` when organization or regulatory policy requires it. + +## 51. Documents + +Use shared object storage. + +Database: + +```text +documents +document_versions +document_categories +retention_policies +``` + +Binary data: + +```text +S3-compatible object storage +``` + +### Document + +Suggested fields: + +```text +id +organization_id + +name +category_id + +classification + +retention_policy_id + +current_version_id + +created_by_user_id +created_at +updated_at +``` + +Classification examples: + +```text +public +internal +confidential +restricted +regulated +``` + +Avoid a single `is_confidential` boolean as the long-term security model. + +### Document Version + +Suggested fields: + +```text +id +organization_id +document_id + +version_number + +storage_key + +mime_type +size_bytes + +content_hash +hash_algorithm + +uploaded_by_user_id +created_at +``` + +The authoritative checksum belongs on the version because each binary revision has different content. + +Optional document-level metadata may include: + +```text +current_version_id +current_version_number +``` + +but should not replace version-level integrity data. + +### Metadata + +Use JSONB only for genuinely extensible metadata that does not deserve stable relational columns. + +Examples: + +```text +CAD-specific extraction results +scanner metadata +non-authoritative document properties +``` + +Do not place access control, retention state, ownership, or lifecycle rules inside arbitrary metadata JSON. + +### Document Categories + +Suggested fields: + +```text +id +organization_id +profession nullable +name +parent_category_id +created_at +``` + +If `profession` is nullable and shared categories must remain unique, PostgreSQL uniqueness must explicitly handle nulls. + +Options include: + +```text +UNIQUE NULLS NOT DISTINCT +``` + +where supported, or separate partial unique indexes for: + +```text +profession IS NULL +profession IS NOT NULL +``` + +Do not rely on a plain nullable composite unique constraint and assume NULL behaves like a normal value. + +### Upload Security + +Validate: + +```text +declared MIME +extension +magic bytes/content signature +file size +malware scan +organization quota +classification policy +``` + +A renamed executable is not a PDF merely because the filename developed ambition. + +## 52. Document Upload Flow + +Small and ordinary file uploads: + +```text +Frontend + ↓ +Request upload authorization + ↓ +Backend validates tenant + permission + upload policy + ↓ +Create pending document/version + ↓ +Return signed upload URL + ↓ +Frontend uploads directly to object storage + ↓ +Backend finalizes + ↓ +verify checksum/type/size + ↓ +malware/security scan + ↓ +classification + retention + ↓ +available +``` + +REST: + +```http +POST /api/v1/documents/upload-url +POST /api/v1/documents/{documentId}/complete-upload + +GET /api/v1/documents/{documentId} +GET /api/v1/documents/{documentId}/download-url + +POST /api/v1/documents/{documentId}/versions +``` + +### Very Large Engineering Files + +Large CAD/BIM/model files use native object-storage multipart upload. + +The API orchestrates authorization and signed part URLs. + +It does **not** proxy gigabytes of file content through application servers. + +Flow: + +```text +Frontend + ↓ +POST /documents/multipart-uploads + ↓ +Backend authorizes +and initializes object-storage multipart upload + ↓ +Frontend requests signed part URLs + ↓ +Frontend uploads parts directly to object storage + ↓ +Frontend reports completed parts + ↓ +POST /documents/{id}/multipart-upload/complete + ↓ +Backend finalizes object + ↓ +verify object metadata/checksum + ↓ +malware/security scan + ↓ +mark document version available +``` + +Possible REST: + +```http +POST /api/v1/documents/multipart-uploads + +POST /api/v1/documents/{documentId}/multipart-upload/parts +POST /api/v1/documents/{documentId}/multipart-upload/complete + +DELETE /api/v1/documents/{documentId}/multipart-upload +``` + +The `/parts` endpoint returns signed upload URLs and part metadata. + +It does not carry binary chunks. + +### Multipart Upload State + +Track: + +```text +upload_id +organization_id +document_id +document_version_id +object_storage_upload_id +status +created_at +expires_at +completed_at +aborted_at +``` + +States: + +```text +initiated +uploading +completing +completed +aborted +expired +``` + +Cleanup workers abort abandoned multipart uploads. + +### File Policy + +Use configurable policy: + +```text +max_file_size_bytes +allowed_file_classes +organization_storage_quota_bytes +profession_overrides +plan/tier overrides +multipart_threshold_bytes +``` + +Exact file-size and quota values are product/configuration decisions. + +Validation includes: + +```text +declared MIME +extension +content signature / magic bytes +file size +checksum +quota +classification +malware scan +``` + +## 53. Profession-Specific Document Links + +Use explicit relationship tables. + +Engineering: + +```text +engineering_project_documents +engineering_design_documents +engineering_inspection_documents +``` + +Legal: + +```text +legal_matter_documents +legal_case_documents +``` + +Healthcare: + +```text +healthcare_patient_documents +healthcare_encounter_documents +``` + +### Engineering Project Documents + +Suggested link fields: + +```text +id +organization_id +project_id +document_id + +category +classification_override nullable + +linked_by_user_id +linked_at +unlinked_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/documents +POST /api/v1/engineering/projects/{projectId}/documents + +DELETE /api/v1/engineering/project-documents/{documentLinkId} +``` + +Link deletion may preserve historical linkage through `unlinked_at` when required. + +Example response: + +```json +{ + "data": [ + { + "documentLinkId": "projdoc_123", + "category": "calculations", + "document": { + "id": "doc_456", + "name": "structural_calculations.pdf", + "classification": "confidential", + "currentVersion": 2, + "mimeType": "application/pdf", + "sizeBytes": 2457600 + } + } + ] +} +``` + +Explicit link resources give stronger referential integrity than generic polymorphic foreign keys. + +## 54. Billing + +Shared financial core: + +```text +invoices +invoice_items +payments +``` + +Profession-specific modules may extend billing workflows. + +Engineering examples: + +```text +project billing +hourly billing +milestone billing +``` + +Legal examples: + +```text +matter billing +time billing +retainers +trust accounting +``` + +Healthcare examples: + +```text +insurance +claims +patient billing +``` + +REST: + +```http +POST /api/v1/invoices +GET /api/v1/invoices +GET /api/v1/invoices/{invoiceId} +PATCH /api/v1/invoices/{invoiceId} + +POST /api/v1/invoices/{invoiceId}/issue +POST /api/v1/invoices/{invoiceId}/void +POST /api/v1/invoices/{invoiceId}/payments +POST /api/v1/payments/{paymentId}/refund +``` + +--- + +## 55. Money Representation + +Use integer minor units: + +```json +{ + "amountMinor": 12550, + "currency": "USD" +} +``` + +Meaning: + +```text +$125.50 +``` + +Never use floating point for money. + +--- + +## 56. Audit Logging + +Table: + +```text +audit_events +``` + +Suggested fields: + +```text +id +organization_id +actor_type +actor_user_id +actor_service_account_id +action +resource_type +resource_id +request_id +correlation_id +ip_address +user_agent +metadata +occurred_at +``` + +Audit events are append-only. + +### Mandatory Engineering Audit Events + +```text +engineering.projects.create +engineering.projects.close +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede +engineering.inspections.complete +``` + +### Mandatory Legal Audit Events + +```text +legal.matters.create +legal.matters.close +legal.matters.reopen +legal.conflicts.approve +legal.conflicts.decline +legal.retainers.manage +``` + +### Mandatory Healthcare Audit Events + +```text +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.prescriptions.write +healthcare.prescriptions.sign +``` + +Example: + +```json +{ + "id": "audit_123", + "organizationId": "org_456", + "actorUserId": "user_789", + "action": "healthcare.records.read", + "resourceType": "healthcare_clinical_record", + "resourceId": "record_456", + "requestId": "req_abc", + "ipAddress": "192.0.2.10", + "userAgent": "Mozilla/5.0", + "metadata": { + "patientId": "patient_123", + "recordType": "progress_note", + "accessReason": "clinical_review" + }, + "occurredAt": "2026-08-26T01:30:00Z" +} +``` + +Audit metadata must never contain: + +- passwords +- access or refresh tokens +- full clinical note content +- secret keys +- unnecessary payment data + +REST: + +```http +GET /api/v1/audit-events +``` + +No public create/update/delete endpoints. + +## 57. Domain Events and Transactional Outbox + +Profession modules produce internal domain events. + +Examples: + +```text +engineering.project.created +engineering.design.approved +engineering.inspection.completed + +legal.matter.closed +legal.conflict_check.approved + +healthcare.appointment.created +healthcare.clinical_record.signed + +invoice.issued +payment.recorded +``` + +Consumers: + +```text +notifications +webhooks +analytics +search indexing +integrations +background workflows +``` + +Use: + +```text +outbox_events +``` + +Suggested fields: + +```text +id +organization_id +event_type +aggregate_type +aggregate_id +payload +occurred_at +available_at +processed_at +attempt_count +last_error +dead_lettered_at +``` + +Transaction: + +```text +BEGIN + +business change +audit event +outbox event + +COMMIT +``` + +The outbox is **at-least-once delivery**, not magically exactly-once. + +Worker claim example: + +```sql +SELECT id +FROM outbox_events +WHERE processed_at IS NULL + AND dead_lettered_at IS NULL + AND available_at <= now() +ORDER BY occurred_at +FOR UPDATE SKIP LOCKED +LIMIT 100; +``` + +Worker responsibilities: + +1. claim committed event +2. process consumer action +3. mark processed on success +4. increment attempts on failure +5. schedule retry with backoff +6. dead-letter after policy threshold +7. emit metrics +8. preserve replay/debug metadata + +### Critical Failure Case + +A worker may: + +```text +perform external side effect + ↓ +crash + ↓ +fail to mark event processed + ↓ +event is retried +``` + +Therefore every external consumer must support idempotency. + +Examples: + +```text +payment provider command → provider idempotency key +webhook delivery → delivery/event ID +email notification → dedupe key if duplicate mail is unacceptable +search indexing → upsert by entity/version +``` + +`FOR UPDATE SKIP LOCKED` prevents concurrent claims. It does **not** prevent duplicate side effects after a crash. + +Workers may be awakened by queue notifications, but must still poll durable outbox state so lost wake-ups do not strand events. + +## 57A. Webhooks and External Integrations + +Webhooks are a shared platform capability, not profession-specific transport code. + +Configuration REST: + +```http +GET /api/v1/webhooks +POST /api/v1/webhooks +GET /api/v1/webhooks/{webhookId} +PATCH /api/v1/webhooks/{webhookId} +DELETE /api/v1/webhooks/{webhookId} + +POST /api/v1/webhooks/{webhookId}/test +POST /api/v1/webhooks/{webhookId}/rotate-secret +``` + +Delivery REST: + +```http +GET /api/v1/webhook-deliveries +GET /api/v1/webhook-deliveries/{deliveryId} +POST /api/v1/webhook-deliveries/{deliveryId}/retry +``` + +Suggested tables: + +```text +webhooks +webhook_event_subscriptions +webhook_deliveries +``` + +Webhook fields: + +```text +id +organization_id +url +status +secret_ciphertext or signing_key_reference +created_by_user_id +created_at +updated_at +``` + +Do not return a secret hash to the client. + +### Secret Handling + +If using symmetric HMAC signing: + +```text +generate secret + ↓ +show plaintext once + ↓ +encrypt using KMS/key-management system + ↓ +store ciphertext + ↓ +decrypt only for signing +``` + +A one-way hash alone is insufficient because the server must possess the signing material. + +Alternative: + +```text +asymmetric signing ++ +published verification key +``` + +### Delivery Model + +Each delivery records: + +```text +id +organization_id +webhook_id +event_id + +attempt_number +request_timestamp +response_status +response_summary + +delivered_at +failed_at +next_attempt_at +``` + +Webhook workers require: + +```text +timeouts +retry with backoff +dead-letter/failure state +request signing +event IDs +idempotency guidance for consumers +delivery history +manual replay +``` + +Events should include stable identifiers so consumers can deduplicate. + +Example: + +```json +{ + "id": "evt_123", + "type": "engineering.design.approved", + "organizationId": "org_456", + "occurredAt": "2026-08-26T12:00:00Z", + "data": { + "designId": "design_789" + } +} +``` + +--- + +## 58. Background Jobs + +Workers handle: + +```text +Email +SMS +Notifications + +PDF/report generation + +File security scanning +Document processing + +Imports +Exports +Bulk updates + +Webhook delivery +Search indexing + +Large data operations +``` + +Architecture: + +```text +API + ↓ +Queue + ↓ +Worker +``` + +### Async Job Resource + +Use a shared job model for long-running user-requested operations. + +Suggested table: + +```text +jobs +``` + +Fields: + +```text +id +organization_id +requested_by_user_id + +job_type +status + +input_reference +result_reference + +progress_percent + +created_at +started_at +completed_at +failed_at + +error_code +error_summary +``` + +States: + +```text +queued +running +completed +failed +cancelled +``` + +REST: + +```http +GET /api/v1/jobs/{jobId} +GET /api/v1/jobs/{jobId}/result +POST /api/v1/jobs/{jobId}/cancel +``` + +### Import / Export + +Do not create asynchronous side effects with `GET`. + +Engineering examples: + +```http +POST /api/v1/engineering/project-imports +POST /api/v1/engineering/project-exports + +POST /api/v1/engineering/time-entry-imports +POST /api/v1/engineering/time-entry-exports +``` + +Response: + +```http +202 Accepted +``` + +```json +{ + "data": { + "jobId": "job_123", + "status": "queued" + } +} +``` + +Initial formats may include: + +```text +CSV +JSON +``` + +Import requirements: + +```text +validation report +row-level errors +all-or-partial mode explicitly defined +idempotency strategy +audit event +job result artifact +``` + +Export requirements: + +```text +authorization applied before generation +signed result URL +expiration +audit where data sensitivity requires it +``` + +## 59. Redis + +Use Redis as an acceleration and coordination layer, not the authoritative system of record. + +Appropriate uses: + +```text +job queue +rate-limit counters +short-lived authorization caches +organization configuration cache +session lookup acceleration +idempotency lookup acceleration +distributed locks when justified +``` + +### Cache Layers + +L1 optional application-memory cache: + +```text +static permission definitions +non-sensitive configuration +``` + +L2 Redis shared cache: + +```text +organization settings +membership snapshots +role permission snapshots +rate-limit counters +session lookup cache +recent idempotency lookups +``` + +CDN: + +```text +frontend static assets +explicitly public assets only +``` + +Do not cache private professional API responses at a CDN by default. + +### Cache Invalidation + +Invalidate or version caches when: + +```text +membership changes +role permissions change +organization settings change +professional credentials change +session is revoked +profession module enablement changes +``` + +High-risk authorization decisions must not depend solely on stale cached credential state. + +### Idempotency Durability + +Redis may improve idempotency lookup latency, but PostgreSQL remains authoritative for high-risk commands. + +## 60. Pagination + +Use cursor pagination. + +Example: + +```http +GET /api/v1/engineering/projects?limit=25 +``` + +Response: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": "...", + "hasMore": true + } + } +} +``` + +Maximum page size: + +```text +100 +``` + +--- + +## 61. Filtering + +Use explicit resource-specific filters. + +Examples: + +```http +GET /api/v1/engineering/projects?status=active&discipline=structural +GET /api/v1/engineering/tasks?status=todo&assignedToUserId=user_123 +``` + +Do not build a generic query DSL in v1. + +--- + +## 62. Sorting + +Examples: + +```http +GET /api/v1/engineering/projects?sort=createdAt +GET /api/v1/engineering/projects?sort=-createdAt +``` + +Only explicitly supported fields may be sorted. + +--- + +## 63. Search + +Start with PostgreSQL search. + +Engineering search may cover: + +```text +project number +project name +client name +``` + +Legal: + +```text +matter number +client +case number +``` + +Healthcare: + +```text +patient number +patient identity +``` + +Healthcare search requires stricter privacy and authorization controls. + +Potential PostgreSQL capabilities: + +- B-tree indexes for exact/filter queries +- PostgreSQL full-text search where appropriate +- `pg_trgm` only when fuzzy search requirements justify it + +Do not introduce Elasticsearch/OpenSearch until real query volume, relevance requirements, or indexing features justify another distributed system. + +Do not create every conceivable search index on day one. Indexes cost memory, storage, and write performance. + +## 64. Optimistic Concurrency + +Important mutable resources should use a version field. + +Example: + +```json +{ + "id": "project_123", + "version": 6 +} +``` + +Update: + +```json +{ + "version": 6, + "name": "Central Tower Phase II" +} +``` + +If the current database version differs: + +```text +409 CONCURRENT_MODIFICATION +``` + +--- + +## 65. Domain-Oriented REST + +Important state transitions use explicit command endpoints. + +Good: + +```http +POST /engineering/projects/{id}/close +POST /engineering/designs/{id}/approve +POST /engineering/tasks/{id}/complete +POST /engineering/inspections/{id}/complete +POST /invoices/{id}/issue +``` + +Avoid: + +```http +PATCH /resource/{id} +{ + "status": "approved" +} +``` + +when the change has significant rules or side effects. + +--- + +## 66. Transaction Boundaries + +Create project: + +```text +BEGIN + +create project +assign project manager +write audit event +write outbox event + +COMMIT +``` + +Approve design: + +```text +BEGIN + +validate permission +validate project access +validate credentials +validate design state +create review result +mark approved +write audit event +write outbox event + +COMMIT +``` + +--- + +## 67. Request Context + +Every authenticated request should resolve: + +```text +RequestContext +{ + requestId + userId + sessionId + organizationId + membershipId + permissions +} +``` + +Profession modules consume this context. + +--- + +## 68. Request IDs + +Every request has: + +```http +X-Request-Id +``` + +If missing, the server generates one. + +Use it in: + +- logs +- audit context +- error diagnostics +- asynchronous correlation + +--- + +## 69. OpenAPI + +Maintain: + +```text +openapi.yaml +``` + +Use OpenAPI 3.1. + +Production server example: + +```yaml +servers: + - url: https://api.example.com/api/v1 +``` + +The server URL and path definitions must remain consistent with the platform base path. + +OpenAPI defines: + +- routes +- request DTOs +- response DTOs +- security schemes +- organization header +- request IDs +- idempotency header +- pagination +- filters +- error schemas +- examples +- profession tags + +Security scheme: + +```yaml +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +``` + +Reusable headers/parameters: + +```text +X-Organization-Id +X-Request-Id +Idempotency-Key +limit +cursor +``` + +CI must validate the OpenAPI document. + +Contract tests should detect drift between implementation and specification. + +Generated clients may be used by the separate frontends, but generated transport code should not dictate frontend domain architecture. + +## 70. DTO Rule + +Database models are not public API contracts. + +Use: + +```text +Request DTO +Response DTO +``` + +A database migration should not accidentally change the public API. + +--- + +## 71. Backend Module Structure + +Recommended: + +```text +src/ +├── core/ +│ ├── auth/ +│ ├── organizations/ +│ ├── memberships/ +│ ├── authorization/ +│ ├── documents/ +│ ├── billing/ +│ ├── audit/ +│ └── events/ +│ +├── engineering/ +│ ├── clients/ +│ ├── projects/ +│ ├── project-members/ +│ ├── phases/ +│ ├── sites/ +│ ├── tasks/ +│ ├── designs/ +│ ├── inspections/ +│ └── specifications/ +│ +├── legal/ +│ ├── clients/ +│ ├── matters/ +│ ├── cases/ +│ ├── hearings/ +│ ├── conflicts/ +│ └── retainers/ +│ +└── healthcare/ + ├── patients/ + ├── practitioners/ + ├── appointments/ + ├── encounters/ + ├── records/ + └── prescriptions/ +``` + +--- + +## 72. Internal Module Structure + +Example: + +```text +projects/ +├── domain/ +│ ├── project.entity.ts +│ ├── project-status.ts +│ └── project.errors.ts +│ +├── application/ +│ ├── commands/ +│ │ ├── create-project.ts +│ │ ├── update-project.ts +│ │ └── close-project.ts +│ │ +│ └── queries/ +│ ├── get-project.ts +│ └── list-projects.ts +│ +├── infrastructure/ +│ └── project.repository.ts +│ +└── api/ + ├── project.controller.ts + ├── project.request.ts + └── project.response.ts +``` + +--- + +## 73. Controllers + +Controllers should handle: + +```text +HTTP +authentication context +input DTO parsing +application command/query invocation +response mapping +``` + +Controllers should not contain: + +```text +business rules +raw SQL +role logic +transaction orchestration +email sending +audit implementation +``` + +--- + +## 74. Commands and Queries + +Mutations use commands. + +Examples: + +```text +CreateEngineeringProjectCommand +ApproveEngineeringDesignCommand +CloseLegalMatterCommand +CompleteHealthcareEncounterCommand +``` + +Reads use queries. + +Examples: + +```text +GetEngineeringProjectQuery +ListLegalMattersQuery +GetHealthcarePatientQuery +``` + +--- + +## 75. Repositories + +Use domain-specific repositories. + +Examples: + +```text +EngineeringProjectRepository +LegalMatterRepository +HealthcarePatientRepository +``` + +Avoid one massive generic repository abstraction that eventually needs dozens of flags. + +--- + +## 76. Security Baseline + +Minimum controls: + +```text +TLS everywhere +Argon2id or equivalent strong password hashing +short-lived access tokens +refresh-token rotation +refresh-token reuse detection +server-side session revocation + +rate limiting +anti-automation controls + +RBAC +resource policies +credential-aware authorization +tenant isolation + +input validation +SQL injection protection + +signed object-storage URLs +file-content validation +malware scanning + +audit trails +secret management +encryption at rest + +dependency scanning +container/image scanning +security headers + +request/correlation IDs +backup and restore testing +``` + +### Rate Limiting + +Model policy rather than baking arbitrary numbers into architecture. + +Example: + +```typescript +interface RateLimitRule { + routePattern: string; + method: string; + windowSeconds: number; + maxRequests: number; + scope: 'user' | 'organization' | 'ip' | 'email' | 'session'; +} +``` + +Policy classes: + +```text +authentication +password recovery +general API +search +upload authorization +report generation +webhooks/integrations +clinical record reads +``` + +Rate-limit values are configuration derived from: + +```text +security testing +load testing +observed traffic +customer tier +endpoint cost +abuse risk +``` + +Do not grant normal tenant roles blanket rate-limit bypass. + +Administrative exceptions, if any, require explicit trusted-system policy. + +Return: + +```http +429 Too Many Requests +Retry-After: ... +``` + +Error: + +```text +RATE_LIMIT_EXCEEDED +``` + +### Secrets + +Use managed secrets/key management where possible. + +Never put real secrets in source-controlled examples. + +Prefer JWT asymmetric signing or managed signing keys with rotation capability. + +## 77. Data Classification + +Suggested classes: + +### Public + +```text +marketing configuration +``` + +### Internal + +```text +organization settings +tasks +``` + +### Confidential + +```text +engineering documents +legal matters +billing +``` + +### Highly Sensitive + +```text +clinical records +professional credentials +authentication secrets +``` + +--- + +## 78. Healthcare Security + +Before healthcare production use, define: + +```text +privacy model +minimum-necessary access model +clinical access policies +break-glass/emergency access policy if required +audit policy +record-signing policy +amendment policy +retention policy +credential policy +scope-of-practice policy +jurisdiction requirements +encryption strategy +consent requirements +data residency requirements +backup/restore handling +export/portability requirements +breach-response requirements +``` + +Healthcare is a stricter security tier. + +Key rules: + +1. default patient responses do not contain all available PHI +2. clinical record reads may be auditable events +3. signed records are immutable except through explicit amendment/version workflows +4. prescribing authorization is jurisdiction-specific +5. privileged clinical commands revalidate professional authority +6. caches must not allow revoked credentials to remain effective for high-risk writes +7. healthcare search results themselves are protected data +8. access logs may require dedicated permissions +9. do not claim regulatory compliance from architecture alone + +## 79. Observability + +Use: + +```text +structured logs +metrics +distributed tracing +request IDs +correlation IDs +``` + +Recommended: + +```text +OpenTelemetry +``` + +### Core Metrics + +API: + +```text +api_requests_total +api_errors_total +api_request_duration_seconds +``` + +Authentication: + +```text +auth_login_attempts_total +auth_token_refresh_total +auth_refresh_reuse_detections_total +auth_sessions_revoked_total +``` + +Authorization/security: + +```text +cross_tenant_access_attempts_total +tenant_isolation_invariant_failures_total +authorization_denials_total +credential_policy_denials_total +rate_limit_events_total +``` + +Important distinction: + +```text +cross_tenant_access_attempt += +request attempted another tenant's resource +``` + +This may be a stale link, mistake, or attack. + +```text +tenant_isolation_invariant_failure += +our system nearly or actually created/returned cross-tenant data +``` + +That is a high-severity internal correctness/security incident. + +Outbox/jobs/webhooks: + +```text +outbox_events_pending +outbox_events_failed_total +outbox_processing_duration_seconds + +jobs_queued +jobs_failed_total +job_duration_seconds + +webhook_delivery_attempts_total +webhook_delivery_failures_total +webhook_delivery_latency_seconds +``` + +Database: + +```text +db_pool_active +db_pool_waiting +db_query_duration_seconds +db_transaction_duration_seconds +``` + +Business metrics may include: + +```text +engineering_projects_created_total +engineering_designs_approved_total +engineering_inspections_completed_total +invoices_issued_total +``` + +Avoid patient-specific or sensitive identifiers in metric labels. + +### Alerts + +Examples: + +```text +refresh token reuse detected +tenant isolation invariant failure +outbox backlog exceeds SLO +webhook failure spike +database pool saturation +error-rate spike +latency regression +backup failure +malware scanner unavailable +``` + +Thresholds are calibrated from real environments rather than copied from a review document. + +### SLOs + +Define by endpoint class. + +Interactive CRUD, reports, file orchestration, and background jobs should not share one arbitrary latency target. + +## 80. Logging + +Useful fields: + +```text +request_id +route +method +status +duration +user_id when appropriate +organization_id when appropriate +``` + +Never log: + +```text +passwords +tokens +clinical record text +full sensitive documents +payment secrets +``` + +--- + +## 81. Testing Strategy + +### Unit Tests + +Test: + +```text +domain rules +state transitions +authorization policies +credential policies +money calculations +idempotency request hashing +``` + +### Property-Based Tests + +Use property-based testing for high-value domain state machines. + +Candidates: + +```text +engineering design lifecycle +engineering inspection lifecycle +invoice lifecycle +payment state transitions +membership/role invariants +``` + +Correct properties: + +```text +every successful transition ends in a valid state + +every forbidden transition is rejected + +terminal states reject prohibited actions + +required invariants survive every valid transition + +transition sequences never bypass required approval/credential rules +``` + +Do not assert that every random state/action pair succeeds. Many are supposed to fail. + +### Integration Tests + +Test: + +```text +repositories +tenant-aware foreign keys +PostgreSQL constraints +transactions +outbox persistence +idempotency persistence +cache invalidation +job persistence +webhook delivery persistence +``` + +### API Tests + +Every important endpoint covers: + +```text +happy path +request validation +authentication +organization context +permission denial +scope denial +credential denial where relevant +cross-tenant access +concurrent modification +invalid state transition +idempotent replay +idempotency conflict +audit creation +outbox creation +``` + +### Outbox Reliability / Chaos Tests + +Test: + +```text +worker crash before side effect +worker crash after side effect but before marking processed +two workers competing for same row +temporary dependency outage +retry/backoff behavior +dead-letter behavior +consumer idempotency +lost worker wake-up +replay +``` + +The dangerous scenario is: + +```text +external side effect succeeds +worker dies +event retries +``` + +Tests must prove the consumer does not create an unacceptable duplicate. + +### Tenant Security Tests + +Test both: + +```text +external cross-tenant access attempts +``` + +and: + +```text +internal cross-tenant data invariant failures +``` + +These are different classes of failure. + +### Performance Tests + +Create realistic profiles: + +```text +interactive reads +interactive writes +search +dashboard read models +reporting +file upload orchestration +outbox processing +webhook bursts +notification bursts +``` + +Measure: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +``` + +Set production SLO gates only after a realistic baseline exists. + +### Coverage + +Track code coverage. + +Do not treat a single percentage such as `90%` as proof of quality. + +Critical-path expectations are stronger: + +```text +all tenant-isolation paths tested +all financial commands tested +all regulated commands tested +all state transitions tested +all critical authorization policies tested +``` + +## 82. Tenant Security Tests + +For every major resource, attempt: + +```text +Organization A resource +using Organization B context +``` + +Test: + +```text +read +update +delete/action +list filtering +search +documents +``` + +Expected result: + +```text +404 / denied +``` + +--- + +## 83. Engineering MVP + +Engineering is the first vertical. + +Initial features: + +```text +Authentication +Organization management +Users / memberships / roles +Engineering clients +Projects +Project members +Project phases +Tasks +Sites +Documents +Basic design records +Inspections +Time entries +Basic billing +Audit history +``` + +Do not initially build: + +```text +advanced CAD integration +BIM integration +full document markup +advanced resource planning +procurement +complex accounting +AI design analysis +IoT integrations +``` + +--- + +## 84. Engineering MVP Workflow + +```text +User registers + ↓ +Creates engineering organization + ↓ +Invites engineer + ↓ +Assigns role + ↓ +Creates client + ↓ +Creates project + ↓ +Assigns project team + ↓ +Creates project phases + ↓ +Creates tasks + ↓ +Uploads documents + ↓ +Creates design + ↓ +Reviews / approves design + ↓ +Schedules inspection + ↓ +Records inspection findings + ↓ +Records engineering time + ↓ +Creates invoice + ↓ +Records payment + ↓ +Closes project + ↓ +Audit history contains lifecycle +``` + +--- + +## 85. Development Phases + +### Phase 0: Architecture Foundation + +Deliver: + +```text +domain boundaries +database conventions +REST conventions +authorization model +session/token model +idempotency strategy +error taxonomy +OpenAPI skeleton +engineering state machines +migration conventions +threat model +initial ADRs +risk register +``` + +### Phase 1: Shared Platform Core + +Build: + +```text +auth +sessions +refresh-token families +token rotation/revocation + +users +organizations +organization professions + +membership invitations +memberships +roles +permissions +authorization + +audit +outbox + +request context +idempotency +rate limiting +observability +``` + +### Phase 2: Engineering CRM + +Build: + +```text +engineering clients +engineering client contacts +client archive/restore +``` + +### Phase 3: Engineering Projects + +Build: + +```text +projects +project members +project phases +activation/close/archive +``` + +### Phase 4: Work and Site Management + +Build: + +```text +tasks +task batch operations +sites +``` + +### Phase 5: Documents + +Build: + +```text +documents +versions +categories +classification +retention references +signed uploads +multipart uploads +content verification +malware scanning +engineering document links +``` + +### Phase 6: Engineering Designs + +Build: + +```text +designs +assignments +versions +reviews +cancel/withdraw semantics +credential-aware approval +audit +outbox +idempotency +``` + +### Phase 7: Engineering Inspections + +Build: + +```text +inspection lifecycle +inspection outcome +findings +corrective work +follow-up inspections +attachments +audit +outbox +idempotency +``` + +### Phase 8: Time, Budgets, and Billing + +Build: + +```text +time entries +batch timesheet submission +project budgets when required +invoices +payments +financial idempotency +reconciliation +``` + +### Phase 9: Notifications, Jobs, and Webhooks + +Build: + +```text +notifications +email +async jobs +imports/exports +webhooks +delivery/retry +dead-letter handling +``` + +### Phase 10: Reporting and Search + +Build: + +```text +project status +overdue work +inspection status +billable time +revenue +outstanding invoices +dashboard read models +``` + +### Phase 11: Engineering Client Portal + +Build: + +```text +portal account invitations +external project grants +published project documents +client review/acceptance workflow +portal audit +portal-specific frontend +``` + +Do not expose professional approval actions to client portal accounts. + +### Phase 12: Legal Vertical + +Validate shared core against: + +```text +matters +cases +conflicts +deadlines +retainers +restricted access / ethical walls +``` + +### Phase 13: Healthcare Readiness and Vertical + +Before implementation: + +```text +healthcare threat model +privacy review +jurisdiction analysis +scope-of-practice policy +record signing/amendment model +retention model +audit requirements +``` + +### Estimation Rule + +These are dependency-ordered milestones. + +They are not calendar promises. + +Calendar estimates require: + +```text +team size +frontend/UX scope +cloud decisions +third-party providers +security requirements +QA capacity +domain-expert availability +``` + +## 86. Legal Expansion + +Only after engineering proves the shared platform assumptions. + +Build: + +```text +Legal Client + ↓ +Matter + ↓ +Case + ↓ +Hearings / Deadlines / Documents +``` + +Do not redesign engineering around legal terminology. + +Extract only genuinely reusable infrastructure. + +--- + +## 87. Healthcare Expansion + +Healthcare comes after: + +- core platform is stable +- audit model is proven +- permission model is proven +- tenant isolation is tested +- retention and encryption strategies are defined + +Healthcare should be treated as its own security and compliance workstream. + +--- + +## 88. Deployment Environments + +Use: + +```text +development +testing +staging +production +``` + +Each environment has independent: + +```text +database +object storage +secrets +queues +API keys +``` + +--- + +## 89. Initial Deployment Architecture + +```text +CDN + │ + ├── Engineering Web + ├── Legal Web + └── Healthcare Web + +Load Balancer + │ + Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Queue + │ + Workers +``` + +Prefer managed infrastructure where practical. + +--- + +## 90. Backup Strategy + +Database: + +```text +automated backups +point-in-time recovery +tested restores +``` + +Object storage: + +```text +versioning +retention policies +backup or replication where required +``` + +A backup strategy is incomplete until restoration is tested. + +--- + +## 91. Migration Strategy + +Use explicit immutable migration files. + +Recommended naming: + +```text +YYYYMMDDHHMMSS_description.sql +``` + +Example: + +```text +20260826010000_create_organizations.sql +20260826011000_create_users.sql +20260826012000_create_memberships.sql +20260826013000_create_rbac.sql +20260826014000_create_audit_outbox.sql +20260826015000_create_engineering_clients.sql +``` + +### UUID Standard + +The platform uses UUIDv7. + +Supported implementation choices: + +```text +PostgreSQL 18+: + use native uuidv7() if database-generated identifiers are desired + +Earlier PostgreSQL: + generate UUIDv7 in the application or use a controlled extension +``` + +Database columns remain PostgreSQL `UUID`. + +The rule is consistency, not ideological loyalty to one generation layer. + +Do not silently fall back to UUIDv4 while documenting UUIDv7. + +### Production Migration Rules + +Use expand/contract: + +```text +1. add backward-compatible schema +2. deploy code supporting old + new schema +3. backfill/migrate +4. switch reads/writes +5. observe +6. remove obsolete schema later +``` + +For destructive changes: + +```text +backup/restore plan +compatibility window +production-like dry run +explicit approval +post-migration verification +``` + +Do not assume a destructive database migration can always be reversed by a simple down migration. + +Never use automatic ORM schema synchronization in production. + +## 91A. Architecture Decision Records + +v4 stops treating technology suggestions as automatically settled architecture. + +Create ADRs before implementation locks in: + +```text +ADR-001 Backend Framework +ADR-002 SQL / ORM / Query Layer +ADR-003 Queue Implementation +ADR-004 PostgreSQL Minimum Version +ADR-005 Error Format / RFC 9457 Compatibility +ADR-006 Rate-Limit Header Convention +ADR-007 Webhook Signing Strategy +ADR-008 Object Storage Provider / Multipart Strategy +``` + +Each ADR should include: + +```text +context +decision +alternatives considered +tradeoffs +security impact +operational impact +migration/exit path +date +status +``` + +The architecture currently fixes capabilities and boundaries. + +It does not require a framework merely because a review document described it positively. + +--- + +## 92. Technology Recommendation + +The following are preferred candidates, not all final decisions. + +### Fixed Platform Choices + +```text +API style: REST +Contract: OpenAPI 3.1 +Primary language: TypeScript +Primary database: PostgreSQL +Architecture: Modular Monolith +Observability standard: OpenTelemetry +Object storage model: S3-compatible +Container model: Docker/OCI +``` + +### ADR-Gated Choices + +Backend framework candidates: + +```text +NestJS +Fastify-centered custom application structure +``` + +SQL / persistence candidates: + +```text +Drizzle +Kysely +Prisma +direct SQL for specialized queries +``` + +Queue candidates: + +```text +BullMQ / Redis +managed cloud queue +``` + +PostgreSQL baseline: + +```text +PostgreSQL 18+ +``` + +is attractive because of native UUIDv7 and current capabilities, but the minimum supported version must be confirmed against: + +```text +hosting provider availability +operations policy +extension requirements +upgrade policy +support lifecycle +``` + +Do not claim one ORM is categorically "faster" or "better" without workload-specific evidence. + +The selected stack should preserve: + +```text +transaction control +explicit SQL visibility +tenant-safe query design +migration control +observability +testability +``` + +## 93. REST API Milestones + +### Milestone 1: Platform Access and Security + +```http +POST /auth/register +POST /auth/login + +POST /auth/token/refresh +POST /auth/token/revoke +POST /auth/token/revoke-all + +GET /auth/sessions +DELETE /auth/sessions/{sessionId} + +GET /me + +POST /organizations +GET /me/organizations + +POST /membership-invitations +GET /memberships + +GET /roles +POST /roles +GET /permissions +``` + +Includes: + +```text +explicit organization context +session revocation +refresh-token reuse detection +audit foundation +outbox foundation +idempotency foundation +rate limiting +``` + +### Milestone 2: Engineering Clients + +```http +GET /engineering/clients +POST /engineering/clients +GET /engineering/clients/{id} +PATCH /engineering/clients/{id} +POST /engineering/clients/{id}/archive +POST /engineering/clients/{id}/restore +GET /engineering/clients/{id}/projects +``` + +### Milestone 3: Engineering Projects + +```http +GET /engineering/projects +POST /engineering/projects +GET /engineering/projects/{id} +PATCH /engineering/projects/{id} + +POST /engineering/projects/{id}/activate +POST /engineering/projects/{id}/close +POST /engineering/projects/{id}/archive + +GET /engineering/projects/{id}/summary +``` + +Timeline and budget read models follow when the frontend requires them. + +### Milestone 4: Collaboration + +```http +POST /engineering/projects/{id}/members +GET /engineering/projects/{id}/members + +POST /engineering/tasks +GET /engineering/tasks +POST /engineering/tasks/{id}/complete +``` + +### Milestone 5: Sites and Documents + +Build: + +```text +engineering sites +signed file uploads +document versions +malware scanning +project document links +``` + +### Milestone 6: Designs + +Build: + +```text +design lifecycle +versions +reviews +submit-review +request-changes +approve +reject +supersede +credential validation +audit + outbox + idempotency +``` + +### Milestone 7: Inspections + +Build: + +```text +schedule +start +complete +cancel +findings +finding resolution +audit + outbox + idempotency +``` + +### Milestone 8: Commercial Workflows + +Build: + +```text +time entries +invoices +payments +refunds +financial idempotency +reports +``` + +## 94. Architecture Rules to Freeze + +1. REST is the primary frontend and integration API. +2. Base path is `/api/v1`. +3. OpenAPI 3.1 is the public API contract. +4. GraphQL is not part of v1. +5. Start as one modular monolith backend. +6. Each profession has its own frontend. +7. Each profession owns its domain tables and state machines. +8. Shared modules provide infrastructure, not forced domain abstractions. +9. Every tenant-owned row contains `organization_id`. +10. Tenant-scoped requests require explicit `X-Organization-Id`. +11. The API never silently selects an organization. +12. Tenant boundaries are enforced in queries and database constraints. +13. Cross-tenant resources appear nonexistent. +14. Authorization is server-side and deny-by-default. +15. Roles and professional qualifications are separate. +16. High-risk professional commands validate authoritative credential state. +17. Sessions and refresh tokens are separate resources. +18. Refresh tokens rotate within families and support reuse detection. +19. UUIDv7 is the identifier standard. +20. PostgreSQL 18 native UUIDv7 may be used when PostgreSQL 18+ is selected. +21. Important domain transitions use explicit REST command endpoints. +22. High-risk commands use durable idempotency. +23. Redis may accelerate idempotency but is not authoritative for financial/regulated commands. +24. Profession-specific state transitions are explicitly modeled and tested. +25. Design cancellation and post-review withdrawal are distinct when needed. +26. Inspection lifecycle and outcome are separate dimensions. +27. Follow-up inspection work is linked work, not overloaded lifecycle state. +28. Files live in object storage. +29. Large files use native object-storage multipart uploads. +30. Application servers do not proxy multi-gigabyte file chunks. +31. Document checksum is version-level authoritative data. +32. Document classification is multi-level. +33. File upload policy is configurable. +34. Upload validation includes content signature, size, quota, checksum, and malware scanning. +35. Explicit document-link tables are preferred over generic polymorphic links. +36. Project document linkage does not imply client-portal publication. +37. External publication requires an explicit publication record. +38. Client portal identities are not ordinary internal memberships. +39. Client portal permissions are separate from internal RBAC assumptions. +40. Client acceptance/review is not professional engineering approval. +41. Professional design approval is never granted through a client portal role. +42. Small batch commands define atomic or partial semantics explicitly. +43. Large batch operations become asynchronous jobs. +44. Every batch item receives tenant/authorization/domain validation. +45. Domain events use a transactional outbox. +46. Outbox semantics are at-least-once. +47. External side-effect consumers are idempotent. +48. Webhooks are shared platform infrastructure with retry, replay, delivery history, and signing. +49. HMAC webhook signing material is securely recoverable, normally encrypted with managed keys. +50. Async imports/exports/reports use job resources and return `202 Accepted`. +51. `GET` endpoints do not create export jobs. +52. Engineering clients support multiple contacts. +53. Engineering budgets use dedicated tables when budget management enters scope. +54. Derived financial totals do not become uncontrolled duplicate truth. +55. PostgreSQL is the authoritative transactional datastore. +56. Redis is an acceleration and coordination layer. +57. Search starts in PostgreSQL. +58. External search/read replicas/materialized views require measured need. +59. API collections use cursor pagination. +60. Important mutable resources use optimistic concurrency. +61. Database entities are not serialized directly as public API contracts. +62. Errors use stable codes. +63. Rate-limited responses use 429 and Retry-After. +64. Additional rate-limit headers are implementation/API-contract decisions, not frozen legacy header names. +65. Important and regulated actions are audited. +66. Sensitive healthcare reads are audited where policy requires. +67. Signed clinical records use sign/amend/version workflows. +68. Prescribing authority is jurisdiction and scope-of-practice driven. +69. Production migrations follow expand/contract. +70. Destructive schema changes are not assumed trivially reversible. +71. Secrets are managed outside source control. +72. Rate limits are policy/configuration calibrated by evidence. +73. CI validates types, tests, OpenAPI, migrations, and security scans. +74. Property-based tests are used for high-value state machines. +75. Outbox/job/webhook reliability is tested under failure and concurrency. +76. Internal tenant-isolation invariant failures and external cross-tenant attempts are separate signals. +77. Critical-path tests matter more than vanity coverage percentages. +78. Performance objectives are provisional until measured. +79. Architecture risks are maintained in a living register. +80. Framework/ORM/queue choices require ADRs. +81. Engineering is the first implemented product vertical. +82. The engineering client portal follows internal Engineering MVP foundations. +83. Legal follows after engineering validates shared assumptions. +84. Healthcare requires explicit security/privacy/jurisdiction readiness work. +85. Architecture documentation never equates "designed for" with "certified/compliant". +86. v4 is the final broad architecture baseline unless a foundational assumption is invalidated. + +## 95. Required Design Artifacts + +Maintain: + +```text +01_PROJECT_ARCHITECTURE.md +02_DATABASE_CONVENTIONS.md +03_AUTHORIZATION_MODEL.md +04_AUTH_SESSION_MODEL.md + +05_ENGINEERING_DOMAIN.md +06_ENGINEERING_DATABASE_SCHEMA.md +07_ENGINEERING_STATE_MACHINES.md + +08_API_CONVENTIONS.md +09_ENGINEERING_API_SPEC.md +10_OPENAPI.yaml + +11_FRONTEND_ARCHITECTURE.md +12_CLIENT_PORTAL_SECURITY_MODEL.md + +13_DOCUMENT_SECURITY_MODEL.md +14_LARGE_FILE_UPLOAD_MODEL.md + +15_WEBHOOK_INTEGRATION_MODEL.md +16_ASYNC_JOB_MODEL.md + +17_SECURITY_MODEL.md +18_DEPLOYMENT_ARCHITECTURE.md +19_OBSERVABILITY_MODEL.md +20_TESTING_STRATEGY.md + +21_ARCHITECTURE_DECISION_RECORDS/ +22_RISK_REGISTER.md +23_MVP_BACKLOG.md +``` + +Important ADRs: + +```text +backend framework +persistence/query layer +queue implementation +PostgreSQL minimum version +error format +rate-limit headers +webhook signing +object-storage provider +``` + +## 96. Recommended Implementation Order + +```text +Foundation + ↓ +Authentication + ↓ +Organizations + ↓ +Memberships + ↓ +RBAC + ↓ +Engineering Clients + ↓ +Engineering Projects + ↓ +Project Team + ↓ +Tasks + ↓ +Sites + ↓ +Documents + ↓ +Designs + ↓ +Inspections + ↓ +Time Tracking + ↓ +Billing + ↓ +Notifications + ↓ +Reports + ↓ +Legal Vertical + ↓ +Healthcare Vertical +``` + +--- + +## 97A. Database Indexing Strategy + +All tenant-owned tables need efficient tenant scoping. + +Baseline: + +```text +(organization_id, id) +``` + +Common list access often benefits from: + +```text +(organization_id, created_at) +``` + +Query-specific examples: + +```text +(organization_id, status) +(organization_id, client_id) +(organization_id, project_id) +(organization_id, assigned_to_user_id) +``` + +### Rules + +1. every index corresponds to a known query, ordering, or constraint +2. column order follows real predicates +3. validate with `EXPLAIN (ANALYZE, BUFFERS)` +4. include production-like cardinality in testing +5. measure write amplification +6. do not index every field +7. introduce trigram/full-text indexes only for actual search requirements + +Potential later tools: + +```text +covering indexes +materialized views +read replicas +table partitioning +external search +``` + +These are evidence-driven scaling mechanisms, not baseline dependencies. + +### Document Category Uniqueness + +If a nullable field such as profession participates in uniqueness: + +```text +organization_id +profession nullable +name +``` + +do not assume plain uniqueness treats NULL as one shared value. + +Use PostgreSQL-supported null-aware uniqueness or partial unique indexes according to the selected PostgreSQL version. + +--- + +## 97B.## 97B. CI/CD and Deployment Gates + +Pipeline stages: + +```text +lint/typecheck + ↓ +unit tests + ↓ +integration tests + ↓ +OpenAPI validation + contract tests + ↓ +security/dependency scan + ↓ +container build + image scan + ↓ +migration compatibility check + ↓ +deploy development + ↓ +smoke tests + ↓ +deploy staging + ↓ +E2E + performance/security baseline + ↓ +manual production approval + ↓ +production deployment + ↓ +post-deploy verification +``` + +Production deployment should support: + +```text +rolling or blue/green application deployment +backward-compatible database migrations +health checks +fast application rollback +feature flags for incomplete features +observability gates +``` + +Database schema rollback is not treated as equivalent to application rollback. + +### Configuration and Secrets + +Non-secret configuration may use environment variables. + +Secrets should use a managed secret store where possible: + +```text +database credentials +Redis credentials +JWT/private signing keys +object storage credentials +SMTP/API provider credentials +monitoring credentials +``` + +Do not publish real secrets in sample configuration. + +Organization profession enablement remains primarily data-driven through `organization_professions`. + +Global feature flags may be used for staged rollout, kill switches, or incomplete features. + +--- + +## 97C. Review-Driven Deferred Decisions + +The following ideas are valid possibilities but are explicitly **not frozen into v1**: + +```text +read replicas +materialized views +Elasticsearch/OpenSearch +universal 100 MB file limit +fixed 100 req/min user limit +fixed 1000 req/hour organization limit +specific cache-hit-ratio target +specific p95 latency promise +database-per-tenant +microservices +GraphQL +``` + +These require evidence from: + +```text +load tests +security analysis +customer requirements +compliance requirements +real production workloads +``` + +This prevents benchmark-shaped guesses from becoming architecture law. + +--- + +## 97D. Provisional Performance Objectives + +Performance numbers in architecture are starting hypotheses, not guarantees. + +Initial engineering objectives may begin with: + +```text +Interactive read: + target p95 <= 500 ms + +Interactive mutation: + target p95 <= 750 ms + +Simple list/search: + target p95 <= 800 ms + +Upload authorization: + target p95 <= 300 ms + +Background outbox pickup: + target <= 5 seconds under normal operating conditions +``` + +These are revised after realistic testing. + +Track: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +outbox lag +``` + +Different endpoint classes receive different SLOs. + +Do not use file-transfer completion time as an API SLO when bytes travel directly between client and object storage. + +--- + +## 97E. Risk Register + +Maintain a living risk register. + +Suggested structure: + +| Risk | Impact | Mitigation | Owner | Phase | Status | +|---|---|---|---|---|---| +| Cross-tenant data exposure | Critical | Tenant-aware FKs, scoped queries, security tests | Backend/Security | P0 | Open | +| Non-idempotent outbox side effect | Critical | Consumer dedupe, provider idempotency, chaos tests | Backend | P0 | Open | +| Migration failure | High | Expand/contract, dry runs, backups | Backend/Platform | P0 | Open | +| Engineering workflow mismatch | High | Domain expert validation | Product/Engineering SME | MVP | Open | +| Portal authorization leak | Critical | Separate external access model, publication grants | Backend/Security | Portal | Open | +| Webhook delivery instability | Medium | Retry, dead-letter, replay, metrics | Backend | Integrations | Open | +| Large upload abandonment | Medium | Multipart expiry and cleanup | Backend/Platform | Documents | Open | +| Documentation drift | Medium | OpenAPI validation, ADRs, CI | Engineering | Continuous | Open | + +Do not pretend likelihood labels are quantitative unless the team defines and uses a scoring method. + +--- + +## 97F. Architecture Change Governance + +v4 is the last broad platform-architecture revision before Engineering MVP implementation. + +New discoveries should normally become: + +```text +ADR +OpenAPI change +database migration +domain-state-machine update +security decision +backlog item +runbook +``` + +rather than a new full architecture rewrite. + +Reopen the broad architecture only when a discovery invalidates one of these foundational assumptions: + +```text +tenant model +profession separation +shared-core boundary +REST API model +data ownership +security trust boundary +deployment topology +database architecture +``` + +This prevents design review from becoming an infinite recursion problem. + +--- + +## 97G. Production Readiness Gates + +Architecture being coherent does not mean production is safe. + +Before production, require evidence in these categories. + +### Security + +```text +TLS configured +password hashing configured +refresh rotation/reuse detection tested +session revocation tested +tenant isolation tests passing +authorization/credential policies tested +rate limiting active +secrets managed outside source control +file security scanning active +security review completed +``` + +### Reliability + +```text +database backups automated +restore tested +object storage recovery strategy tested +outbox monitoring active +job queue monitoring active +webhook retry/dead-letter behavior tested +health checks configured +dependency failures tested +``` + +### Data Integrity + +```text +tenant-aware foreign keys present where required +financial invariants tested +migration tested on production-like data +idempotency tested for high-risk commands +optimistic concurrency tested +audit integrity tested +``` + +### Contract / API + +```text +OpenAPI validates +contract tests pass +error schema consistent +versioning rules documented +client SDK generation validated if used +``` + +### Performance + +```text +load test executed +realistic SLOs defined +database pool configured +key queries analyzed +outbox/job backlogs remain within SLO +``` + +### Critical Domain Coverage + +Rather than a magic overall coverage number, require explicit test coverage for: + +```text +tenant boundaries +design approval +inspection completion +invoice issue +payment/refund +membership privilege changes +clinical record signing/amendment when healthcare exists +prescribing authorization when healthcare exists +``` + +### Release Gate Principle + +No single metric such as: + +```text +90% test coverage +``` + +is sufficient evidence of production readiness. + +Quality gates are based on critical behavior, not vanity percentages. + +--- + +## 97. Final Design Position + +The platform is: + +```text +One Shared Platform + │ + ├── Shared Identity / Sessions + ├── Shared Security / Authorization + ├── Shared Documents / Multipart Uploads + ├── Shared Financial Core + ├── Shared Audit / Outbox + ├── Shared Jobs / Webhooks / Notifications + │ + ├── Engineering Internal Product + │ ├── Engineering Frontend + │ ├── Engineering REST APIs + │ ├── Engineering State Machines + │ └── Engineering Tables + │ + ├── Engineering Client Portal + │ ├── External Portal Frontend + │ ├── Portal Accounts + │ ├── Project Grants + │ ├── Published Documents + │ └── Client Review / Acceptance + │ + ├── Legal Product + │ ├── Legal Frontend + │ ├── Legal REST APIs + │ └── Legal Tables + │ + └── Healthcare Product + ├── Healthcare Frontend + ├── Healthcare REST APIs + ├── Healthcare Security Policies + └── Healthcare Tables +``` + +The system shares infrastructure where reuse is valuable while preserving profession-specific domain semantics and trust boundaries. + +v4 is the final broad architecture baseline for Engineering MVP implementation. + +From this point forward, architecture detail should primarily move into: + +```text +ADRs +OpenAPI +database schema/migrations +state-machine specifications +security policies +implementation backlog +runbooks +``` + +rather than repeatedly rewriting the entire architecture plan. + +This document does not itself prove: + +```text +regulatory compliance +production certification +security certification +performance at a specific scale +``` + +Those require implementation evidence, security review, domain validation, operational testing, restore testing, and measured production-like workloads. + + + +--- + +# v4 Changelog + +Compared with v3, v4 adds or changes: + +```text +✓ dedicated engineering client portal trust boundary +✓ portal accounts separated from organization memberships +✓ explicit project-level external access grants +✓ professional design approval separated from client acceptance +✓ explicit external document-publication model +✓ batch operations with atomic/partial semantics +✓ asynchronous escalation for large batches +✓ object-storage multipart upload orchestration +✓ no API proxying of multi-gigabyte chunks +✓ rate-limit response policy with Retry-After +✓ legacy X-RateLimit headers not frozen into the architecture +✓ framework/ORM/queue decisions moved into ADRs +✓ PostgreSQL minimum version moved into an ADR +✓ RFC 9457 error-format compatibility added as an ADR +✓ provisional performance objectives separated from production SLOs +✓ living risk register introduced +✓ architecture-change governance introduced +✓ v4 designated final broad architecture baseline +``` diff --git a/professional_management_platform_rest_plan_v4_1.md b/professional_management_platform_rest_plan_v4_1.md new file mode 100644 index 0000000..cbf5a66 --- /dev/null +++ b/professional_management_platform_rest_plan_v4_1.md @@ -0,0 +1,6642 @@ +# Professional Management Platform +## Full REST-First System Design Plan + +> **Revision:** v4.1 — Consistency and Implementation-Contract Cleanup +> **Status:** Locked broad architecture baseline with implementation-blocking contradictions resolved. Subsequent detail belongs in ADRs, OpenAPI, migrations, domain specifications, and backlog items. +> **Primary vertical:** Engineering +> **API style:** REST + JSON + OpenAPI 3.1 +> **Backend style:** Modular monolith +> **Data:** PostgreSQL + profession-specific tables +> **Tenant model:** Organization-scoped, explicit tenant context + +### v4.1 Cleanup Notes + +v4.1 does not redesign the platform. It resolves contradictions and fills implementation contracts discovered during detailed review. + +Resolved: + +- raw UUIDv7 is now the serialized/API identifier format +- database columns remain UUID; prefixed strings are not public IDs +- all tenant-owned subresources carry direct `organization_id` +- design-version document cardinality is explicit and relational +- engineering time entries can be attributed to a specific work item +- project-level `budget_minor` is removed in favor of the dedicated budget model +- duplicate `legal_documents` ownership is removed +- batch custom-action paths use one documented convention +- membership invitations can pre-assign multiple roles +- missing design `revise` command is added +- inspection follow-ups now have a table and lifecycle +- change requests are moved out of the initial Engineering schema until specified +- service accounts and API keys are defined for machine access +- API JSON, query parameters, and path parameter names use camelCase; database columns use snake_case +- professional profiles support multiple professional credentials +- deletion/archival/revocation/unlink behavior is globally defined +- invoice-item fields are specified +- deferred healthcare placeholder tables receive minimum schemas or explicit deferral notes +- document upload and multipart DTOs are defined +- webhook subscription fields are defined +- `POST /auth/logout` is restored +- portal review-request and portal capability schemas are defined +- project-role, task-status, priority, and inspection-outcome values are defined +- document-category uniqueness has a version-independent fallback +- retention policy fields are defined +- pagination defaults are explicit +- `assigned` authorization scope has resource-specific resolution rules +- feature-flag behavior is clarified +- audit privacy minimization/anonymization strategy is documented +- outbox correlation and causation IDs are added +- CORS and web-security configuration is moved into a required ADR +- project `stage` duplication is removed; project phases remain authoritative +- phase reordering, site listing, appointment locations, and encounter reason fields are clarified +- jobs remain tenant-scoped through the standard organization header rather than path nesting + +--- +--- +--- +--- + +## 2. Core Architecture Decision + +The platform will use: + +- REST +- JSON +- OpenAPI +- Versioned endpoints +- PostgreSQL +- Modular monolith backend +- Profession-specific frontends +- Profession-specific database tables +- Shared identity, security, billing, documents, audit, and infrastructure + +Base API path: + +```text +/api/v1 +``` + +GraphQL is not part of v1. + +--- + +## 3. High-Level Architecture + +```text + FRONTENDS + + ┌──────────────────┼──────────────────┐ + │ │ │ + Engineering Web Legal Web Healthcare Web + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ + REST API + /api/v1 + │ + ┌───────────┼───────────┐ + │ │ │ + Core Engineering Legal + │ │ │ + │ Healthcare │ + │ │ │ + └───────────┼───────────┘ + │ + PostgreSQL + │ + ┌───────────────┼────────────────┐ + │ │ │ + Shared Tables Profession Tables Audit/Event Tables +``` + +Shared infrastructure: + +```text +PostgreSQL +Redis +Object Storage +Queue / Workers +Audit +Notifications +Billing +Observability +``` + +--- + +## 4. System Architecture Strategy + +Start with a modular monolith. + +Do not start with microservices. + +Initial deployment: + +```text +Frontend Apps + │ + ▼ +Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Worker Queue +``` + +Benefits: + +- simpler transactions +- easier development +- easier deployment +- clearer domain boundaries +- lower operational burden +- easier refactoring +- future service extraction remains possible + +--- + +## 5. Repository Structure + +Recommended monorepo: + +```text +professional-platform/ +│ +├── apps/ +│ ├── engineering-web/ +│ ├── legal-web/ +│ ├── healthcare-web/ +│ ├── platform-admin/ +│ ├── api/ +│ └── workers/ +│ +├── packages/ +│ ├── ui/ +│ ├── api-client/ +│ ├── auth-client/ +│ ├── validation/ +│ ├── types/ +│ ├── config/ +│ └── testing/ +│ +├── database/ +│ ├── migrations/ +│ ├── seeds/ +│ └── scripts/ +│ +├── infrastructure/ +│ ├── docker/ +│ ├── deployment/ +│ └── monitoring/ +│ +└── docs/ + ├── architecture/ + ├── api/ + ├── security/ + └── domains/ +``` + +--- + +## 6. Frontend Strategy + +Every profession receives its own frontend application. + +Avoid one giant frontend filled with profession checks. + +### Engineering Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Projects +Project Phases +Project Team +Sites +Designs +Design Reviews +Inspections +Specifications +Tasks +Documents +Timesheets +Billing +Reports +Administration +``` + +### Legal Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Matters +Cases +Hearings +Courts +Deadlines +Documents +Conflict Checks +Time Tracking +Retainers +Billing +Reports +Administration +``` + +### Healthcare Frontend + +Suggested navigation: + +```text +Dashboard +Patients +Appointments +Practitioners +Encounters +Clinical Records +Diagnoses +Prescriptions +Insurance +Documents +Billing +Reports +Administration +``` + +### Platform Admin Frontend + +Suggested functions: + +```text +Organizations +Users +Profession Modules +Subscriptions +System Health +Audit +Support +Global Configuration +``` + +Platform administrators and organization administrators are separate concepts. + +--- + +## 7. REST API Structure + +Shared endpoints: + +```text +/api/v1/auth +/api/v1/me +/api/v1/organizations +/api/v1/memberships +/api/v1/membership-invitations +/api/v1/roles +/api/v1/permissions +/api/v1/documents +/api/v1/invoices +/api/v1/payments +/api/v1/audit-events +``` + +Engineering: + +```text +/api/v1/engineering/clients +/api/v1/engineering/projects +/api/v1/engineering/project-members +/api/v1/engineering/phases +/api/v1/engineering/sites +/api/v1/engineering/tasks +/api/v1/engineering/designs +/api/v1/engineering/inspections +/api/v1/engineering/specifications +/api/v1/engineering/time-entries +``` + +Legal: + +```text +/api/v1/legal/clients +/api/v1/legal/matters +/api/v1/legal/cases +/api/v1/legal/hearings +/api/v1/legal/deadlines +/api/v1/legal/conflict-checks +/api/v1/legal/retainers +/api/v1/legal/time-entries +``` + +Healthcare: + +```text +/api/v1/healthcare/patients +/api/v1/healthcare/practitioners +/api/v1/healthcare/appointments +/api/v1/healthcare/encounters +/api/v1/healthcare/clinical-records +/api/v1/healthcare/diagnoses +/api/v1/healthcare/prescriptions +/api/v1/healthcare/insurance +``` + +--- + +## 8. REST Conventions + +All APIs use JSON over HTTPS. + +Typical tenant-scoped request: + +```http +Authorization: Bearer +X-Organization-Id: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d +X-Request-Id: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff +Content-Type: application/json +``` + +### API Naming Convention + +Public API representation: + +```text +JSON properties: camelCase +query parameters: camelCase +path parameter names in documentation: camelCase +HTTP headers: conventional HTTP header casing +``` + +Database representation: + +```text +table names: snake_case +column names: snake_case +constraint/index names: snake_case +``` + +Example: + +```http +GET /api/v1/engineering/tasks?assignedToUserId=&createdAfter=2026-08-01T00:00:00Z +``` + +```json +{ + "assignedToUserId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d", + "createdAt": "2026-08-26T12:00:00Z" +} +``` + +maps internally to columns such as: + +```text +assigned_to_user_id +created_at +``` + +### Organization Context + +`X-Organization-Id` is mandatory for every tenant-scoped endpoint. + +Global endpoints such as these do not require tenant context: + +```http +POST /api/v1/auth/login +POST /api/v1/auth/token/refresh +GET /api/v1/me +GET /api/v1/me/organizations +GET /api/v1/auth/sessions +``` + +Tenant-context resolution: + +```yaml +header_missing_on_tenant_endpoint: + status: 400 + code: ORGANIZATION_CONTEXT_REQUIRED + +organization_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + +membership_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + +membership_inactive: + status: 403 + code: AUTHZ_MEMBERSHIP_INACTIVE + +organization_inactive: + status: 403 + code: AUTHZ_ORGANIZATION_INACTIVE + +resource_organization_mismatch: + status: 404 + code: RESOURCE_NOT_FOUND +``` + +### Idempotency + +Use: + +```http +Idempotency-Key: 8f7d6c5e-4b3a-4b1c-9d8e-7f6a5b4c3d2e +``` + +Required where duplicate execution can create material side effects. + +PostgreSQL is authoritative for critical idempotency records. + +Redis may accelerate lookup. + +### Batch Custom-Action Convention + +For collection-level custom commands use: + +```text +/{collection}/batch/{action} +``` + +Examples: + +```http +POST /api/v1/engineering/tasks/batch/assign +POST /api/v1/engineering/tasks/batch/complete +POST /api/v1/engineering/time-entries/batch/submit +``` + +Do not mix `batch-assign`, colon-style custom methods, and `/batch/assign` in the same API. + +### Rate-Limit Responses + +```http +429 Too Many Requests +Retry-After: +``` + +Additional rate-limit metadata may be exposed according to the selected gateway/standard. + +Do not freeze legacy `X-RateLimit-*` names here. + +### Error Standard Decision + +The current error envelope remains: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff" + } +} +``` + +ADR-005 decides whether OpenAPI v1 aligns this with RFC 9457 Problem Details. + +Do not silently change the envelope during implementation. + +## 9. Standard Response Format + +Single resource: + +```json +{ + "data": { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c5d", + "name": "Central Tower" + } +} +``` + +Collection: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Standard error: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "req_123" + } +} +``` + +Clients depend on `error.code`, not message text. + +### Error Taxonomy + +Authentication: + +```text +AUTH_INVALID_CREDENTIALS +AUTH_TOKEN_EXPIRED +AUTH_TOKEN_INVALID +AUTH_MFA_REQUIRED +AUTH_SESSION_REVOKED +AUTH_REFRESH_TOKEN_REUSED +``` + +Authorization: + +```text +AUTHZ_PERMISSION_DENIED +AUTHZ_ORGANIZATION_INACTIVE +AUTHZ_MEMBERSHIP_INACTIVE +AUTHZ_CREDENTIAL_INVALID +AUTHZ_SCOPE_MISMATCH +``` + +Tenant context: + +```text +ORGANIZATION_CONTEXT_REQUIRED +``` + +Resource/state: + +```text +RESOURCE_NOT_FOUND +RESOURCE_ALREADY_EXISTS +RESOURCE_CONCURRENT_MODIFICATION +RESOURCE_INVALID_STATE +RESOURCE_ARCHIVED +``` + +Validation: + +```text +VALIDATION_ERROR +VALIDATION_REQUIRED_FIELD +VALIDATION_INVALID_FORMAT +VALIDATION_BUSINESS_RULE +``` + +Idempotency: + +```text +IDEMPOTENCY_KEY_REQUIRED +IDEMPOTENCY_KEY_CONFLICT +``` + +Rate limiting: + +```text +RATE_LIMIT_EXCEEDED +``` + +System/dependency: + +```text +INTERNAL_ERROR +SERVICE_UNAVAILABLE +DATABASE_UNAVAILABLE +DEPENDENCY_FAILED +``` + +Validation example: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed.", + "requestId": "req_123", + "details": { + "fields": [ + { + "field": "email", + "code": "INVALID_FORMAT", + "message": "Must be a valid email address" + } + ] + } + } +} +``` + +Business-state example: + +```json +{ + "error": { + "code": "RESOURCE_INVALID_STATE", + "message": "Cannot approve design in current state.", + "requestId": "req_123", + "details": { + "resourceType": "engineering_design", + "resourceId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c6d", + "currentState": "draft", + "requiredState": "under_review", + "allowedActions": [ + "submit_review" + ] + } + } +} +``` + +Do not expose internal stack traces, SQL, policy internals, secrets, or cross-tenant information. + +## 10. HTTP Status Rules + +```text +200 Success +201 Created +202 Accepted +204 No Content +400 Bad Request +401 Unauthorized +403 Forbidden +404 Not Found +409 Conflict +422 Validation Error +429 Too Many Requests +500 Internal Server Error +``` + +Cross-tenant resource access should return 404. + +--- + +## 11. API Versioning + +Current API: + +```text +/api/v1 +``` + +Breaking changes require: + +```text +/api/v2 +``` + +Additive fields generally do not require a new version. + +--- + +## 11A. Identifier Convention + +The serialized identifier standard is **raw UUIDv7**. + +Example: + +```text +0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d +``` + +Database: + +```sql +id UUID PRIMARY KEY +``` + +API: + +```json +{ + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d" +} +``` + +Do not serialize IDs as: + +```text +org_ +user_ +project_ +``` + +unless a future ADR explicitly changes the public identifier contract. + +Human-friendly resource references use separate fields such as: + +```text +projectNumber +matterNumber +patientNumber +invoiceNumber +``` + +This separates machine identity from business/display references. + +UUID generation is decided by ADR-004: + +```text +PostgreSQL-native UUIDv7 when supported and selected +or +application-generated UUIDv7 +``` + +The API format is identical either way. + +--- + +## 12. Authentication + +Initial human authentication: + +```text +Email ++ +Password ++ +Short-Lived Access Token ++ +Opaque Refresh Token ++ +Server-Side Session +``` + +REST: + +```http +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/logout + +POST /api/v1/auth/token/refresh +POST /api/v1/auth/token/revoke +POST /api/v1/auth/token/revoke-all + +GET /api/v1/auth/sessions +DELETE /api/v1/auth/sessions/{sessionId} + +GET /api/v1/me +``` + +`POST /auth/logout` revokes the current session. + +`DELETE /auth/sessions/{sessionId}` allows a user to revoke a specific session, such as another device. + +### Access Token + +```yaml +format: JWT +lifetime: short-lived +signed: true +encrypted: false +claims: + - sub + - sessionId + - issuer + - audience + - issuedAt + - expiresAt +``` + +Organization context is not trusted from the token as authorization authority. + +### Sessions + +```text +sessions +├── id +├── user_id +├── device metadata +├── created_at +├── last_active_at +├── expires_at +├── revoked_at +└── revocation_reason +``` + +### Refresh Tokens + +```text +refresh_tokens +├── id +├── session_id +├── family_id +├── token_hash +├── issued_at +├── expires_at +├── rotated_at +├── replaced_by_token_id +├── revoked_at +└── revocation_reason +``` + +Constraints/indexes: + +```text +UNIQUE(token_hash) +INDEX(family_id) +INDEX(session_id) +``` + +`family_id` is not unique. + +### Refresh Reuse Detection + +Use of a previously rotated token triggers: + +```text +revoke token family +revoke affected session +security audit event +reauthentication +``` + +Policy may escalate to all-session revocation. + +Future human authentication: + +- MFA +- WebAuthn/passkeys +- OIDC/SSO +- enterprise identity providers + + +## 12A. Service Accounts and API Keys + +Machine-to-machine access is separate from human sessions. + +Use: + +```text +service_accounts +api_keys +service_account_roles +``` + +### Service Account + +Suggested fields: + +```text +id +organization_id +name +description +status +created_by_user_id +created_at +updated_at +revoked_at +``` + +### API Key + +Suggested fields: + +```text +id +organization_id +service_account_id + +key_prefix +secret_hash + +created_at +expires_at +last_used_at +revoked_at +revocation_reason +``` + +Raw API-key secrets are shown only once. + +Store only a secure hash of the secret. + +`key_prefix` is safe display material for identifying a key in administration screens. + +### Authorization + +Service accounts use explicit organization-scoped permissions, preferably through: + +```text +service_account_roles +``` + +with the same registered permission vocabulary used by RBAC. + +They do not become fake human memberships. + +### Audit + +Audit actors support: + +```text +actor_type = user +actor_type = service_account +actor_type = system +``` + +Machine authentication is required when public/integration API access is implemented; it does not block the earliest internal Engineering UI slice. + +--- + +## 13. Shared Core Backend + +Recommended modules: + +```text +core/ +├── auth/ +├── users/ +├── organizations/ +├── memberships/ +├── roles/ +├── permissions/ +├── authorization/ +├── documents/ +├── billing/ +├── notifications/ +├── audit/ +└── events/ +``` + +Dependency rule: + +```text +Profession module → Core +``` + +Never: + +```text +Core → Profession module +``` + +--- + +## 14. Organizations + +Organizations are tenants. + +Examples: + +```text +Atlas Structural Engineering +Smith & Associates Law +North Shore Medical Practice +``` + +Suggested fields: + +```text +id +name +slug +status +country_code +timezone +currency_code +created_at +updated_at +``` + +--- + +## 15. Profession Enablement + +Use: + +```text +organization_professions +``` + +Suggested fields: + +```text +organization_id +profession +enabled_at +configuration +``` + +Possible professions: + +```text +engineering +legal +healthcare +``` + +An organization may eventually enable more than one profession module. + +--- + +## 16. Users and Memberships + +Users are global identities. + +A user gains tenant access through membership. + +```text +User + │ + ▼ +Membership + │ + ▼ +Organization +``` + +Suggested `users` fields: + +```text +id +email +first_name +last_name +phone +avatar_url +status +created_at +updated_at +``` + +Suggested `memberships` fields: + +```text +id +organization_id +user_id +status +joined_at +created_at +updated_at +``` + +--- + +## 17. Membership Invitations + +Keep invitations separate from memberships. + +Tables: + +```text +membership_invitations +membership_invitation_roles +``` + +`membership_invitations`: + +```text +id +organization_id +email +invited_by_user_id +expires_at +accepted_at +revoked_at +created_at +``` + +`membership_invitation_roles`: + +```text +organization_id +invitation_id +role_id +created_at +``` + +Use tenant-aware foreign keys so invitation roles cannot reference another organization's role. + +Flow: + +```text +Invitation + Intended Roles + ↓ + Accepted + ↓ + User + ↓ + Membership + ↓ + Membership Roles +``` + +At acceptance: + +1. validate invitation token and expiry +2. validate invited email/account policy +3. create membership +4. copy valid intended roles to membership-role assignments +5. mark invitation accepted +6. audit +7. emit outbox event + +If an intended role was revoked/deleted before acceptance, acceptance fails safely or drops that role according to explicit organization policy. + +## 18. Authorization + +Use: + +```text +RBAC ++ +Permission Scope ++ +Resource Policies ++ +Professional Qualification Policies ++ +Domain State Rules +``` + +Decision flow: + +```text +Authenticated User + ↓ +Explicit Organization Context + ↓ +Active Membership + ↓ +Enabled Profession Module + ↓ +Roles + ↓ +Permissions + ↓ +Permission Scope + ↓ +Tenant-scoped Resource Query + ↓ +Resource Policy + ↓ +Credential/Jurisdiction Policy + ↓ +Domain State Rule + ↓ +ALLOW / DENY +``` + +Default decision: + +```text +DENY +``` + +Authorization rules: + +1. Controllers never perform ad-hoc role comparisons. +2. Tenant resource queries always include `organization_id`. +3. Do not load an arbitrary resource first and then discover it belongs to another tenant. +4. High-risk professional actions perform credential checks at command execution time. +5. A permission grants the ability to attempt an action, not a guarantee the domain state allows it. +6. Cross-tenant resources appear nonexistent. +7. Profession module enablement is checked before profession-specific authorization. + +## 19. Roles and Permissions + +Roles are organization-scoped collections of permissions. + +Example roles: + +```text +Owner +Administrator +Project Manager +Engineer +Reviewer +Inspector +Lawyer +Paralegal +Doctor +Nurse +Billing Manager +Viewer +``` + +Roles are not professional credentials. + +### Engineering Permissions + +```text +engineering.clients.read +engineering.clients.create +engineering.clients.update +engineering.clients.archive + +engineering.projects.read +engineering.projects.create +engineering.projects.update +engineering.projects.activate +engineering.projects.close +engineering.projects.archive + +engineering.project_members.manage +engineering.phases.manage +engineering.tasks.manage +engineering.sites.manage + +engineering.documents.read +engineering.documents.upload +engineering.documents.delete + +engineering.designs.read +engineering.designs.create +engineering.designs.update +engineering.designs.review +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede + +engineering.inspections.read +engineering.inspections.manage +engineering.inspections.complete + +engineering.time_entries.manage +engineering.reports.read +``` + +### Legal Permissions + +```text +legal.clients.read +legal.clients.create +legal.clients.update + +legal.matters.read +legal.matters.create +legal.matters.update +legal.matters.close +legal.matters.reopen + +legal.cases.read +legal.cases.manage +legal.hearings.manage +legal.deadlines.manage + +legal.documents.read +legal.documents.upload + +legal.conflicts.manage +legal.conflicts.approve + +legal.retainers.manage +legal.time_entries.manage +``` + +### Healthcare Permissions + +```text +healthcare.patients.read +healthcare.patients.create +healthcare.patients.update + +healthcare.appointments.read +healthcare.appointments.manage + +healthcare.encounters.read +healthcare.encounters.manage + +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.records.access_log.read + +healthcare.prescriptions.read +healthcare.prescriptions.write +healthcare.prescriptions.sign + +healthcare.insurance.read +healthcare.insurance.manage +``` + +### Shared Permissions + +```text +documents.read +documents.upload + +billing.read +invoices.create +invoices.issue +invoices.void +payments.record +payments.refund + +members.read +members.invite +members.update +members.remove + +roles.read +roles.manage + +audit.read +``` + +Avoid vague permissions such as `admin_everything` in normal tenant RBAC. + +## 20. Permission Scopes + +Initial scopes: + +```text +assigned +organization +``` + +Example: + +```text +Engineer: +engineering.projects.read = assigned + +Principal Engineer: +engineering.projects.read = organization +``` + +`assigned` is not magic. Each resource policy defines how assignment is resolved. + +### Engineering Project + +Assigned when: + +```text +engineering_project_members.user_id = ctx.userId +AND engineering_project_members.left_at IS NULL +``` + +or when the user is the active project manager, if project-manager assignment is modeled separately. + +### Engineering Task + +Assigned when: + +```text +engineering_tasks.assigned_to_user_id = ctx.userId +``` + +For tasks linked to a project, parent-project access may also be required. + +### Engineering Design + +Assigned when an active row exists in: + +```text +engineering_design_assignments +``` + +for the user and an allowed assignment role. + +### Engineering Inspection + +Assigned when: + +```text +engineering_inspections.inspector_user_id = ctx.userId +``` + +or an explicit inspection assignment exists if the model later supports multiple inspectors. + +### Derived Client Access + +An assigned professional may access a client only through a policy that derives access from authorized projects. + +Project assignment must not automatically grant access to every project belonging to that client. + +Future scopes may include: + +```text +owned +team +department +restricted +``` + +Do not add them before a real workflow requires them. + +## 21. Professional Credentials + +Professional identity and credentials are separate from RBAC. + +Use: + +```text +professional_profiles +professional_credentials +``` + +### Professional Profile + +One organization/user/profession relationship. + +Suggested fields: + +```text +id +organization_id +user_id +profession +title +status +created_at +updated_at +``` + +### Professional Credential + +One profile may hold many credentials. + +Suggested fields: + +```text +id +organization_id +professional_profile_id + +credential_type +credential_number +issuing_authority +jurisdiction +discipline + +status +valid_from +expires_at + +verified_at +verified_by_user_id + +created_at +updated_at +``` + +Examples: + +```text +professional engineering license in jurisdiction A +professional engineering license in jurisdiction B +specialty certification +medical license +controlled-substance prescribing registration where applicable +``` + +Credential policy evaluates the set of active credentials rather than one `primary_license_number`. + +High-risk actions such as design approval, record signing, or prescribing use authoritative or revocation-aware credential state. + +Prescribing remains jurisdiction/scope-of-practice policy, not a hard-coded profession test. + +## 22. Database Architecture + +Use PostgreSQL. + +Start with: + +```text +One database ++ +Shared schema ++ +Profession-specific tables +``` + +Do not begin with database-per-profession or database-per-customer unless compliance or residency requirements force that choice. + +--- + +## 23. Shared Tables + +Recommended shared tables: + +```text +organizations +organization_professions + +users +user_credentials +sessions + +memberships +membership_invitations + +roles +permissions +role_permissions +membership_roles + +professional_profiles + +documents +document_versions + +invoices +invoice_items +payments + +notifications +notification_deliveries + +audit_events +outbox_events +``` + +--- + +## 24. Multi-Tenancy Rule + +Every tenant-owned row must contain: + +```text +organization_id +``` + +Examples: + +```text +engineering_projects.organization_id +legal_matters.organization_id +healthcare_patients.organization_id +``` + +Enforce tenant boundaries at: + +- API layer +- authorization layer +- repository/query layer +- database constraints + +--- + +## 25. Tenant-Safe Foreign Keys + +Use composite tenant-aware foreign keys when possible. + +Example: + +```text +engineering_projects +organization_id +client_id +``` + +references: + +```text +engineering_clients +organization_id +id +``` + +This prevents linking a resource from one organization to another organization's data. + +--- + +## 21A. Deletion, Archival, Revocation, and Unlink Policy + +`DELETE` does not have one universal persistence meaning. + +Use four lifecycle behaviors. + +### Archive / Domain Inactivation + +For business records whose history matters: + +```text +engineering clients +engineering projects +legal matters +healthcare patients +documents where retention requires history +``` + +Typical fields: + +```text +status +archived_at +archived_by_user_id +``` + +Restore is permitted only when domain, retention, and organization policy allow it. + +### Revoke + +For access/security resources: + +```text +sessions +refresh tokens +API keys +membership invitations +portal grants +webhook credentials +``` + +Use: + +```text +revoked_at +revoked_by +revocation_reason +``` + +### Temporal Unlink + +For relationship records where the historical relationship matters: + +```text +project documents +project members +design assignments +portal document publications +``` + +Use: + +```text +unlinked_at +left_at +unassigned_at +revoked_at +``` + +rather than deleting historical evidence. + +### Hard Delete + +Reserved for genuinely disposable or never-committed data, such as: + +```text +expired pending upload artifacts +failed temporary staging objects +unreferenced draft configuration where audit/retention does not require history +``` + +Hard deletion of financial, professional, audit, signed clinical, or issued business records is forbidden unless an explicit retention/privacy policy defines the operation. + +Every resource specification must declare its lifecycle behavior. + +--- + +# Engineering Domain + +## 26. Engineering Tables + +Initial Engineering MVP tables: + +```text +engineering_clients +engineering_client_contacts + +engineering_projects +engineering_project_members +engineering_project_phases +engineering_sites +engineering_tasks + +engineering_designs +engineering_design_assignments +engineering_design_versions +engineering_design_version_documents +engineering_design_reviews + +engineering_inspections +engineering_inspection_findings +engineering_inspection_followups + +engineering_specifications + +engineering_time_entries +``` + +Later Engineering extensions: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries + +engineering_change_requests +``` + +`engineering_change_requests` is not part of the initial schema until its lifecycle, relationships, and REST contract are specified. + +## 27. Engineering Clients + +Suggested core client fields: + +```text +id +organization_id +client_type +display_name +legal_name +status +created_at +updated_at +version +``` + +Do not permanently squeeze all contacts into one `email`, one `phone`, and one `contact_name`. + +Engineering customers commonly have multiple: + +```text +technical contacts +billing contacts +executive contacts +site contacts +contract contacts +``` + +Use: + +```text +engineering_client_contacts +``` + +Suggested contact fields: + +```text +id +organization_id +client_id + +name +title +department + +email +phone + +contact_type +is_primary + +created_at +updated_at +``` + +Client REST: + +```http +GET /api/v1/engineering/clients +POST /api/v1/engineering/clients +GET /api/v1/engineering/clients/{clientId} +PATCH /api/v1/engineering/clients/{clientId} + +POST /api/v1/engineering/clients/{clientId}/archive +POST /api/v1/engineering/clients/{clientId}/restore + +GET /api/v1/engineering/clients/{clientId}/projects +GET /api/v1/engineering/clients/{clientId}/invoices +``` + +Contact REST: + +```http +GET /api/v1/engineering/clients/{clientId}/contacts +POST /api/v1/engineering/clients/{clientId}/contacts +PATCH /api/v1/engineering/clients/{clientId}/contacts/{contactId} +DELETE /api/v1/engineering/clients/{clientId}/contacts/{contactId} +``` + +Delete may be implemented as archival when contact history matters. + +Client restore is allowed only when organization policy and retention rules permit it. + +## 27A. Engineering Client Portal + +External clients are not internal organization members. + +Use shared authentication identities where practical, but create a separate authorization boundary. + +```text +User + │ + ├── Internal Membership + │ ↓ + │ Organization Staff Access + │ + └── Client Portal Account + ↓ + Engineering Client Contact + ↓ + Project Access Grants +``` + +Suggested tables: + +```text +engineering_client_portal_accounts +engineering_client_portal_project_grants +engineering_project_document_publications +engineering_client_review_requests +``` + +### Portal Account + +Suggested fields: + +```text +id +organization_id +user_id +engineering_client_contact_id + +status + +invited_by_user_id +invited_at +accepted_at + +revoked_at +revoked_by_user_id +``` + +Portal accounts are not placed in `memberships`. + +### Project Grant + +Suggested fields: + +```text +id +organization_id +portal_account_id +project_id + +access_profile + +granted_by_user_id +granted_at +expires_at +revoked_at +``` + +Initial access capabilities may include: + +```text +project.status.read +project.documents.read_published +project.comments.create +project.files.submit +client_review.respond +``` + +The access model may later normalize capabilities into a grant table if simple profiles become insufficient. + +### Separate Frontend + +Recommended: + +```text +apps/ +├── engineering-web/ +└── engineering-client-portal/ +``` + +The internal engineering frontend and external portal do not share authorization assumptions. + +### Client Acceptance Is Not Engineering Approval + +Never represent client acceptance with: + +```text +engineering.designs.approve +``` + +Professional engineering approval is reserved for qualified internal/authorized professionals. + +Client-facing review should use separate concepts such as: + +```text +engineering.client_reviews.request +engineering.client_reviews.respond +engineering.client_reviews.accept +engineering.client_reviews.request_changes +``` + +Example: + +```http +POST /api/v1/engineering/client-review-requests/{reviewId}/accept +POST /api/v1/engineering/client-review-requests/{reviewId}/request-changes +``` + +A client acceptance may be commercially meaningful without being a professional engineering approval. + +### Portal Security Rules + +1. portal access is deny-by-default +2. every portal request remains organization-scoped +3. portal users only access explicitly granted projects +4. project membership does not apply to portal users +5. internal RBAC roles do not automatically apply to portal users +6. portal account revocation is immediate +7. portal grants may expire +8. sensitive document access requires explicit publication +9. portal activity is audited according to organization policy +10. professional approval endpoints are never exposed through portal grants + +--- + +## 27B. External Document Publication + +A document being linked to an engineering project does **not** make it externally visible. + +Use: + +```text +engineering_project_document_publications +``` + +Suggested fields: + +```text +id +organization_id + +project_document_link_id + +audience_type +portal_account_id nullable +client_id nullable + +published_by_user_id +published_at + +expires_at +revoked_at +revoked_by_user_id +``` + +Possible audiences: + +```text +all_active_client_portal_accounts_for_project +specific_portal_account +specific_client_contact +``` + +External download checks: + +```text +authenticated portal user ++ +active portal account ++ +active project grant ++ +active document publication ++ +publication not expired/revoked ++ +document classification allows publication ++ +download permission +``` + +This prevents an internal project document from appearing in the client portal merely because it is linked to the project. + +## 28. Engineering Projects + +Suggested fields: + +```text +id +organization_id +client_id + +project_number +name +description +discipline + +status + +project_manager_user_id + +start_date +expected_completion_date +completed_date + +created_at +updated_at +version +``` + +`stage` is removed from the project row because project phases are the authoritative workflow decomposition. + +If the frontend needs a "current stage", derive it from the active/current project phase or maintain an explicitly documented `current_phase_id` pointer. + +Project `budget_minor` is also removed. + +Detailed project budgets belong to the dedicated budget model. + +REST: + +```http +GET /api/v1/engineering/projects +POST /api/v1/engineering/projects +GET /api/v1/engineering/projects/{projectId} +PATCH /api/v1/engineering/projects/{projectId} + +POST /api/v1/engineering/projects/{projectId}/activate +POST /api/v1/engineering/projects/{projectId}/close +POST /api/v1/engineering/projects/{projectId}/archive +``` + +Purpose-built reads: + +```http +GET /api/v1/engineering/projects/{projectId}/summary +GET /api/v1/engineering/projects/{projectId}/timeline +GET /api/v1/engineering/projects/{projectId}/budget +``` + +The budget endpoint reads from the budget module when that module exists. + +## 29. Engineering Project Members + +Suggested fields: + +```text +id +organization_id +project_id +user_id +project_role +joined_at +left_at +``` + +Initial project-role vocabulary: + +```text +project_manager +engineer +designer +reviewer +inspector +viewer +contractor +``` + +Project role describes participation in one project. + +It is not a substitute for RBAC permission. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/members +POST /api/v1/engineering/projects/{projectId}/members +PATCH /api/v1/engineering/projects/{projectId}/members/{memberId} +DELETE /api/v1/engineering/projects/{projectId}/members/{memberId} +``` + +`DELETE` means end participation by setting `left_at`, not erase historical participation. + +## 30. Engineering Project Phases + +Suggested fields: + +```text +id +organization_id +project_id +name +sequence +status +start_date +end_date +created_at +updated_at +version +``` + +Typical initial statuses: + +```text +planned +active +completed +cancelled +``` + +Example phases: + +```text +Concept +Preliminary Design +Detailed Design +Construction +Inspection +Closeout +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/phases +POST /api/v1/engineering/projects/{projectId}/phases +PATCH /api/v1/engineering/projects/{projectId}/phases/{phaseId} + +POST /api/v1/engineering/projects/{projectId}/phases/{phaseId}/complete +POST /api/v1/engineering/projects/{projectId}/phases/reorder +``` + +Reorder request: + +```json +{ + "projectVersion": 12, + "orderedPhaseIds": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b301", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b302" + ] +} +``` + +Reordering is transactional. + +Sequences remain unique within a project after commit. + +## 31. Engineering Sites + +Suggested fields: + +```text +id +organization_id +project_id +name +address +latitude +longitude +created_at +updated_at +``` + +REST: + +```http +GET /api/v1/engineering/sites +GET /api/v1/engineering/sites/{siteId} + +POST /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/projects/{projectId}/sites + +PATCH /api/v1/engineering/sites/{siteId} +``` + +Global site listing is still tenant-scoped through `X-Organization-Id`. + +## 32. Engineering Tasks + +Suggested fields: + +```text +id +organization_id +project_id + +title +description + +status +priority + +created_by_user_id +assigned_to_user_id + +due_at +completed_at + +created_at +updated_at +version +``` + +Statuses: + +```text +todo +in_progress +completed +cancelled +``` + +Priorities: + +```text +low +medium +high +urgent +``` + +REST: + +```http +POST /api/v1/engineering/tasks +GET /api/v1/engineering/tasks +GET /api/v1/engineering/tasks/{taskId} +PATCH /api/v1/engineering/tasks/{taskId} + +POST /api/v1/engineering/tasks/{taskId}/complete +POST /api/v1/engineering/tasks/{taskId}/reopen +POST /api/v1/engineering/tasks/{taskId}/cancel +``` + +## 32A. Engineering Batch Operations + +Batch operations are useful for repetitive engineering workflows, but they must not bypass per-resource authorization or domain rules. + +Examples: + +```http +POST /api/v1/engineering/tasks/batch/assign +POST /api/v1/engineering/tasks/batch/complete + +POST /api/v1/engineering/time-entries/batch/submit +``` + +### Batch Execution Modes + +Every batch command explicitly defines one of: + +```text +atomic +partial +``` + +Atomic: + +```text +all resources succeed +or +entire operation fails +``` + +Partial: + +```text +each resource is evaluated independently +successful items commit +failed items return individual errors +``` + +Do not leave this behavior implicit. + +Example request: + +```json +{ + "taskIds": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c81", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c82", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c83" + ], + "assigneeUserId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d", + "mode": "partial" +} +``` + +Example response: + +```json +{ + "data": { + "succeeded": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c81", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c82" + ], + "failed": [ + { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c83", + "code": "RESOURCE_INVALID_STATE" + } + ] + } +} +``` + +### Authorization + +Each resource is evaluated for: + +```text +tenant +permission +scope +resource access +state validity +credential policy where applicable +``` + +Never authorize the first item and assume the remaining batch is equivalent. + +### Synchronous vs Asynchronous + +Small batches may execute synchronously. + +Large batches become jobs: + +```http +202 Accepted +``` + +with: + +```text +jobId +``` + +The synchronous/asynchronous threshold is configuration based on: + +```text +batch size +operation cost +database load +side effects +product tier +``` + +Financial or regulated batch actions require stricter idempotency and audit rules than ordinary task updates. + +--- + +## 33. Engineering Designs + +Suggested fields: + +```text +id +organization_id +project_id + +design_number +title +description +discipline + +status + +owner_user_id +prepared_by_user_id + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +States: + +```text +draft +under_review +changes_requested +approved +rejected +cancelled +withdrawn +superseded +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/designs +POST /api/v1/engineering/projects/{projectId}/designs + +GET /api/v1/engineering/designs/{designId} +PATCH /api/v1/engineering/designs/{designId} + +POST /api/v1/engineering/designs/{designId}/submit-review +POST /api/v1/engineering/designs/{designId}/request-changes +POST /api/v1/engineering/designs/{designId}/approve +POST /api/v1/engineering/designs/{designId}/reject +POST /api/v1/engineering/designs/{designId}/revise +POST /api/v1/engineering/designs/{designId}/cancel +POST /api/v1/engineering/designs/{designId}/withdraw +POST /api/v1/engineering/designs/{designId}/supersede + +POST /api/v1/engineering/designs/{designId}/assign +POST /api/v1/engineering/designs/{designId}/unassign + +GET /api/v1/engineering/designs/{designId}/versions +POST /api/v1/engineering/designs/{designId}/versions + +GET /api/v1/engineering/designs/{designId}/reviews +POST /api/v1/engineering/designs/{designId}/reviews +``` + +State machine: + +```text +draft + ├── submit-review ─────────────► under_review + └── cancel ────────────────────► cancelled + +under_review + ├── request-changes ───────────► changes_requested + ├── approve ───────────────────► approved + ├── reject ────────────────────► rejected + └── withdraw ──────────────────► withdrawn + +changes_requested + ├── submit-review ─────────────► under_review + └── withdraw ──────────────────► withdrawn + +rejected + └── revise ────────────────────► draft + +approved + └── supersede ─────────────────► superseded +``` + +Approval remains credential-aware, audited, and idempotent. + +## 34. Design Versions and Reviews + +A design version is a logical professional revision. + +It may have multiple document files. + +Use: + +```text +engineering_design_versions +engineering_design_version_documents +engineering_design_reviews +``` + +### Design Version + +```text +id +organization_id +design_id +version_number +created_by_user_id +created_at +``` + +Unique: + +```text +(organization_id, design_id, version_number) +``` + +### Design Version Documents + +```text +id +organization_id +design_version_id +document_id +document_role +linked_by_user_id +linked_at +unlinked_at +``` + +Possible `document_role` values: + +```text +primary_drawing +calculation +supporting_document +specification +attachment +``` + +A design version therefore supports one or many documents without putting `document_id` directly on the version. + +### Design Review + +```text +id +organization_id +design_id +design_version_id +reviewer_user_id +status +comments +reviewed_at +created_at +``` + +Statuses: + +```text +pending +approved +changes_requested +rejected +``` + +All three tables are tenant-owned and carry direct `organization_id`. + +## 35. Engineering Inspections + +Inspection fields: + +```text +id +organization_id +project_id +site_id + +inspection_type +inspector_user_id + +status +outcome + +scheduled_at +started_at +performed_at +cancelled_at + +summary + +created_at +updated_at +version +``` + +Lifecycle: + +```text +draft +scheduled +in_progress +completed +cancelled +``` + +Outcome: + +```text +passed +passed_with_observations +followup_required +failed +``` + +`inspection_type` is an application/domain registry rather than a PostgreSQL enum. + +Initial common keys may include: + +```text +structural +mechanical +electrical +safety +final +``` + +Organizations/modules may add supported types through controlled configuration later. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/inspections +POST /api/v1/engineering/projects/{projectId}/inspections + +GET /api/v1/engineering/inspections/{inspectionId} +PATCH /api/v1/engineering/inspections/{inspectionId} + +POST /api/v1/engineering/inspections/{inspectionId}/schedule +POST /api/v1/engineering/inspections/{inspectionId}/start +POST /api/v1/engineering/inspections/{inspectionId}/complete +POST /api/v1/engineering/inspections/{inspectionId}/cancel + +GET /api/v1/engineering/inspections/{inspectionId}/findings +POST /api/v1/engineering/inspections/{inspectionId}/findings + +GET /api/v1/engineering/inspections/{inspectionId}/followups +POST /api/v1/engineering/inspections/{inspectionId}/followups +``` + +Inspection completion may create follow-up records. + +Lifecycle and outcome remain separate. + +## 36. Inspection Findings + +`engineering_inspection_findings`: + +```text +id +organization_id +inspection_id + +severity +description +status + +resolved_at +resolved_by_user_id + +created_at +updated_at +version +``` + +Severity: + +```text +observation +minor +major +critical +``` + +Status: + +```text +open +in_progress +resolved +accepted_risk +``` + +REST: + +```http +POST /api/v1/engineering/inspections/{inspectionId}/findings +PATCH /api/v1/engineering/inspection-findings/{findingId} +POST /api/v1/engineering/inspection-findings/{findingId}/resolve +``` + +`organization_id` is direct even though tenant ownership is also derivable through the inspection. + +### Follow-Up Resource + +Use: + +```text +engineering_inspection_followups +``` + +Fields: + +```text +id +organization_id +inspection_id + +followup_type + +linked_task_id nullable +linked_inspection_id nullable + +status + +created_by_user_id +created_at +completed_at +cancelled_at +``` + +`followup_type`: + +```text +corrective_task +followup_inspection +both +``` + +`status`: + +```text +open +in_progress +completed +cancelled +``` + +Tenant-safe foreign keys apply to the original inspection and any linked task/inspection. + +## 37. Engineering Specifications + +Suggested fields: + +```text +id +organization_id +project_id +specification_number +title +version +status +document_id +created_at +updated_at +``` + +--- + +## 38. Engineering Change Requests + +**Deferred from the initial Engineering schema.** + +Change requests are a valid future engineering capability, but v4.1 does not create the table until these are specified: + +```text +relationship to project +relationship to design/specification +request origin +impact analysis +cost/schedule effects +review workflow +approval authority +state machine +document links +REST commands +audit requirements +``` + +Future candidate: + +```text +engineering_change_requests +``` + +This belongs in the Engineering extension backlog rather than a half-defined initial migration. + +## 38A. Engineering Project Budgets + +A single `budget_minor` column is sufficient only for a very early project total. + +When budget management enters scope, introduce: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries +``` + +### Budget + +Suggested fields: + +```text +id +organization_id +project_id + +name +currency_code +status + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +### Budget Item + +Suggested fields: + +```text +id +organization_id +budget_id + +category +description + +allocated_amount_minor + +created_at +updated_at +``` + +Do not casually store mutable: + +```text +spent_amount_minor +committed_amount_minor +``` + +as independent sources of truth if those values are derived from time entries, expenses, purchase commitments, or invoices. + +Prefer: + +```text +authoritative cost/commitment records + ↓ +derived budget projections +``` + +If denormalized totals are needed for performance, update them transactionally and reconcile them. + +Potential REST: + +```http +GET /api/v1/engineering/projects/{projectId}/budgets +POST /api/v1/engineering/projects/{projectId}/budgets +GET /api/v1/engineering/budgets/{budgetId} +PATCH /api/v1/engineering/budgets/{budgetId} + +POST /api/v1/engineering/budgets/{budgetId}/approve +GET /api/v1/engineering/budgets/{budgetId}/items +POST /api/v1/engineering/budgets/{budgetId}/items +``` + +Budget approval is an explicit command. + +--- + +## 39. Engineering Time Entries + +Suggested fields: + +```text +id +organization_id +project_id +user_id + +work_date +duration_minutes +description + +billable +billing_rate_minor +currency_code + +phase_id nullable +task_id nullable +design_id nullable +inspection_id nullable + +created_at +updated_at +version +``` + +The project is always required. + +A time entry may also identify one primary work item. + +Database check: + +```text +at most one of: +phase_id +task_id +design_id +inspection_id +``` + +Each optional foreign key is tenant-aware: + +```text +(organization_id, task_id) +→ engineering_tasks(organization_id, id) +``` + +and similarly for phase, design, and inspection. + +This preserves relational integrity instead of using an unconstrained polymorphic `reference_type/reference_id`. + +REST: + +```http +POST /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries/{timeEntryId} +PATCH /api/v1/engineering/time-entries/{timeEntryId} + +POST /api/v1/engineering/time-entries/batch/submit +``` + +Duration is integer minutes. + +## 40. Legal Tables + +Initial legal-domain tables: + +```text +legal_clients +legal_matters +legal_matter_members +legal_cases +legal_case_parties +legal_courts +legal_hearings +legal_deadlines +legal_time_entries +legal_retainers +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +There is no separate `legal_documents` ownership table. + +Documents remain shared infrastructure: + +```text +documents +document_versions +``` + +Legal relationships use: + +```text +legal_matter_documents +legal_case_documents +``` + +REST namespace: + +```text +/api/v1/legal +``` + +Legal remains a later vertical. + +## 41. Legal Matters + +Suggested fields: + +```text +id +organization_id +client_id +matter_number +title +practice_area +responsible_lawyer_user_id +status +opened_date +closed_date +created_at +updated_at +``` + +--- + +## 42. Legal Cases + +Suggested fields: + +```text +id +organization_id +matter_id +case_number +court_id +jurisdiction +case_type +status +filed_date +created_at +updated_at +``` + +--- + +## 43. Legal Hearings + +Suggested fields: + +```text +id +organization_id +case_id +hearing_type +scheduled_at +courtroom +judge +status +notes +``` + +--- + +## 44. Legal Conflict Checks + +Suggested tables: + +```text +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +Conflict-check fields: + +```text +id +organization_id +potential_client_name +matter_description +requested_by_user_id +reviewed_by_user_id +status +decision +decision_reason +created_at +reviewed_at +version +``` + +Request example: + +```json +{ + "potentialClientName": "Acme Corporation", + "relatedParties": [ + { + "name": "John Smith", + "relationship": "CEO" + }, + { + "name": "Acme Subsidiary LLC", + "relationship": "Subsidiary" + } + ], + "matterDescription": "Corporate acquisition" +} +``` + +Response may contain possible matches: + +```json +{ + "data": { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cbd", + "status": "pending_review", + "potentialConflicts": [ + { + "type": "possible_direct_adversity", + "partyName": "Acme Corporation", + "existingMatterId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2ccd", + "existingMatterNumber": "MAT-2026-089" + } + ] + } +} +``` + +The system should distinguish: + +```text +automated possible match +``` + +from: + +```text +lawyer-approved conflict determination +``` + +The software may assist discovery; it should not silently make the professional judgment. + +Approvals and declines are auditable commands. + +## 45. Healthcare Tables + +Healthcare remains a later vertical. + +Minimum planned tables: + +```text +healthcare_patients +healthcare_patient_contacts +healthcare_patient_addresses + +healthcare_practitioners +healthcare_locations +healthcare_rooms + +healthcare_appointments +healthcare_encounters + +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments + +healthcare_diagnoses +healthcare_prescriptions +healthcare_insurance_policies +healthcare_allergies +healthcare_medications +``` + +All tenant-owned tables carry direct `organization_id`. + +Detailed healthcare interoperability, terminology, and jurisdiction rules require healthcare-specific design before implementation. + +## 46. Healthcare Patients + +Core patient: + +```text +id +organization_id +patient_number + +first_name +middle_name +last_name +date_of_birth + +administrative_gender nullable +sex_at_birth nullable +gender_identity nullable + +status + +created_at +updated_at +version +``` + +Exact demographic terminology and allowed values are finalized in the healthcare-domain specification. + +Do not make every field mandatory merely because it exists. + +### Patient Contact + +`healthcare_patient_contacts`: + +```text +id +organization_id +patient_id + +contact_type +value +is_primary + +created_at +updated_at +``` + +### Patient Address + +`healthcare_patient_addresses`: + +```text +id +organization_id +patient_id + +address_type +line_1 +line_2 +city +region +postal_code +country_code + +is_primary + +created_at +updated_at +``` + +Sensitive subresources remain permission-controlled. + +## 47. Healthcare Practitioners + +Suggested fields: + +```text +id +organization_id +user_id +professional_profile_id + +specialty +status + +created_at +updated_at +``` + +Professional licenses are not duplicated here. + +Multiple licenses/credentials live in: + +```text +professional_credentials +``` + +## 48. Healthcare Appointments + +Suggested fields: + +```text +id +organization_id + +patient_id +practitioner_id + +location_id nullable +room_id nullable + +appointment_type + +starts_at +ends_at + +status +reason + +created_at +updated_at +version +``` + +Planned supporting tables: + +`healthcare_locations`: + +```text +id +organization_id +name +address fields +timezone +status +``` + +`healthcare_rooms`: + +```text +id +organization_id +location_id +name +status +``` + +Exact scheduling rules are deferred to the healthcare vertical. + +## 49. Healthcare Encounters + +Suggested fields: + +```text +id +organization_id + +patient_id +practitioner_id +appointment_id nullable + +encounter_type + +reason_for_visit nullable + +started_at +ended_at + +status + +created_at +updated_at +version +``` + +Do not add a generic free-form `notes` field as a substitute for clinical records. + +Clinical narrative belongs in governed clinical-record structures. + +## 50. Clinical Records + +Use: + +```text +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +``` + +### Clinical Record + +```text +id +organization_id +patient_id +encounter_id +author_practitioner_id + +record_type +sensitivity_level +status + +signed_by_practitioner_id +signed_at + +created_at +updated_at +version +``` + +### Clinical Record Version + +```text +id +organization_id +record_id +version_number + +content_reference or governed content payload +created_by_practitioner_id +created_at +``` + +### Clinical Record Amendment + +```text +id +organization_id +record_id +source_version_id +result_version_id + +amended_by_practitioner_id + +amendment_type +amendment_reason + +created_at +``` + +Possible amendment types: + +```text +correction +addendum +clarification +``` + +Signed/finalized history is preserved. + +REST: + +```http +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} +PATCH /api/v1/healthcare/clinical-records/{recordId} +# Draft/editable only. + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend + +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log +``` + +## 51. Documents + +Shared document infrastructure: + +```text +documents +document_versions +document_categories +retention_policies +``` + +Binary data lives in S3-compatible object storage. + +### Document + +```text +id +organization_id +name +category_id +classification +retention_policy_id +current_version_id +created_by_user_id +created_at +updated_at +``` + +Classification: + +```text +public +internal +confidential +restricted +regulated +``` + +### Document Version + +```text +id +organization_id +document_id +version_number +storage_key +mime_type +size_bytes +content_hash +hash_algorithm +uploaded_by_user_id +created_at +``` + +Checksum is version-level authoritative data. + +### Document Category + +```text +id +organization_id +profession nullable +name +parent_category_id +created_at +``` + +Uniqueness requirement: + +```text +shared category: + unique organization_id + name where profession IS NULL + +profession category: + unique organization_id + profession + name where profession IS NOT NULL +``` + +Implementation options: + +```text +PostgreSQL null-aware unique constraint when supported +or +two partial unique indexes +``` + +The partial-index fallback does not depend on selecting PostgreSQL 18. + +### Retention Policy + +```text +id +organization_id + +name +profession nullable +classification nullable + +retention_period_days nullable +action + +created_at +updated_at +``` + +Initial actions: + +```text +review +archive +delete_when_legally_permitted +retain_indefinitely +``` + +A retention policy describes configured behavior. + +Actual deletion remains subject to domain, contractual, privacy, and jurisdiction requirements. + +### Metadata + +JSONB is allowed only for genuinely extensible, non-authoritative metadata. + +Do not put authorization, lifecycle, retention state, or ownership into arbitrary JSON. + +## 52. Document Upload Flow + +### Standard Upload + +Request: + +```http +POST /api/v1/documents/upload-url +``` + +```json +{ + "name": "structural-calculations.pdf", + "categoryId": null, + "classification": "confidential", + "mimeType": "application/pdf", + "sizeBytes": 2457600, + "contentHash": "sha256:..." +} +``` + +Response: + +```json +{ + "data": { + "documentId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d01", + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d02", + "uploadUrl": "https://object-storage.example/...", + "expiresAt": "2026-08-26T13:00:00Z" + } +} +``` + +The frontend uploads directly to object storage. + +Finalize: + +```http +POST /api/v1/documents/{documentId}/complete-upload +``` + +```json +{ + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d02", + "contentHash": "sha256:..." +} +``` + +### Multipart Initialization + +```http +POST /api/v1/documents/multipart-uploads +``` + +Request: + +```json +{ + "name": "building-model.bin", + "categoryId": null, + "classification": "confidential", + "mimeType": "application/octet-stream", + "sizeBytes": 2147483648, + "contentHash": null +} +``` + +Response: + +```json +{ + "data": { + "documentId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d10", + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d11", + "uploadId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d12", + "recommendedPartSizeBytes": 67108864, + "expiresAt": "2026-08-27T12:00:00Z" + } +} +``` + +### Request Signed Part URLs + +```http +POST /api/v1/documents/{documentId}/multipart-uploads/{uploadId}/parts +``` + +```json +{ + "partNumbers": [1, 2, 3, 4] +} +``` + +Response: + +```json +{ + "data": [ + { + "partNumber": 1, + "uploadUrl": "https://object-storage.example/..." + } + ] +} +``` + +Binary parts go directly to object storage. + +### Complete Multipart Upload + +```http +POST /api/v1/documents/{documentId}/multipart-uploads/{uploadId}/complete +``` + +```json +{ + "parts": [ + { + "partNumber": 1, + "etag": "..." + } + ], + "contentHash": "sha256:..." +} +``` + +Abort: + +```http +DELETE /api/v1/documents/{documentId}/multipart-uploads/{uploadId} +``` + +Upload state: + +```text +initiated +uploading +completing +completed +aborted +expired +``` + +Workers clean up abandoned multipart uploads. + +Upload policy validates: + +```text +declared MIME +extension +content signature +size +checksum +quota +classification +malware status +``` + +## 53. Profession-Specific Document Links + +Use explicit relationship tables. + +Engineering: + +```text +engineering_project_documents +engineering_design_version_documents +engineering_inspection_documents +``` + +Legal: + +```text +legal_matter_documents +legal_case_documents +``` + +Healthcare: + +```text +healthcare_patient_documents +healthcare_encounter_documents +``` + +`engineering_design_version_documents` is authoritative for files belonging to a specific design revision. + +Do not also maintain an ambiguous `engineering_design_documents` relation to the unversioned design unless a later requirement introduces a separate clearly named supporting-document relationship. + +### Project Documents + +```text +engineering_project_documents +├── id +├── organization_id +├── project_id +├── document_id +├── category +├── linked_by_user_id +├── linked_at +└── unlinked_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/documents +POST /api/v1/engineering/projects/{projectId}/documents +DELETE /api/v1/engineering/project-documents/{documentLinkId} +``` + +`DELETE` temporally unlinks the relation when history must be preserved. + +## 54. Billing + +Shared financial core: + +```text +invoices +invoice_items +payments +``` + +### Invoice + +Core fields include: + +```text +id +organization_id +client/reference context +invoice_number +status +currency_code +subtotal_minor +tax_total_minor +total_minor +issued_at +due_at +paid_at +created_at +updated_at +version +``` + +### Invoice Item + +```text +id +organization_id +invoice_id + +description + +quantity +unit_price_minor +total_amount_minor + +position + +created_at +updated_at +``` + +`quantity` uses fixed-precision numeric semantics, not floating point. + +Money uses integer minor units. + +The invoice determines currency; invoice items do not independently choose a different currency unless multi-currency invoicing is intentionally designed later. + +### Profession-Specific Source Links + +The shared billing module does not use unconstrained: + +```text +reference_type +reference_id +``` + +to profession-owned tables. + +Profession modules create explicit links, for example: + +```text +engineering_invoice_item_time_entries +├── organization_id +├── invoice_item_id +└── time_entry_id +``` + +This preserves the rule that shared core does not depend on profession-table internals. + +REST: + +```http +POST /api/v1/invoices +GET /api/v1/invoices +GET /api/v1/invoices/{invoiceId} +PATCH /api/v1/invoices/{invoiceId} + +POST /api/v1/invoices/{invoiceId}/issue +POST /api/v1/invoices/{invoiceId}/void +POST /api/v1/invoices/{invoiceId}/payments +POST /api/v1/payments/{paymentId}/refund +``` + +## 55. Money Representation + +Use integer minor units: + +```json +{ + "amountMinor": 12550, + "currency": "USD" +} +``` + +Meaning: + +```text +$125.50 +``` + +Never use floating point for money. + +--- + +## 56. Audit Logging + +Use: + +```text +audit_events +``` + +Suggested fields: + +```text +id +organization_id + +actor_type +actor_user_id nullable +actor_service_account_id nullable + +action + +resource_type +resource_id + +request_id +correlation_id + +ip_address +user_agent + +metadata + +occurred_at +``` + +Audit records are append-only from normal application workflows. + +### Mandatory Examples + +Engineering: + +```text +engineering.projects.create +engineering.projects.close +engineering.designs.approve +engineering.inspections.complete +``` + +Legal: + +```text +legal.matters.create +legal.matters.close +legal.conflicts.approve +``` + +Healthcare: + +```text +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +``` + +### Privacy / Erasure Handling + +Append-only audit does not mean "store unlimited personal data forever." + +Audit metadata must be minimized at write time. + +Where privacy, contractual, or retention obligations require removal of personally identifying material, use a governed privacy process such as: + +```text +pseudonymize actor references +null/remove nonessential PII fields +replace identifiers with irreversible privacy references where appropriate +retain the security/business event itself when permitted/required +``` + +The exact action depends on jurisdiction and retention policy and must be reviewed before healthcare/legal production. + +Do not place passwords, tokens, full clinical content, secret keys, or unnecessary payment data in audit metadata. + +REST: + +```http +GET /api/v1/audit-events +``` + +No public mutation endpoints. + +## 57. Domain Events and Transactional Outbox + +Use: + +```text +outbox_events +``` + +Fields: + +```text +id +organization_id nullable for truly global events + +event_type +aggregate_type +aggregate_id + +payload + +request_id nullable +correlation_id +causation_id nullable + +occurred_at +available_at +processed_at + +attempt_count +last_error +dead_lettered_at +``` + +`correlation_id` groups one logical workflow across requests/jobs/events. + +`causation_id` identifies the event/command that directly caused this event when applicable. + +Transaction: + +```text +BEGIN +business change +audit event +outbox event +COMMIT +``` + +Delivery semantics are at-least-once. + +Worker claim uses row locking such as: + +```sql +SELECT id +FROM outbox_events +WHERE processed_at IS NULL + AND dead_lettered_at IS NULL + AND available_at <= now() +ORDER BY occurred_at +FOR UPDATE SKIP LOCKED +LIMIT 100; +``` + +Every external side-effect consumer must be idempotent. + +`FOR UPDATE SKIP LOCKED` prevents simultaneous claiming; it does not prevent duplicate side effects after a worker crash. + +## 57A. Webhooks and External Integrations + +Shared tables: + +```text +webhooks +webhook_event_subscriptions +webhook_deliveries +``` + +### Webhook + +```text +id +organization_id +url +status +secret_ciphertext or signing_key_reference +created_by_user_id +created_at +updated_at +``` + +### Subscription + +```text +id +organization_id +webhook_id +event_type +created_at +``` + +Unique: + +```text +(organization_id, webhook_id, event_type) +``` + +Only registered externally publishable event types may be subscribed. + +### Delivery + +```text +id +organization_id +webhook_id +event_id + +attempt_number +request_timestamp +response_status +response_summary + +delivered_at +failed_at +next_attempt_at +``` + +Configuration REST: + +```http +GET /api/v1/webhooks +POST /api/v1/webhooks +GET /api/v1/webhooks/{webhookId} +PATCH /api/v1/webhooks/{webhookId} +DELETE /api/v1/webhooks/{webhookId} + +POST /api/v1/webhooks/{webhookId}/test +POST /api/v1/webhooks/{webhookId}/rotate-secret +``` + +Delivery REST: + +```http +GET /api/v1/webhook-deliveries +GET /api/v1/webhook-deliveries/{deliveryId} +POST /api/v1/webhook-deliveries/{deliveryId}/retry +``` + +If HMAC signing is used, signing material is encrypted/recoverable with managed key protection. + +A one-way secret hash is insufficient for outbound HMAC signing. + +Webhook consumers deduplicate using stable event IDs. + +## 58. Background Jobs + +Workers handle: + +```text +notifications +reports/PDFs +file scanning +document processing +imports +exports +bulk operations +webhooks +search indexing +large data operations +``` + +Use shared tenant-owned: + +```text +jobs +``` + +Fields: + +```text +id +organization_id +requested_by_user_id + +job_type +status + +input_reference +result_reference +progress_percent + +created_at +started_at +completed_at +failed_at + +error_code +error_summary +``` + +States: + +```text +queued +running +completed +failed +cancelled +``` + +REST: + +```http +GET /api/v1/jobs/{jobId} +GET /api/v1/jobs/{jobId}/result +POST /api/v1/jobs/{jobId}/cancel +``` + +These endpoints are tenant-scoped through the standard: + +```http +X-Organization-Id +``` + +They do not need `/organizations/{id}/jobs` because the platform already chose header-based tenant context. + +Large import/export operations return: + +```http +202 Accepted +``` + +with a job ID. + +## 59. Redis + +Use Redis as an acceleration and coordination layer, not the authoritative system of record. + +Appropriate uses: + +```text +job queue +rate-limit counters +short-lived authorization caches +organization configuration cache +session lookup acceleration +idempotency lookup acceleration +distributed locks when justified +``` + +### Cache Layers + +L1 optional application-memory cache: + +```text +static permission definitions +non-sensitive configuration +``` + +L2 Redis shared cache: + +```text +organization settings +membership snapshots +role permission snapshots +rate-limit counters +session lookup cache +recent idempotency lookups +``` + +CDN: + +```text +frontend static assets +explicitly public assets only +``` + +Do not cache private professional API responses at a CDN by default. + +### Cache Invalidation + +Invalidate or version caches when: + +```text +membership changes +role permissions change +organization settings change +professional credentials change +session is revoked +profession module enablement changes +``` + +High-risk authorization decisions must not depend solely on stale cached credential state. + +### Idempotency Durability + +Redis may improve idempotency lookup latency, but PostgreSQL remains authoritative for high-risk commands. + +## 60. Pagination + +Use cursor pagination. + +Defaults: + +```text +default limit = 25 +maximum limit = 100 +offset pagination = not supported +``` + +Example: + +```http +GET /api/v1/engineering/projects?limit=25 +``` + +Response: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Rules: + +```text +cursor is opaque +sort order must be deterministic +cursor encodes/represents the selected sort position +unsupported limits return validation errors rather than silent huge responses +``` + +## 61. Filtering + +Use explicit resource-specific filters. + +Examples: + +```http +GET /api/v1/engineering/projects?status=active&discipline=structural +GET /api/v1/engineering/tasks?status=todo&assignedToUserId=0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d +``` + +Do not build a generic query DSL in v1. + +--- + +## 62. Sorting + +Examples: + +```http +GET /api/v1/engineering/projects?sort=createdAt +GET /api/v1/engineering/projects?sort=-createdAt +``` + +Only explicitly supported fields may be sorted. + +--- + +## 63. Search + +Start with PostgreSQL search. + +Engineering search may cover: + +```text +project number +project name +client name +``` + +Legal: + +```text +matter number +client +case number +``` + +Healthcare: + +```text +patient number +patient identity +``` + +Healthcare search requires stricter privacy and authorization controls. + +Potential PostgreSQL capabilities: + +- B-tree indexes for exact/filter queries +- PostgreSQL full-text search where appropriate +- `pg_trgm` only when fuzzy search requirements justify it + +Do not introduce Elasticsearch/OpenSearch until real query volume, relevance requirements, or indexing features justify another distributed system. + +Do not create every conceivable search index on day one. Indexes cost memory, storage, and write performance. + +## 64. Optimistic Concurrency + +Important mutable resources should use a version field. + +Example: + +```json +{ + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c5d", + "version": 6 +} +``` + +Update: + +```json +{ + "version": 6, + "name": "Central Tower Phase II" +} +``` + +If the current database version differs: + +```text +409 CONCURRENT_MODIFICATION +``` + +--- + +## 65. Domain-Oriented REST + +Important state transitions use explicit command endpoints. + +Good: + +```http +POST /engineering/projects/{id}/close +POST /engineering/designs/{id}/approve +POST /engineering/tasks/{id}/complete +POST /engineering/inspections/{id}/complete +POST /invoices/{id}/issue +``` + +Avoid: + +```http +PATCH /resource/{id} +{ + "status": "approved" +} +``` + +when the change has significant rules or side effects. + +--- + +## 66. Transaction Boundaries + +Create project: + +```text +BEGIN + +create project +assign project manager +write audit event +write outbox event + +COMMIT +``` + +Approve design: + +```text +BEGIN + +validate permission +validate project access +validate credentials +validate design state +create review result +mark approved +write audit event +write outbox event + +COMMIT +``` + +--- + +## 67. Request Context + +Every authenticated request should resolve: + +```text +RequestContext +{ + requestId + userId + sessionId + organizationId + membershipId + permissions +} +``` + +Profession modules consume this context. + +--- + +## 68. Request IDs + +Every request has: + +```http +X-Request-Id +``` + +If missing, the server generates one. + +Use it in: + +- logs +- audit context +- error diagnostics +- asynchronous correlation + +--- + +## 69. OpenAPI + +Maintain: + +```text +openapi.yaml +``` + +Use OpenAPI 3.1. + +Production server example: + +```yaml +servers: + - url: https://api.example.com/api/v1 +``` + +The server URL and path definitions must remain consistent with the platform base path. + +OpenAPI defines: + +- routes +- request DTOs +- response DTOs +- security schemes +- organization header +- request IDs +- idempotency header +- pagination +- filters +- error schemas +- examples +- profession tags + +Security scheme: + +```yaml +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +``` + +Reusable headers/parameters: + +```text +X-Organization-Id +X-Request-Id +Idempotency-Key +limit +cursor +``` + +CI must validate the OpenAPI document. + +Contract tests should detect drift between implementation and specification. + +Generated clients may be used by the separate frontends, but generated transport code should not dictate frontend domain architecture. + +## 70. DTO Rule + +Database models are not public API contracts. + +Use: + +```text +Request DTO +Response DTO +``` + +A database migration should not accidentally change the public API. + +--- + +## 71. Backend Module Structure + +Recommended: + +```text +src/ +├── core/ +│ ├── auth/ +│ ├── organizations/ +│ ├── memberships/ +│ ├── authorization/ +│ ├── documents/ +│ ├── billing/ +│ ├── audit/ +│ └── events/ +│ +├── engineering/ +│ ├── clients/ +│ ├── projects/ +│ ├── project-members/ +│ ├── phases/ +│ ├── sites/ +│ ├── tasks/ +│ ├── designs/ +│ ├── inspections/ +│ └── specifications/ +│ +├── legal/ +│ ├── clients/ +│ ├── matters/ +│ ├── cases/ +│ ├── hearings/ +│ ├── conflicts/ +│ └── retainers/ +│ +└── healthcare/ + ├── patients/ + ├── practitioners/ + ├── appointments/ + ├── encounters/ + ├── records/ + └── prescriptions/ +``` + +--- + +## 72. Internal Module Structure + +Example: + +```text +projects/ +├── domain/ +│ ├── project.entity.ts +│ ├── project-status.ts +│ └── project.errors.ts +│ +├── application/ +│ ├── commands/ +│ │ ├── create-project.ts +│ │ ├── update-project.ts +│ │ └── close-project.ts +│ │ +│ └── queries/ +│ ├── get-project.ts +│ └── list-projects.ts +│ +├── infrastructure/ +│ └── project.repository.ts +│ +└── api/ + ├── project.controller.ts + ├── project.request.ts + └── project.response.ts +``` + +--- + +## 73. Controllers + +Controllers should handle: + +```text +HTTP +authentication context +input DTO parsing +application command/query invocation +response mapping +``` + +Controllers should not contain: + +```text +business rules +raw SQL +role logic +transaction orchestration +email sending +audit implementation +``` + +--- + +## 74. Commands and Queries + +Mutations use commands. + +Examples: + +```text +CreateEngineeringProjectCommand +ApproveEngineeringDesignCommand +CloseLegalMatterCommand +CompleteHealthcareEncounterCommand +``` + +Reads use queries. + +Examples: + +```text +GetEngineeringProjectQuery +ListLegalMattersQuery +GetHealthcarePatientQuery +``` + +--- + +## 75. Repositories + +Use domain-specific repositories. + +Examples: + +```text +EngineeringProjectRepository +LegalMatterRepository +HealthcarePatientRepository +``` + +Avoid one massive generic repository abstraction that eventually needs dozens of flags. + +--- + +## 76. Security Baseline + +Minimum controls: + +```text +TLS everywhere +strong password hashing +short-lived access tokens +refresh-token rotation/reuse detection +server-side session revocation + +rate limiting +anti-automation controls + +RBAC +resource policies +credential-aware authorization +tenant isolation + +input validation +SQL injection protection + +signed object-storage URLs +file-content validation +malware scanning + +audit trails +secret management +encryption at rest + +dependency/image scanning + +request/correlation IDs +backup and restore testing +``` + +### Web Security / CORS + +ADR-009 defines environment-specific web security. + +Baseline requirements: + +```text +explicit CORS allowlist +no wildcard credentialed CORS +allowed methods/headers documented +preflight behavior tested +HSTS at the edge for production HTTPS +X-Content-Type-Options: nosniff +secure cookie attributes when cookies are used +CSP on browser frontends +frame-ancestor/clickjacking policy on frontends +referrer policy appropriate to the frontend +``` + +Security headers belong at the appropriate application/CDN/gateway layer. + +### Rate Limiting + +Policies are endpoint-specific and configurable. + +Return: + +```http +429 Too Many Requests +Retry-After: ... +``` + +### Secrets + +Production secrets live outside source control, preferably in managed secret/key systems. + +JWT signing keys support rotation. + +## 77. Data Classification + +Suggested classes: + +### Public + +```text +marketing configuration +``` + +### Internal + +```text +organization settings +tasks +``` + +### Confidential + +```text +engineering documents +legal matters +billing +``` + +### Highly Sensitive + +```text +clinical records +professional credentials +authentication secrets +``` + +--- + +## 78. Healthcare Security + +Before healthcare production use, define: + +```text +privacy model +minimum-necessary access model +clinical access policies +break-glass/emergency access policy if required +audit policy +record-signing policy +amendment policy +retention policy +credential policy +scope-of-practice policy +jurisdiction requirements +encryption strategy +consent requirements +data residency requirements +backup/restore handling +export/portability requirements +breach-response requirements +``` + +Healthcare is a stricter security tier. + +Key rules: + +1. default patient responses do not contain all available PHI +2. clinical record reads may be auditable events +3. signed records are immutable except through explicit amendment/version workflows +4. prescribing authorization is jurisdiction-specific +5. privileged clinical commands revalidate professional authority +6. caches must not allow revoked credentials to remain effective for high-risk writes +7. healthcare search results themselves are protected data +8. access logs may require dedicated permissions +9. do not claim regulatory compliance from architecture alone + +## 79. Observability + +Use: + +```text +structured logs +metrics +distributed tracing +request IDs +correlation IDs +``` + +Recommended: + +```text +OpenTelemetry +``` + +### Core Metrics + +API: + +```text +api_requests_total +api_errors_total +api_request_duration_seconds +``` + +Authentication: + +```text +auth_login_attempts_total +auth_token_refresh_total +auth_refresh_reuse_detections_total +auth_sessions_revoked_total +``` + +Authorization/security: + +```text +cross_tenant_access_attempts_total +tenant_isolation_invariant_failures_total +authorization_denials_total +credential_policy_denials_total +rate_limit_events_total +``` + +Important distinction: + +```text +cross_tenant_access_attempt += +request attempted another tenant's resource +``` + +This may be a stale link, mistake, or attack. + +```text +tenant_isolation_invariant_failure += +our system nearly or actually created/returned cross-tenant data +``` + +That is a high-severity internal correctness/security incident. + +Outbox/jobs/webhooks: + +```text +outbox_events_pending +outbox_events_failed_total +outbox_processing_duration_seconds + +jobs_queued +jobs_failed_total +job_duration_seconds + +webhook_delivery_attempts_total +webhook_delivery_failures_total +webhook_delivery_latency_seconds +``` + +Database: + +```text +db_pool_active +db_pool_waiting +db_query_duration_seconds +db_transaction_duration_seconds +``` + +Business metrics may include: + +```text +engineering_projects_created_total +engineering_designs_approved_total +engineering_inspections_completed_total +invoices_issued_total +``` + +Avoid patient-specific or sensitive identifiers in metric labels. + +### Alerts + +Examples: + +```text +refresh token reuse detected +tenant isolation invariant failure +outbox backlog exceeds SLO +webhook failure spike +database pool saturation +error-rate spike +latency regression +backup failure +malware scanner unavailable +``` + +Thresholds are calibrated from real environments rather than copied from a review document. + +### SLOs + +Define by endpoint class. + +Interactive CRUD, reports, file orchestration, and background jobs should not share one arbitrary latency target. + +## 80. Logging + +Useful fields: + +```text +request_id +route +method +status +duration +user_id when appropriate +organization_id when appropriate +``` + +Never log: + +```text +passwords +tokens +clinical record text +full sensitive documents +payment secrets +``` + +--- + +## 81. Testing Strategy + +### Unit Tests + +Test: + +```text +domain rules +state transitions +authorization policies +credential policies +money calculations +idempotency request hashing +``` + +### Property-Based Tests + +Use property-based testing for high-value domain state machines. + +Candidates: + +```text +engineering design lifecycle +engineering inspection lifecycle +invoice lifecycle +payment state transitions +membership/role invariants +``` + +Correct properties: + +```text +every successful transition ends in a valid state + +every forbidden transition is rejected + +terminal states reject prohibited actions + +required invariants survive every valid transition + +transition sequences never bypass required approval/credential rules +``` + +Do not assert that every random state/action pair succeeds. Many are supposed to fail. + +### Integration Tests + +Test: + +```text +repositories +tenant-aware foreign keys +PostgreSQL constraints +transactions +outbox persistence +idempotency persistence +cache invalidation +job persistence +webhook delivery persistence +``` + +### API Tests + +Every important endpoint covers: + +```text +happy path +request validation +authentication +organization context +permission denial +scope denial +credential denial where relevant +cross-tenant access +concurrent modification +invalid state transition +idempotent replay +idempotency conflict +audit creation +outbox creation +``` + +### Outbox Reliability / Chaos Tests + +Test: + +```text +worker crash before side effect +worker crash after side effect but before marking processed +two workers competing for same row +temporary dependency outage +retry/backoff behavior +dead-letter behavior +consumer idempotency +lost worker wake-up +replay +``` + +The dangerous scenario is: + +```text +external side effect succeeds +worker dies +event retries +``` + +Tests must prove the consumer does not create an unacceptable duplicate. + +### Tenant Security Tests + +Test both: + +```text +external cross-tenant access attempts +``` + +and: + +```text +internal cross-tenant data invariant failures +``` + +These are different classes of failure. + +### Performance Tests + +Create realistic profiles: + +```text +interactive reads +interactive writes +search +dashboard read models +reporting +file upload orchestration +outbox processing +webhook bursts +notification bursts +``` + +Measure: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +``` + +Set production SLO gates only after a realistic baseline exists. + +### Coverage + +Track code coverage. + +Do not treat a single percentage such as `90%` as proof of quality. + +Critical-path expectations are stronger: + +```text +all tenant-isolation paths tested +all financial commands tested +all regulated commands tested +all state transitions tested +all critical authorization policies tested +``` + +## 82. Tenant Security Tests + +For every major resource, attempt: + +```text +Organization A resource +using Organization B context +``` + +Test: + +```text +read +update +delete/action +list filtering +search +documents +``` + +Expected result: + +```text +404 / denied +``` + +--- + +## 83. Engineering MVP + +Engineering is the first vertical. + +Initial features: + +```text +Authentication +Organization management +Users / memberships / roles +Engineering clients +Projects +Project members +Project phases +Tasks +Sites +Documents +Basic design records +Inspections +Time entries +Basic billing +Audit history +``` + +Do not initially build: + +```text +advanced CAD integration +BIM integration +full document markup +advanced resource planning +procurement +complex accounting +AI design analysis +IoT integrations +``` + +--- + +## 84. Engineering MVP Workflow + +```text +User registers + ↓ +Creates engineering organization + ↓ +Invites engineer + ↓ +Assigns role + ↓ +Creates client + ↓ +Creates project + ↓ +Assigns project team + ↓ +Creates project phases + ↓ +Creates tasks + ↓ +Uploads documents + ↓ +Creates design + ↓ +Reviews / approves design + ↓ +Schedules inspection + ↓ +Records inspection findings + ↓ +Records engineering time + ↓ +Creates invoice + ↓ +Records payment + ↓ +Closes project + ↓ +Audit history contains lifecycle +``` + +--- + +## 85. Development Phases + +### Phase 0: Architecture Foundation + +Deliver: + +```text +domain boundaries +database conventions +REST conventions +authorization model +session/token model +idempotency strategy +error taxonomy +OpenAPI skeleton +engineering state machines +migration conventions +threat model +initial ADRs +risk register +``` + +### Phase 1: Shared Platform Core + +Build: + +```text +auth +sessions +refresh-token families +token rotation/revocation + +users +organizations +organization professions + +membership invitations +memberships +roles +permissions +authorization + +audit +outbox + +request context +idempotency +rate limiting +observability +``` + +### Phase 2: Engineering CRM + +Build: + +```text +engineering clients +engineering client contacts +client archive/restore +``` + +### Phase 3: Engineering Projects + +Build: + +```text +projects +project members +project phases +activation/close/archive +``` + +### Phase 4: Work and Site Management + +Build: + +```text +tasks +task batch operations +sites +``` + +### Phase 5: Documents + +Build: + +```text +documents +versions +categories +classification +retention references +signed uploads +multipart uploads +content verification +malware scanning +engineering document links +``` + +### Phase 6: Engineering Designs + +Build: + +```text +designs +assignments +versions +reviews +cancel/withdraw semantics +credential-aware approval +audit +outbox +idempotency +``` + +### Phase 7: Engineering Inspections + +Build: + +```text +inspection lifecycle +inspection outcome +findings +corrective work +follow-up inspections +attachments +audit +outbox +idempotency +``` + +### Phase 8: Time, Budgets, and Billing + +Build: + +```text +time entries +batch timesheet submission +project budgets when required +invoices +payments +financial idempotency +reconciliation +``` + +### Phase 9: Notifications, Jobs, and Webhooks + +Build: + +```text +notifications +email +async jobs +imports/exports +webhooks +delivery/retry +dead-letter handling +``` + +### Phase 10: Reporting and Search + +Build: + +```text +project status +overdue work +inspection status +billable time +revenue +outstanding invoices +dashboard read models +``` + +### Phase 11: Engineering Client Portal + +Build: + +```text +portal account invitations +external project grants +published project documents +client review/acceptance workflow +portal audit +portal-specific frontend +``` + +Do not expose professional approval actions to client portal accounts. + +### Phase 12: Legal Vertical + +Validate shared core against: + +```text +matters +cases +conflicts +deadlines +retainers +restricted access / ethical walls +``` + +### Phase 13: Healthcare Readiness and Vertical + +Before implementation: + +```text +healthcare threat model +privacy review +jurisdiction analysis +scope-of-practice policy +record signing/amendment model +retention model +audit requirements +``` + +### Estimation Rule + +These are dependency-ordered milestones. + +They are not calendar promises. + +Calendar estimates require: + +```text +team size +frontend/UX scope +cloud decisions +third-party providers +security requirements +QA capacity +domain-expert availability +``` + +## 86. Legal Expansion + +Only after engineering proves the shared platform assumptions. + +Build: + +```text +Legal Client + ↓ +Matter + ↓ +Case + ↓ +Hearings / Deadlines / Documents +``` + +Do not redesign engineering around legal terminology. + +Extract only genuinely reusable infrastructure. + +--- + +## 87. Healthcare Expansion + +Healthcare comes after: + +- core platform is stable +- audit model is proven +- permission model is proven +- tenant isolation is tested +- retention and encryption strategies are defined + +Healthcare should be treated as its own security and compliance workstream. + +--- + +## 88. Deployment Environments + +Use: + +```text +development +testing +staging +production +``` + +Each environment has independent: + +```text +database +object storage +secrets +queues +API keys +``` + +--- + +## 89. Initial Deployment Architecture + +```text +CDN + │ + ├── Engineering Web + ├── Legal Web + └── Healthcare Web + +Load Balancer + │ + Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Queue + │ + Workers +``` + +Prefer managed infrastructure where practical. + +--- + +## 90. Backup Strategy + +Database: + +```text +automated backups +point-in-time recovery +tested restores +``` + +Object storage: + +```text +versioning +retention policies +backup or replication where required +``` + +A backup strategy is incomplete until restoration is tested. + +--- + +## 91. Migration Strategy + +Use explicit immutable migration files. + +Recommended naming: + +```text +YYYYMMDDHHMMSS_description.sql +``` + +Example: + +```text +20260826010000_create_organizations.sql +20260826011000_create_users.sql +20260826012000_create_memberships.sql +20260826013000_create_rbac.sql +20260826014000_create_audit_outbox.sql +20260826015000_create_engineering_clients.sql +``` + +### UUID Standard + +The platform uses UUIDv7. + +Supported implementation choices: + +```text +PostgreSQL 18+: + use native uuidv7() if database-generated identifiers are desired + +Earlier PostgreSQL: + generate UUIDv7 in the application or use a controlled extension +``` + +Database columns remain PostgreSQL `UUID`. + +The rule is consistency, not ideological loyalty to one generation layer. + +Do not silently fall back to UUIDv4 while documenting UUIDv7. + +### Production Migration Rules + +Use expand/contract: + +```text +1. add backward-compatible schema +2. deploy code supporting old + new schema +3. backfill/migrate +4. switch reads/writes +5. observe +6. remove obsolete schema later +``` + +For destructive changes: + +```text +backup/restore plan +compatibility window +production-like dry run +explicit approval +post-migration verification +``` + +Do not assume a destructive database migration can always be reversed by a simple down migration. + +Never use automatic ORM schema synchronization in production. + +## 91A. Architecture Decision Records + +v4 stops treating technology suggestions as automatically settled architecture. + +Create ADRs before implementation locks in: + +```text +ADR-001 Backend Framework +ADR-002 SQL / ORM / Query Layer +ADR-003 Queue Implementation +ADR-004 PostgreSQL Minimum Version +ADR-005 Error Format / RFC 9457 Compatibility +ADR-006 Rate-Limit Header Convention +ADR-007 Webhook Signing Strategy +ADR-008 Object Storage Provider / Multipart Strategy +ADR-009 Web Security / CORS / Browser Headers +ADR-010 Machine Authentication / API Key Policy +``` + +Each ADR should include: + +```text +context +decision +alternatives considered +tradeoffs +security impact +operational impact +migration/exit path +date +status +``` + +The architecture currently fixes capabilities and boundaries. + +It does not require a framework merely because a review document described it positively. + +--- + +## 92. Technology Recommendation + +The following are preferred candidates, not all final decisions. + +### Fixed Platform Choices + +```text +API style: REST +Contract: OpenAPI 3.1 +Primary language: TypeScript +Primary database: PostgreSQL +Architecture: Modular Monolith +Observability standard: OpenTelemetry +Object storage model: S3-compatible +Container model: Docker/OCI +``` + +### ADR-Gated Choices + +Backend framework candidates: + +```text +NestJS +Fastify-centered custom application structure +``` + +SQL / persistence candidates: + +```text +Drizzle +Kysely +Prisma +direct SQL for specialized queries +``` + +Queue candidates: + +```text +BullMQ / Redis +managed cloud queue +``` + +PostgreSQL baseline: + +```text +PostgreSQL 18+ +``` + +is attractive because of native UUIDv7 and current capabilities, but the minimum supported version must be confirmed against: + +```text +hosting provider availability +operations policy +extension requirements +upgrade policy +support lifecycle +``` + +Do not claim one ORM is categorically "faster" or "better" without workload-specific evidence. + +The selected stack should preserve: + +```text +transaction control +explicit SQL visibility +tenant-safe query design +migration control +observability +testability +``` + +## 93. REST API Milestones + +### Milestone 1: Platform Access and Security + +```http +POST /auth/register +POST /auth/login + +POST /auth/token/refresh +POST /auth/token/revoke +POST /auth/token/revoke-all + +GET /auth/sessions +DELETE /auth/sessions/{sessionId} + +GET /me + +POST /organizations +GET /me/organizations + +POST /membership-invitations +GET /memberships + +GET /roles +POST /roles +GET /permissions +``` + +Includes: + +```text +explicit organization context +session revocation +refresh-token reuse detection +audit foundation +outbox foundation +idempotency foundation +rate limiting +``` + +### Milestone 2: Engineering Clients + +```http +GET /engineering/clients +POST /engineering/clients +GET /engineering/clients/{id} +PATCH /engineering/clients/{id} +POST /engineering/clients/{id}/archive +POST /engineering/clients/{id}/restore +GET /engineering/clients/{id}/projects +``` + +### Milestone 3: Engineering Projects + +```http +GET /engineering/projects +POST /engineering/projects +GET /engineering/projects/{id} +PATCH /engineering/projects/{id} + +POST /engineering/projects/{id}/activate +POST /engineering/projects/{id}/close +POST /engineering/projects/{id}/archive + +GET /engineering/projects/{id}/summary +``` + +Timeline and budget read models follow when the frontend requires them. + +### Milestone 4: Collaboration + +```http +POST /engineering/projects/{id}/members +GET /engineering/projects/{id}/members + +POST /engineering/tasks +GET /engineering/tasks +POST /engineering/tasks/{id}/complete +``` + +### Milestone 5: Sites and Documents + +Build: + +```text +engineering sites +signed file uploads +document versions +malware scanning +project document links +``` + +### Milestone 6: Designs + +Build: + +```text +design lifecycle +versions +reviews +submit-review +request-changes +approve +reject +supersede +credential validation +audit + outbox + idempotency +``` + +### Milestone 7: Inspections + +Build: + +```text +schedule +start +complete +cancel +findings +finding resolution +audit + outbox + idempotency +``` + +### Milestone 8: Commercial Workflows + +Build: + +```text +time entries +invoices +payments +refunds +financial idempotency +reports +``` + +## 94. Architecture Rules to Freeze + +1. REST is the primary frontend and integration API. +2. Base path is `/api/v1`. +3. OpenAPI 3.1 is the public API contract. +4. GraphQL is not part of v1. +5. Start as one modular monolith backend. +6. Each profession has its own frontend. +7. Each profession owns its domain tables and state machines. +8. Shared modules provide infrastructure, not forced domain abstractions. +9. Public serialized IDs are raw UUIDv7. +10. Database ID columns use PostgreSQL UUID. +11. Human-readable business references are separate from resource IDs. +12. Every tenant-owned row carries direct `organization_id`. +13. Tenant-scoped requests require explicit `X-Organization-Id`. +14. Tenant boundaries are enforced in queries and database constraints. +15. Cross-tenant resources appear nonexistent. +16. API JSON/query parameter names use camelCase; DB identifiers use snake_case. +17. Authorization is server-side and deny-by-default. +18. `assigned` scope is defined per resource policy, never inferred generically. +19. Roles and professional credentials are separate. +20. A professional profile may own multiple credentials. +21. Sessions and refresh tokens are separate resources. +22. Refresh tokens rotate within families and support reuse detection. +23. Machine identities use service accounts/API keys, not fake human memberships. +24. Important domain transitions use explicit REST command endpoints. +25. High-risk commands use durable idempotency. +26. Batch custom actions use `/{collection}/batch/{action}`. +27. Every batch defines atomic or partial semantics. +28. Every batch item receives independent authorization/domain validation. +29. Large batches become asynchronous jobs. +30. Project phases are authoritative; duplicated project `stage` is not stored. +31. Project budgets use the dedicated budget model; project `budget_minor` is not authoritative. +32. Engineering time entries may attribute time to one explicit primary work item using tenant-safe FKs. +33. Design versions and design-version document links have explicit one-to-many cardinality. +34. All design/review/version/finding/follow-up subresources carry `organization_id`. +35. Inspection lifecycle and inspection outcome are separate. +36. Inspection follow-ups are explicit resources. +37. Engineering change requests remain deferred until fully specified. +38. Shared documents own document records; profession modules own link tables. +39. Legal does not duplicate shared document ownership. +40. Large files use object-storage multipart uploads. +41. Application servers do not proxy multi-gigabyte chunks. +42. Document checksums belong to document versions. +43. Document classification is multi-level. +44. Document retention is explicit policy. +45. Document category uniqueness must work for nullable profession values on the selected PostgreSQL version. +46. Project document linkage does not imply client-portal publication. +47. External publication requires explicit publication records. +48. Client portal accounts are not internal memberships. +49. Client acceptance is not professional engineering approval. +50. Domain events use a transactional outbox. +51. Outbox delivery is at-least-once. +52. Outbox events carry correlation/causation identifiers. +53. External side-effect consumers are idempotent. +54. Webhook subscriptions and deliveries are tenant-owned. +55. HMAC signing secrets are securely recoverable/encrypted, not only hashed. +56. Jobs are tenant-scoped by the standard organization header. +57. PostgreSQL is the authoritative transactional datastore. +58. Redis is acceleration/coordination, not critical source of truth. +59. Search starts with PostgreSQL. +60. Collections use cursor pagination, default 25 and max 100. +61. Important mutable resources use optimistic concurrency. +62. Database entities are not serialized directly. +63. Errors use stable codes. +64. `429` responses use `Retry-After`; exact quota headers are an API decision. +65. Business records use explicit archive/revoke/unlink/hard-delete lifecycle policies. +66. Financial/professional/audit records are not casually hard-deleted. +67. Audit metadata is minimized and supports governed privacy transformation when required. +68. Important/regulated actions are audited. +69. Signed clinical records use sign/amend/version workflows. +70. Prescribing authority remains jurisdiction/scope-of-practice policy. +71. Production migrations use expand/contract. +72. Destructive changes are not assumed trivially reversible. +73. Secrets remain outside source control. +74. CORS and browser security policy are explicit ADR/configuration. +75. Rate limits are calibrated by evidence. +76. CI validates types, tests, OpenAPI, migrations, and security checks. +77. Property-based tests cover high-value state machines. +78. Outbox/job/webhook reliability is tested under failure/concurrency. +79. Critical-path tests matter more than vanity coverage percentages. +80. Framework/ORM/queue/PostgreSQL-minimum choices require ADRs. +81. Engineering is the first vertical. +82. Client portal follows internal Engineering MVP foundations. +83. Legal follows after Engineering validates shared assumptions. +84. Healthcare requires dedicated privacy/security/domain design before implementation. +85. Architecture documentation never equates "designed for" with "certified/compliant". + +## 95. Required Design Artifacts + +Maintain: + +```text +01_PROJECT_ARCHITECTURE.md +02_DATABASE_CONVENTIONS.md +03_AUTHORIZATION_MODEL.md +04_AUTH_SESSION_MODEL.md + +05_ENGINEERING_DOMAIN.md +06_ENGINEERING_DATABASE_SCHEMA.md +07_ENGINEERING_STATE_MACHINES.md + +08_API_CONVENTIONS.md +09_ENGINEERING_API_SPEC.md +10_OPENAPI.yaml + +11_FRONTEND_ARCHITECTURE.md +12_CLIENT_PORTAL_SECURITY_MODEL.md + +13_DOCUMENT_SECURITY_MODEL.md +14_LARGE_FILE_UPLOAD_MODEL.md + +15_WEBHOOK_INTEGRATION_MODEL.md +16_ASYNC_JOB_MODEL.md + +17_SECURITY_MODEL.md +18_DEPLOYMENT_ARCHITECTURE.md +19_OBSERVABILITY_MODEL.md +20_TESTING_STRATEGY.md + +21_ARCHITECTURE_DECISION_RECORDS/ +22_RISK_REGISTER.md +23_MVP_BACKLOG.md +``` + +Important ADRs: + +```text +backend framework +persistence/query layer +queue implementation +PostgreSQL minimum version +error format +rate-limit headers +webhook signing +object-storage provider +``` + +## 96. Recommended Implementation Order + +```text +Foundation + ↓ +Authentication + ↓ +Organizations + ↓ +Memberships + ↓ +RBAC + ↓ +Engineering Clients + ↓ +Engineering Projects + ↓ +Project Team + ↓ +Tasks + ↓ +Sites + ↓ +Documents + ↓ +Designs + ↓ +Inspections + ↓ +Time Tracking + ↓ +Billing + ↓ +Notifications + ↓ +Reports + ↓ +Legal Vertical + ↓ +Healthcare Vertical +``` + +--- + +## 97A. Database Indexing Strategy + +All tenant-owned tables need efficient tenant scoping. + +Baseline: + +```text +(organization_id, id) +``` + +Common list access often benefits from: + +```text +(organization_id, created_at) +``` + +Query-specific examples: + +```text +(organization_id, status) +(organization_id, client_id) +(organization_id, project_id) +(organization_id, assigned_to_user_id) +``` + +### Rules + +1. every index corresponds to a known query, ordering, or constraint +2. column order follows real predicates +3. validate with `EXPLAIN (ANALYZE, BUFFERS)` +4. include production-like cardinality in testing +5. measure write amplification +6. do not index every field +7. introduce trigram/full-text indexes only for actual search requirements + +Potential later tools: + +```text +covering indexes +materialized views +read replicas +table partitioning +external search +``` + +These are evidence-driven scaling mechanisms, not baseline dependencies. + +### Document Category Uniqueness + +If a nullable field such as profession participates in uniqueness: + +```text +organization_id +profession nullable +name +``` + +do not assume plain uniqueness treats NULL as one shared value. + +Use PostgreSQL-supported null-aware uniqueness or partial unique indexes according to the selected PostgreSQL version. + +--- + +## 97B.## 97B. CI/CD and Deployment Gates + +Pipeline stages: + +```text +lint/typecheck + ↓ +unit tests + ↓ +integration tests + ↓ +OpenAPI validation + contract tests + ↓ +security/dependency scan + ↓ +container build + image scan + ↓ +migration compatibility check + ↓ +deploy development + ↓ +smoke tests + ↓ +deploy staging + ↓ +E2E + performance/security baseline + ↓ +manual production approval + ↓ +production deployment + ↓ +post-deploy verification +``` + +Production deployment should support: + +```text +rolling or blue/green application deployment +backward-compatible database migrations +health checks +fast application rollback +feature flags for incomplete features +observability gates +``` + +Database schema rollback is not treated as equivalent to application rollback. + + +### Feature Flags + +Feature flags used for deployment safety are operational configuration, not automatically a business database table. + +Initial implementation may use: + +```text +environment/config-service flags +``` + +for global rollout and kill switches. + +If per-organization feature rollout is later required, introduce an explicit tenant-owned model such as: + +```text +organization_feature_flags +``` + +through an ADR/migration. + +Do not overload `organization_professions` with unrelated product experiments. + + +### Configuration and Secrets + +Non-secret configuration may use environment variables. + +Secrets should use a managed secret store where possible: + +```text +database credentials +Redis credentials +JWT/private signing keys +object storage credentials +SMTP/API provider credentials +monitoring credentials +``` + +Do not publish real secrets in sample configuration. + +Organization profession enablement remains primarily data-driven through `organization_professions`. + +Global feature flags may be used for staged rollout, kill switches, or incomplete features. + +--- + +## 97C. Review-Driven Deferred Decisions + +The following ideas are valid possibilities but are explicitly **not frozen into v1**: + +```text +read replicas +materialized views +Elasticsearch/OpenSearch +universal 100 MB file limit +fixed 100 req/min user limit +fixed 1000 req/hour organization limit +specific cache-hit-ratio target +specific p95 latency promise +database-per-tenant +microservices +GraphQL +``` + +These require evidence from: + +```text +load tests +security analysis +customer requirements +compliance requirements +real production workloads +``` + +This prevents benchmark-shaped guesses from becoming architecture law. + +--- + +## 97D. Provisional Performance Objectives + +Performance numbers in architecture are starting hypotheses, not guarantees. + +Initial engineering objectives may begin with: + +```text +Interactive read: + target p95 <= 500 ms + +Interactive mutation: + target p95 <= 750 ms + +Simple list/search: + target p95 <= 800 ms + +Upload authorization: + target p95 <= 300 ms + +Background outbox pickup: + target <= 5 seconds under normal operating conditions +``` + +These are revised after realistic testing. + +Track: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +outbox lag +``` + +Different endpoint classes receive different SLOs. + +Do not use file-transfer completion time as an API SLO when bytes travel directly between client and object storage. + +--- + +## 97E. Risk Register + +Maintain a living risk register. + +Suggested structure: + +| Risk | Impact | Mitigation | Owner | Phase | Status | +|---|---|---|---|---|---| +| Cross-tenant data exposure | Critical | Tenant-aware FKs, scoped queries, security tests | Backend/Security | P0 | Open | +| Non-idempotent outbox side effect | Critical | Consumer dedupe, provider idempotency, chaos tests | Backend | P0 | Open | +| Migration failure | High | Expand/contract, dry runs, backups | Backend/Platform | P0 | Open | +| Engineering workflow mismatch | High | Domain expert validation | Product/Engineering SME | MVP | Open | +| Portal authorization leak | Critical | Separate external access model, publication grants | Backend/Security | Portal | Open | +| Webhook delivery instability | Medium | Retry, dead-letter, replay, metrics | Backend | Integrations | Open | +| Large upload abandonment | Medium | Multipart expiry and cleanup | Backend/Platform | Documents | Open | +| Documentation drift | Medium | OpenAPI validation, ADRs, CI | Engineering | Continuous | Open | + +Do not pretend likelihood labels are quantitative unless the team defines and uses a scoring method. + +--- + +## 97F. Architecture Change Governance + +v4 is the last broad platform-architecture revision before Engineering MVP implementation. + +New discoveries should normally become: + +```text +ADR +OpenAPI change +database migration +domain-state-machine update +security decision +backlog item +runbook +``` + +rather than a new full architecture rewrite. + +Reopen the broad architecture only when a discovery invalidates one of these foundational assumptions: + +```text +tenant model +profession separation +shared-core boundary +REST API model +data ownership +security trust boundary +deployment topology +database architecture +``` + +This prevents design review from becoming an infinite recursion problem. + +--- + +## 97G. Production Readiness Gates + +Architecture being coherent does not mean production is safe. + +Before production, require evidence in these categories. + +### Security + +```text +TLS configured +password hashing configured +refresh rotation/reuse detection tested +session revocation tested +tenant isolation tests passing +authorization/credential policies tested +rate limiting active +secrets managed outside source control +file security scanning active +security review completed +``` + +### Reliability + +```text +database backups automated +restore tested +object storage recovery strategy tested +outbox monitoring active +job queue monitoring active +webhook retry/dead-letter behavior tested +health checks configured +dependency failures tested +``` + +### Data Integrity + +```text +tenant-aware foreign keys present where required +financial invariants tested +migration tested on production-like data +idempotency tested for high-risk commands +optimistic concurrency tested +audit integrity tested +``` + +### Contract / API + +```text +OpenAPI validates +contract tests pass +error schema consistent +versioning rules documented +client SDK generation validated if used +``` + +### Performance + +```text +load test executed +realistic SLOs defined +database pool configured +key queries analyzed +outbox/job backlogs remain within SLO +``` + +### Critical Domain Coverage + +Rather than a magic overall coverage number, require explicit test coverage for: + +```text +tenant boundaries +design approval +inspection completion +invoice issue +payment/refund +membership privilege changes +clinical record signing/amendment when healthcare exists +prescribing authorization when healthcare exists +``` + +### Release Gate Principle + +No single metric such as: + +```text +90% test coverage +``` + +is sufficient evidence of production readiness. + +Quality gates are based on critical behavior, not vanity percentages. + +--- + +## 97. Final Design Position + +The platform is: + +```text +One Shared Platform + │ + ├── Shared Identity / Sessions + ├── Shared Security / Authorization + ├── Shared Documents / Multipart Uploads + ├── Shared Financial Core + ├── Shared Audit / Outbox + ├── Shared Jobs / Webhooks / Notifications + │ + ├── Engineering Internal Product + │ ├── Engineering Frontend + │ ├── Engineering REST APIs + │ ├── Engineering State Machines + │ └── Engineering Tables + │ + ├── Engineering Client Portal + │ ├── External Portal Frontend + │ ├── Portal Accounts + │ ├── Project Grants + │ ├── Published Documents + │ └── Client Review / Acceptance + │ + ├── Legal Product + │ ├── Legal Frontend + │ ├── Legal REST APIs + │ └── Legal Tables + │ + └── Healthcare Product + ├── Healthcare Frontend + ├── Healthcare REST APIs + ├── Healthcare Security Policies + └── Healthcare Tables +``` + +The system shares infrastructure where reuse is valuable while preserving profession-specific domain semantics and trust boundaries. + +v4 is the final broad architecture baseline for Engineering MVP implementation. + +From this point forward, architecture detail should primarily move into: + +```text +ADRs +OpenAPI +database schema/migrations +state-machine specifications +security policies +implementation backlog +runbooks +``` + +rather than repeatedly rewriting the entire architecture plan. + +This document does not itself prove: + +```text +regulatory compliance +production certification +security certification +performance at a specific scale +``` + +Those require implementation evidence, security review, domain validation, operational testing, restore testing, and measured production-like workloads. + + + + +--- + +# v4.1 Changelog + +v4.1 resolves implementation-contract issues without changing the core architecture. + +```text +✓ raw UUIDv7 API ID contract +✓ camelCase API / snake_case database naming convention +✓ direct organization_id on tenant subresources +✓ invitation role assignments +✓ service accounts and hashed API keys +✓ multiple professional credentials per profile +✓ global archive/revoke/unlink/hard-delete policy +✓ project stage duplication removed +✓ project budget_minor removed +✓ project phase reorder command +✓ global engineering site listing +✓ task status and priority vocabularies +✓ design revise endpoint +✓ design-version many-document cardinality +✓ design review/version tenant keys +✓ inspection finding tenant keys +✓ explicit inspection follow-up table +✓ change requests deferred until fully specified +✓ time-entry work-item attribution +✓ legal_documents duplication removed +✓ healthcare placeholder schemas clarified +✓ invoice-item schema defined +✓ document retention-policy schema +✓ version-independent document-category uniqueness fallback +✓ standard upload DTO +✓ multipart-init/parts/complete DTOs +✓ webhook subscription schema +✓ logout endpoint +✓ assigned-scope resolution rules +✓ audit privacy transformation strategy +✓ outbox correlation and causation IDs +✓ CORS/browser-security ADR +✓ feature-flag strategy clarified +✓ jobs confirmed tenant-scoped via X-Organization-Id +``` + +The next artifacts should be implementation-specific: + +```text +ADRs +Engineering OpenAPI +Engineering database migrations +Engineering state-machine spec +Engineering MVP backlog +``` diff --git a/professional_management_platform_rest_plan_v4_2.md b/professional_management_platform_rest_plan_v4_2.md new file mode 100644 index 0000000..ad16410 --- /dev/null +++ b/professional_management_platform_rest_plan_v4_2.md @@ -0,0 +1,6773 @@ +# Professional Management Platform +## Full REST-First System Design Plan + +> **Revision:** v4.1 — Consistency and Implementation-Contract Cleanup +> **Status:** Locked broad architecture baseline with implementation-blocking contradictions resolved. Subsequent detail belongs in ADRs, OpenAPI, migrations, domain specifications, and backlog items. +> **Primary vertical:** Engineering +> **API style:** REST + JSON + OpenAPI 3.1 +> **Backend style:** Modular monolith +> **Data:** PostgreSQL + profession-specific tables +> **Tenant model:** Organization-scoped, explicit tenant context + +### v4.1 Cleanup Notes + +v4.1 does not redesign the platform. It resolves contradictions and fills implementation contracts discovered during detailed review. + +Resolved: + +- raw UUIDv7 is now the serialized/API identifier format +- database columns remain UUID; prefixed strings are not public IDs +- all tenant-owned subresources carry direct `organization_id` +- design-version document cardinality is explicit and relational +- engineering time entries can be attributed to a specific work item +- time-entry work-item links are constrained to the time entry's project +- engineering specifications use explicit many-document link records +- project-level `budget_minor` is removed in favor of the dedicated budget model +- duplicate `legal_documents` ownership is removed +- legal matter/case document-link schemas are defined +- batch custom-action paths use one documented convention +- membership invitations can pre-assign multiple roles +- missing design `revise` command is added +- inspection follow-ups now have a table and lifecycle +- change requests are moved out of the initial Engineering schema until specified +- service accounts and API keys are defined for machine access +- service-account role assignments have an explicit relational schema +- API JSON, query parameters, and path parameter names use camelCase; database columns use snake_case +- professional profiles support multiple professional credentials +- deletion/archival/revocation/unlink behavior is globally defined +- invoice-item fields are specified +- deferred healthcare placeholder tables receive minimum schemas or explicit deferral notes +- document upload and multipart DTOs are defined +- webhook subscription fields are defined +- `POST /auth/logout` is restored +- portal review-request and portal capability schemas are defined +- project-role, task-status, priority, and inspection-outcome values are defined +- document-category uniqueness has a version-independent fallback +- retention policy fields are defined +- retention-period null semantics are explicit +- pagination defaults are explicit +- `assigned` authorization scope has resource-specific resolution rules +- feature-flag behavior is clarified +- audit privacy minimization/anonymization strategy is documented +- outbox correlation and causation IDs are added +- webhook delivery event references and job reference envelopes are defined +- CORS and web-security configuration is moved into a required ADR +- project `stage` duplication is removed; project phases remain authoritative +- phase reordering, site listing, appointment locations, and encounter reason fields are clarified +- jobs remain tenant-scoped through the standard organization header rather than path nesting + +--- +--- +--- +--- + +## 2. Core Architecture Decision + +The platform will use: + +- REST +- JSON +- OpenAPI +- Versioned endpoints +- PostgreSQL +- Modular monolith backend +- Profession-specific frontends +- Profession-specific database tables +- Shared identity, security, billing, documents, audit, and infrastructure + +Base API path: + +```text +/api/v1 +``` + +GraphQL is not part of v1. + +--- + +## 3. High-Level Architecture + +```text + FRONTENDS + + ┌──────────────────┼──────────────────┐ + │ │ │ + Engineering Web Legal Web Healthcare Web + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ + REST API + /api/v1 + │ + ┌───────────┼───────────┐ + │ │ │ + Core Engineering Legal + │ │ │ + │ Healthcare │ + │ │ │ + └───────────┼───────────┘ + │ + PostgreSQL + │ + ┌───────────────┼────────────────┐ + │ │ │ + Shared Tables Profession Tables Audit/Event Tables +``` + +Shared infrastructure: + +```text +PostgreSQL +Redis +Object Storage +Queue / Workers +Audit +Notifications +Billing +Observability +``` + +--- + +## 4. System Architecture Strategy + +Start with a modular monolith. + +Do not start with microservices. + +Initial deployment: + +```text +Frontend Apps + │ + ▼ +Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Worker Queue +``` + +Benefits: + +- simpler transactions +- easier development +- easier deployment +- clearer domain boundaries +- lower operational burden +- easier refactoring +- future service extraction remains possible + +--- + +## 5. Repository Structure + +Recommended monorepo: + +```text +professional-platform/ +│ +├── apps/ +│ ├── engineering-web/ +│ ├── legal-web/ +│ ├── healthcare-web/ +│ ├── platform-admin/ +│ ├── api/ +│ └── workers/ +│ +├── packages/ +│ ├── ui/ +│ ├── api-client/ +│ ├── auth-client/ +│ ├── validation/ +│ ├── types/ +│ ├── config/ +│ └── testing/ +│ +├── database/ +│ ├── migrations/ +│ ├── seeds/ +│ └── scripts/ +│ +├── infrastructure/ +│ ├── docker/ +│ ├── deployment/ +│ └── monitoring/ +│ +└── docs/ + ├── architecture/ + ├── api/ + ├── security/ + └── domains/ +``` + +--- + +## 6. Frontend Strategy + +Every profession receives its own frontend application. + +Avoid one giant frontend filled with profession checks. + +### Engineering Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Projects +Project Phases +Project Team +Sites +Designs +Design Reviews +Inspections +Specifications +Tasks +Documents +Timesheets +Billing +Reports +Administration +``` + +### Legal Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Matters +Cases +Hearings +Courts +Deadlines +Documents +Conflict Checks +Time Tracking +Retainers +Billing +Reports +Administration +``` + +### Healthcare Frontend + +Suggested navigation: + +```text +Dashboard +Patients +Appointments +Practitioners +Encounters +Clinical Records +Diagnoses +Prescriptions +Insurance +Documents +Billing +Reports +Administration +``` + +### Platform Admin Frontend + +Suggested functions: + +```text +Organizations +Users +Profession Modules +Subscriptions +System Health +Audit +Support +Global Configuration +``` + +Platform administrators and organization administrators are separate concepts. + +--- + +## 7. REST API Structure + +Shared endpoints: + +```text +/api/v1/auth +/api/v1/me +/api/v1/organizations +/api/v1/memberships +/api/v1/membership-invitations +/api/v1/roles +/api/v1/permissions +/api/v1/documents +/api/v1/invoices +/api/v1/payments +/api/v1/audit-events +``` + +Engineering: + +```text +/api/v1/engineering/clients +/api/v1/engineering/projects +/api/v1/engineering/project-members +/api/v1/engineering/phases +/api/v1/engineering/sites +/api/v1/engineering/tasks +/api/v1/engineering/designs +/api/v1/engineering/inspections +/api/v1/engineering/specifications +/api/v1/engineering/time-entries +``` + +Legal: + +```text +/api/v1/legal/clients +/api/v1/legal/matters +/api/v1/legal/cases +/api/v1/legal/hearings +/api/v1/legal/deadlines +/api/v1/legal/conflict-checks +/api/v1/legal/retainers +/api/v1/legal/time-entries +``` + +Healthcare: + +```text +/api/v1/healthcare/patients +/api/v1/healthcare/practitioners +/api/v1/healthcare/appointments +/api/v1/healthcare/encounters +/api/v1/healthcare/clinical-records +/api/v1/healthcare/diagnoses +/api/v1/healthcare/prescriptions +/api/v1/healthcare/insurance +``` + +--- + +## 8. REST Conventions + +All APIs use JSON over HTTPS. + +Typical tenant-scoped request: + +```http +Authorization: Bearer +X-Organization-Id: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d +X-Request-Id: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff +Content-Type: application/json +``` + +### API Naming Convention + +Public API representation: + +```text +JSON properties: camelCase +query parameters: camelCase +path parameter names in documentation: camelCase +HTTP headers: conventional HTTP header casing +``` + +Database representation: + +```text +table names: snake_case +column names: snake_case +constraint/index names: snake_case +``` + +Example: + +```http +GET /api/v1/engineering/tasks?assignedToUserId=&createdAfter=2026-08-01T00:00:00Z +``` + +```json +{ + "assignedToUserId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d", + "createdAt": "2026-08-26T12:00:00Z" +} +``` + +maps internally to columns such as: + +```text +assigned_to_user_id +created_at +``` + +### Organization Context + +`X-Organization-Id` is mandatory for every tenant-scoped endpoint. + +Global endpoints such as these do not require tenant context: + +```http +POST /api/v1/auth/login +POST /api/v1/auth/token/refresh +GET /api/v1/me +GET /api/v1/me/organizations +GET /api/v1/auth/sessions +``` + +Tenant-context resolution: + +```yaml +header_missing_on_tenant_endpoint: + status: 400 + code: ORGANIZATION_CONTEXT_REQUIRED + +organization_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + +membership_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + +membership_inactive: + status: 403 + code: AUTHZ_MEMBERSHIP_INACTIVE + +organization_inactive: + status: 403 + code: AUTHZ_ORGANIZATION_INACTIVE + +resource_organization_mismatch: + status: 404 + code: RESOURCE_NOT_FOUND +``` + +### Idempotency + +Use: + +```http +Idempotency-Key: 8f7d6c5e-4b3a-4b1c-9d8e-7f6a5b4c3d2e +``` + +Required where duplicate execution can create material side effects. + +PostgreSQL is authoritative for critical idempotency records. + +Redis may accelerate lookup. + +### Batch Custom-Action Convention + +For collection-level custom commands use: + +```text +/{collection}/batch/{action} +``` + +Examples: + +```http +POST /api/v1/engineering/tasks/batch/assign +POST /api/v1/engineering/tasks/batch/complete +POST /api/v1/engineering/time-entries/batch/submit +``` + +Do not mix `batch-assign`, colon-style custom methods, and `/batch/assign` in the same API. + +### Rate-Limit Responses + +```http +429 Too Many Requests +Retry-After: +``` + +Additional rate-limit metadata may be exposed according to the selected gateway/standard. + +Do not freeze legacy `X-RateLimit-*` names here. + +### Error Standard Decision + +The current error envelope remains: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff" + } +} +``` + +ADR-005 decides whether OpenAPI v1 aligns this with RFC 9457 Problem Details. + +Do not silently change the envelope during implementation. + +## 9. Standard Response Format + +Single resource: + +```json +{ + "data": { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c5d", + "name": "Central Tower" + } +} +``` + +Collection: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Standard error: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "req_123" + } +} +``` + +Clients depend on `error.code`, not message text. + +### Error Taxonomy + +Authentication: + +```text +AUTH_INVALID_CREDENTIALS +AUTH_TOKEN_EXPIRED +AUTH_TOKEN_INVALID +AUTH_MFA_REQUIRED +AUTH_SESSION_REVOKED +AUTH_REFRESH_TOKEN_REUSED +``` + +Authorization: + +```text +AUTHZ_PERMISSION_DENIED +AUTHZ_ORGANIZATION_INACTIVE +AUTHZ_MEMBERSHIP_INACTIVE +AUTHZ_CREDENTIAL_INVALID +AUTHZ_SCOPE_MISMATCH +``` + +Tenant context: + +```text +ORGANIZATION_CONTEXT_REQUIRED +``` + +Resource/state: + +```text +RESOURCE_NOT_FOUND +RESOURCE_ALREADY_EXISTS +RESOURCE_CONCURRENT_MODIFICATION +RESOURCE_INVALID_STATE +RESOURCE_ARCHIVED +``` + +Validation: + +```text +VALIDATION_ERROR +VALIDATION_REQUIRED_FIELD +VALIDATION_INVALID_FORMAT +VALIDATION_BUSINESS_RULE +``` + +Idempotency: + +```text +IDEMPOTENCY_KEY_REQUIRED +IDEMPOTENCY_KEY_CONFLICT +``` + +Rate limiting: + +```text +RATE_LIMIT_EXCEEDED +``` + +System/dependency: + +```text +INTERNAL_ERROR +SERVICE_UNAVAILABLE +DATABASE_UNAVAILABLE +DEPENDENCY_FAILED +``` + +Validation example: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed.", + "requestId": "req_123", + "details": { + "fields": [ + { + "field": "email", + "code": "INVALID_FORMAT", + "message": "Must be a valid email address" + } + ] + } + } +} +``` + +Business-state example: + +```json +{ + "error": { + "code": "RESOURCE_INVALID_STATE", + "message": "Cannot approve design in current state.", + "requestId": "req_123", + "details": { + "resourceType": "engineering_design", + "resourceId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c6d", + "currentState": "draft", + "requiredState": "under_review", + "allowedActions": [ + "submit_review" + ] + } + } +} +``` + +Do not expose internal stack traces, SQL, policy internals, secrets, or cross-tenant information. + +## 10. HTTP Status Rules + +```text +200 Success +201 Created +202 Accepted +204 No Content +400 Bad Request +401 Unauthorized +403 Forbidden +404 Not Found +409 Conflict +422 Validation Error +429 Too Many Requests +500 Internal Server Error +``` + +Cross-tenant resource access should return 404. + +--- + +## 11. API Versioning + +Current API: + +```text +/api/v1 +``` + +Breaking changes require: + +```text +/api/v2 +``` + +Additive fields generally do not require a new version. + +--- + +## 11A. Identifier Convention + +The serialized identifier standard is **raw UUIDv7**. + +Example: + +```text +0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d +``` + +Database: + +```sql +id UUID PRIMARY KEY +``` + +API: + +```json +{ + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d" +} +``` + +Do not serialize IDs as: + +```text +org_ +user_ +project_ +``` + +unless a future ADR explicitly changes the public identifier contract. + +Human-friendly resource references use separate fields such as: + +```text +projectNumber +matterNumber +patientNumber +invoiceNumber +``` + +This separates machine identity from business/display references. + +UUID generation is decided by ADR-004: + +```text +PostgreSQL-native UUIDv7 when supported and selected +or +application-generated UUIDv7 +``` + +The API format is identical either way. + +--- + +## 12. Authentication + +Initial human authentication: + +```text +Email ++ +Password ++ +Short-Lived Access Token ++ +Opaque Refresh Token ++ +Server-Side Session +``` + +REST: + +```http +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/logout + +POST /api/v1/auth/token/refresh +POST /api/v1/auth/token/revoke +POST /api/v1/auth/token/revoke-all + +GET /api/v1/auth/sessions +DELETE /api/v1/auth/sessions/{sessionId} + +GET /api/v1/me +``` + +`POST /auth/logout` revokes the current session. + +`DELETE /auth/sessions/{sessionId}` allows a user to revoke a specific session, such as another device. + +### Access Token + +```yaml +format: JWT +lifetime: short-lived +signed: true +encrypted: false +claims: + - sub + - sessionId + - issuer + - audience + - issuedAt + - expiresAt +``` + +Organization context is not trusted from the token as authorization authority. + +### Sessions + +```text +sessions +├── id +├── user_id +├── device metadata +├── created_at +├── last_active_at +├── expires_at +├── revoked_at +└── revocation_reason +``` + +### Refresh Tokens + +```text +refresh_tokens +├── id +├── session_id +├── family_id +├── token_hash +├── issued_at +├── expires_at +├── rotated_at +├── replaced_by_token_id +├── revoked_at +└── revocation_reason +``` + +Constraints/indexes: + +```text +UNIQUE(token_hash) +INDEX(family_id) +INDEX(session_id) +``` + +`family_id` is not unique. + +### Refresh Reuse Detection + +Use of a previously rotated token triggers: + +```text +revoke token family +revoke affected session +security audit event +reauthentication +``` + +Policy may escalate to all-session revocation. + +Future human authentication: + +- MFA +- WebAuthn/passkeys +- OIDC/SSO +- enterprise identity providers + + +## 12A. Service Accounts and API Keys + +Machine-to-machine access is separate from human sessions. + +Use: + +```text +service_accounts +api_keys +service_account_roles +``` + +### Service Account + +Suggested fields: + +```text +id +organization_id +name +description +status +created_by_user_id +created_at +updated_at +revoked_at +``` + +### API Key + +Suggested fields: + +```text +id +organization_id +service_account_id + +key_prefix +secret_hash + +created_at +expires_at +last_used_at +revoked_at +revocation_reason +``` + +Raw API-key secrets are shown only once. + +Store only a secure hash of the secret. + +`key_prefix` is safe display material for identifying a key in administration screens. + +### Service Account Role + +`service_account_roles` uses the same organization-scoped role registry as human RBAC assignments. + +Suggested fields: + +```text +id +organization_id +service_account_id +role_id +created_at +``` + +Unique: + +```text +(organization_id, service_account_id, role_id) +``` + +Tenant-safe foreign keys require the service account and role to belong to the same organization as the assignment. + +### Authorization + +Service accounts use explicit organization-scoped permissions, preferably through: + +```text +service_account_roles +``` + +with the same registered permission vocabulary used by RBAC. + +They do not become fake human memberships. + +### Audit + +Audit actors support: + +```text +actor_type = user +actor_type = service_account +actor_type = system +``` + +Machine authentication is required when public/integration API access is implemented; it does not block the earliest internal Engineering UI slice. + +--- + +## 13. Shared Core Backend + +Recommended modules: + +```text +core/ +├── auth/ +├── users/ +├── organizations/ +├── memberships/ +├── roles/ +├── permissions/ +├── authorization/ +├── documents/ +├── billing/ +├── notifications/ +├── audit/ +└── events/ +``` + +Dependency rule: + +```text +Profession module → Core +``` + +Never: + +```text +Core → Profession module +``` + +--- + +## 14. Organizations + +Organizations are tenants. + +Examples: + +```text +Atlas Structural Engineering +Smith & Associates Law +North Shore Medical Practice +``` + +Suggested fields: + +```text +id +name +slug +status +country_code +timezone +currency_code +created_at +updated_at +``` + +--- + +## 15. Profession Enablement + +Use: + +```text +organization_professions +``` + +Suggested fields: + +```text +organization_id +profession +enabled_at +configuration +``` + +Possible professions: + +```text +engineering +legal +healthcare +``` + +An organization may eventually enable more than one profession module. + +--- + +## 16. Users and Memberships + +Users are global identities. + +A user gains tenant access through membership. + +```text +User + │ + ▼ +Membership + │ + ▼ +Organization +``` + +Suggested `users` fields: + +```text +id +email +first_name +last_name +phone +avatar_url +status +created_at +updated_at +``` + +Suggested `memberships` fields: + +```text +id +organization_id +user_id +status +joined_at +created_at +updated_at +``` + +--- + +## 17. Membership Invitations + +Keep invitations separate from memberships. + +Tables: + +```text +membership_invitations +membership_invitation_roles +``` + +`membership_invitations`: + +```text +id +organization_id +email +invited_by_user_id +expires_at +accepted_at +revoked_at +created_at +``` + +`membership_invitation_roles`: + +```text +organization_id +invitation_id +role_id +created_at +``` + +Use tenant-aware foreign keys so invitation roles cannot reference another organization's role. + +Flow: + +```text +Invitation + Intended Roles + ↓ + Accepted + ↓ + User + ↓ + Membership + ↓ + Membership Roles +``` + +At acceptance: + +1. validate invitation token and expiry +2. validate invited email/account policy +3. create membership +4. copy valid intended roles to membership-role assignments +5. mark invitation accepted +6. audit +7. emit outbox event + +If an intended role was revoked/deleted before acceptance, acceptance fails safely or drops that role according to explicit organization policy. + +## 18. Authorization + +Use: + +```text +RBAC ++ +Permission Scope ++ +Resource Policies ++ +Professional Qualification Policies ++ +Domain State Rules +``` + +Decision flow: + +```text +Authenticated User + ↓ +Explicit Organization Context + ↓ +Active Membership + ↓ +Enabled Profession Module + ↓ +Roles + ↓ +Permissions + ↓ +Permission Scope + ↓ +Tenant-scoped Resource Query + ↓ +Resource Policy + ↓ +Credential/Jurisdiction Policy + ↓ +Domain State Rule + ↓ +ALLOW / DENY +``` + +Default decision: + +```text +DENY +``` + +Authorization rules: + +1. Controllers never perform ad-hoc role comparisons. +2. Tenant resource queries always include `organization_id`. +3. Do not load an arbitrary resource first and then discover it belongs to another tenant. +4. High-risk professional actions perform credential checks at command execution time. +5. A permission grants the ability to attempt an action, not a guarantee the domain state allows it. +6. Cross-tenant resources appear nonexistent. +7. Profession module enablement is checked before profession-specific authorization. + +## 19. Roles and Permissions + +Roles are organization-scoped collections of permissions. + +Example roles: + +```text +Owner +Administrator +Project Manager +Engineer +Reviewer +Inspector +Lawyer +Paralegal +Doctor +Nurse +Billing Manager +Viewer +``` + +Roles are not professional credentials. + +### Engineering Permissions + +```text +engineering.clients.read +engineering.clients.create +engineering.clients.update +engineering.clients.archive + +engineering.projects.read +engineering.projects.create +engineering.projects.update +engineering.projects.activate +engineering.projects.close +engineering.projects.archive + +engineering.project_members.manage +engineering.phases.manage +engineering.tasks.manage +engineering.sites.manage + +engineering.documents.read +engineering.documents.upload +engineering.documents.delete + +engineering.designs.read +engineering.designs.create +engineering.designs.update +engineering.designs.review +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede + +engineering.inspections.read +engineering.inspections.manage +engineering.inspections.complete + +engineering.time_entries.manage +engineering.reports.read +``` + +### Legal Permissions + +```text +legal.clients.read +legal.clients.create +legal.clients.update + +legal.matters.read +legal.matters.create +legal.matters.update +legal.matters.close +legal.matters.reopen + +legal.cases.read +legal.cases.manage +legal.hearings.manage +legal.deadlines.manage + +legal.documents.read +legal.documents.upload + +legal.conflicts.manage +legal.conflicts.approve + +legal.retainers.manage +legal.time_entries.manage +``` + +### Healthcare Permissions + +```text +healthcare.patients.read +healthcare.patients.create +healthcare.patients.update + +healthcare.appointments.read +healthcare.appointments.manage + +healthcare.encounters.read +healthcare.encounters.manage + +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.records.access_log.read + +healthcare.prescriptions.read +healthcare.prescriptions.write +healthcare.prescriptions.sign + +healthcare.insurance.read +healthcare.insurance.manage +``` + +### Shared Permissions + +```text +documents.read +documents.upload + +billing.read +invoices.create +invoices.issue +invoices.void +payments.record +payments.refund + +members.read +members.invite +members.update +members.remove + +roles.read +roles.manage + +audit.read +``` + +Avoid vague permissions such as `admin_everything` in normal tenant RBAC. + +## 20. Permission Scopes + +Initial scopes: + +```text +assigned +organization +``` + +Example: + +```text +Engineer: +engineering.projects.read = assigned + +Principal Engineer: +engineering.projects.read = organization +``` + +`assigned` is not magic. Each resource policy defines how assignment is resolved. + +### Engineering Project + +Assigned when: + +```text +engineering_project_members.user_id = ctx.userId +AND engineering_project_members.left_at IS NULL +``` + +or when the user is the active project manager, if project-manager assignment is modeled separately. + +### Engineering Task + +Assigned when: + +```text +engineering_tasks.assigned_to_user_id = ctx.userId +``` + +For tasks linked to a project, parent-project access may also be required. + +### Engineering Design + +Assigned when an active row exists in: + +```text +engineering_design_assignments +``` + +for the user and an allowed assignment role. + +### Engineering Inspection + +Assigned when: + +```text +engineering_inspections.inspector_user_id = ctx.userId +``` + +or an explicit inspection assignment exists if the model later supports multiple inspectors. + +### Derived Client Access + +An assigned professional may access a client only through a policy that derives access from authorized projects. + +Project assignment must not automatically grant access to every project belonging to that client. + +Future scopes may include: + +```text +owned +team +department +restricted +``` + +Do not add them before a real workflow requires them. + +## 21. Professional Credentials + +Professional identity and credentials are separate from RBAC. + +Use: + +```text +professional_profiles +professional_credentials +``` + +### Professional Profile + +One organization/user/profession relationship. + +Suggested fields: + +```text +id +organization_id +user_id +profession +title +status +created_at +updated_at +``` + +### Professional Credential + +One profile may hold many credentials. + +Suggested fields: + +```text +id +organization_id +professional_profile_id + +credential_type +credential_number +issuing_authority +jurisdiction +discipline + +status +valid_from +expires_at + +verified_at +verified_by_user_id + +created_at +updated_at +``` + +Examples: + +```text +professional engineering license in jurisdiction A +professional engineering license in jurisdiction B +specialty certification +medical license +controlled-substance prescribing registration where applicable +``` + +Credential policy evaluates the set of active credentials rather than one `primary_license_number`. + +High-risk actions such as design approval, record signing, or prescribing use authoritative or revocation-aware credential state. + +Prescribing remains jurisdiction/scope-of-practice policy, not a hard-coded profession test. + +## 22. Database Architecture + +Use PostgreSQL. + +Start with: + +```text +One database ++ +Shared schema ++ +Profession-specific tables +``` + +Do not begin with database-per-profession or database-per-customer unless compliance or residency requirements force that choice. + +--- + +## 23. Shared Tables + +Recommended shared tables: + +```text +organizations +organization_professions + +users +user_credentials +sessions + +memberships +membership_invitations + +roles +permissions +role_permissions +membership_roles + +professional_profiles + +documents +document_versions + +invoices +invoice_items +payments + +notifications +notification_deliveries + +audit_events +outbox_events +``` + +--- + +## 24. Multi-Tenancy Rule + +Every tenant-owned row must contain: + +```text +organization_id +``` + +Examples: + +```text +engineering_projects.organization_id +legal_matters.organization_id +healthcare_patients.organization_id +``` + +Enforce tenant boundaries at: + +- API layer +- authorization layer +- repository/query layer +- database constraints + +--- + +## 25. Tenant-Safe Foreign Keys + +Use composite tenant-aware foreign keys when possible. + +Example: + +```text +engineering_projects +organization_id +client_id +``` + +references: + +```text +engineering_clients +organization_id +id +``` + +This prevents linking a resource from one organization to another organization's data. + +--- + +## 21A. Deletion, Archival, Revocation, and Unlink Policy + +`DELETE` does not have one universal persistence meaning. + +Use four lifecycle behaviors. + +### Archive / Domain Inactivation + +For business records whose history matters: + +```text +engineering clients +engineering projects +legal matters +healthcare patients +documents where retention requires history +``` + +Typical fields: + +```text +status +archived_at +archived_by_user_id +``` + +Restore is permitted only when domain, retention, and organization policy allow it. + +### Revoke + +For access/security resources: + +```text +sessions +refresh tokens +API keys +membership invitations +portal grants +webhook credentials +``` + +Use: + +```text +revoked_at +revoked_by +revocation_reason +``` + +### Temporal Unlink + +For relationship records where the historical relationship matters: + +```text +project documents +project members +design assignments +portal document publications +``` + +Use: + +```text +unlinked_at +left_at +unassigned_at +revoked_at +``` + +rather than deleting historical evidence. + +### Hard Delete + +Reserved for genuinely disposable or never-committed data, such as: + +```text +expired pending upload artifacts +failed temporary staging objects +unreferenced draft configuration where audit/retention does not require history +``` + +Hard deletion of financial, professional, audit, signed clinical, or issued business records is forbidden unless an explicit retention/privacy policy defines the operation. + +Every resource specification must declare its lifecycle behavior. + +--- + +# Engineering Domain + +## 26. Engineering Tables + +Initial Engineering MVP tables: + +```text +engineering_clients +engineering_client_contacts + +engineering_projects +engineering_project_members +engineering_project_phases +engineering_sites +engineering_tasks + +engineering_designs +engineering_design_assignments +engineering_design_versions +engineering_design_version_documents +engineering_design_reviews + +engineering_inspections +engineering_inspection_findings +engineering_inspection_followups + +engineering_specifications +engineering_specification_documents + +engineering_time_entries +``` + +Later Engineering extensions: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries + +engineering_change_requests +``` + +`engineering_change_requests` is not part of the initial schema until its lifecycle, relationships, and REST contract are specified. + +## 27. Engineering Clients + +Suggested core client fields: + +```text +id +organization_id +client_type +display_name +legal_name +status +created_at +updated_at +version +``` + +Do not permanently squeeze all contacts into one `email`, one `phone`, and one `contact_name`. + +Engineering customers commonly have multiple: + +```text +technical contacts +billing contacts +executive contacts +site contacts +contract contacts +``` + +Use: + +```text +engineering_client_contacts +``` + +Suggested contact fields: + +```text +id +organization_id +client_id + +name +title +department + +email +phone + +contact_type +is_primary + +created_at +updated_at +``` + +Client REST: + +```http +GET /api/v1/engineering/clients +POST /api/v1/engineering/clients +GET /api/v1/engineering/clients/{clientId} +PATCH /api/v1/engineering/clients/{clientId} + +POST /api/v1/engineering/clients/{clientId}/archive +POST /api/v1/engineering/clients/{clientId}/restore + +GET /api/v1/engineering/clients/{clientId}/projects +GET /api/v1/engineering/clients/{clientId}/invoices +``` + +Contact REST: + +```http +GET /api/v1/engineering/clients/{clientId}/contacts +POST /api/v1/engineering/clients/{clientId}/contacts +PATCH /api/v1/engineering/clients/{clientId}/contacts/{contactId} +DELETE /api/v1/engineering/clients/{clientId}/contacts/{contactId} +``` + +Delete may be implemented as archival when contact history matters. + +Client restore is allowed only when organization policy and retention rules permit it. + +## 27A. Engineering Client Portal + +External clients are not internal organization members. + +Use shared authentication identities where practical, but create a separate authorization boundary. + +```text +User + │ + ├── Internal Membership + │ ↓ + │ Organization Staff Access + │ + └── Client Portal Account + ↓ + Engineering Client Contact + ↓ + Project Access Grants +``` + +Suggested tables: + +```text +engineering_client_portal_accounts +engineering_client_portal_project_grants +engineering_project_document_publications +engineering_client_review_requests +``` + +### Portal Account + +Suggested fields: + +```text +id +organization_id +user_id +engineering_client_contact_id + +status + +invited_by_user_id +invited_at +accepted_at + +revoked_at +revoked_by_user_id +``` + +Portal accounts are not placed in `memberships`. + +### Project Grant + +Suggested fields: + +```text +id +organization_id +portal_account_id +project_id + +access_profile + +granted_by_user_id +granted_at +expires_at +revoked_at +``` + +Initial access capabilities may include: + +```text +project.status.read +project.documents.read_published +project.comments.create +project.files.submit +client_review.respond +``` + +The access model may later normalize capabilities into a grant table if simple profiles become insufficient. + +### Separate Frontend + +Recommended: + +```text +apps/ +├── engineering-web/ +└── engineering-client-portal/ +``` + +The internal engineering frontend and external portal do not share authorization assumptions. + +### Client Acceptance Is Not Engineering Approval + +Never represent client acceptance with: + +```text +engineering.designs.approve +``` + +Professional engineering approval is reserved for qualified internal/authorized professionals. + +Client-facing review should use separate concepts such as: + +```text +engineering.client_reviews.request +engineering.client_reviews.respond +engineering.client_reviews.accept +engineering.client_reviews.request_changes +``` + +Example: + +```http +POST /api/v1/engineering/client-review-requests/{reviewId}/accept +POST /api/v1/engineering/client-review-requests/{reviewId}/request-changes +``` + +A client acceptance may be commercially meaningful without being a professional engineering approval. + +### Portal Security Rules + +1. portal access is deny-by-default +2. every portal request remains organization-scoped +3. portal users only access explicitly granted projects +4. project membership does not apply to portal users +5. internal RBAC roles do not automatically apply to portal users +6. portal account revocation is immediate +7. portal grants may expire +8. sensitive document access requires explicit publication +9. portal activity is audited according to organization policy +10. professional approval endpoints are never exposed through portal grants + +--- + +## 27B. External Document Publication + +A document being linked to an engineering project does **not** make it externally visible. + +Use: + +```text +engineering_project_document_publications +``` + +Suggested fields: + +```text +id +organization_id + +project_document_link_id + +audience_type +portal_account_id nullable +client_id nullable + +published_by_user_id +published_at + +expires_at +revoked_at +revoked_by_user_id +``` + +Possible audiences: + +```text +all_active_client_portal_accounts_for_project +specific_portal_account +specific_client_contact +``` + +External download checks: + +```text +authenticated portal user ++ +active portal account ++ +active project grant ++ +active document publication ++ +publication not expired/revoked ++ +document classification allows publication ++ +download permission +``` + +This prevents an internal project document from appearing in the client portal merely because it is linked to the project. + +## 28. Engineering Projects + +Suggested fields: + +```text +id +organization_id +client_id + +project_number +name +description +discipline + +status + +project_manager_user_id + +start_date +expected_completion_date +completed_date + +created_at +updated_at +version +``` + +`stage` is removed from the project row because project phases are the authoritative workflow decomposition. + +If the frontend needs a "current stage", derive it from the active/current project phase or maintain an explicitly documented `current_phase_id` pointer. + +Project `budget_minor` is also removed. + +Detailed project budgets belong to the dedicated budget model. + +REST: + +```http +GET /api/v1/engineering/projects +POST /api/v1/engineering/projects +GET /api/v1/engineering/projects/{projectId} +PATCH /api/v1/engineering/projects/{projectId} + +POST /api/v1/engineering/projects/{projectId}/activate +POST /api/v1/engineering/projects/{projectId}/close +POST /api/v1/engineering/projects/{projectId}/archive +``` + +Purpose-built reads: + +```http +GET /api/v1/engineering/projects/{projectId}/summary +GET /api/v1/engineering/projects/{projectId}/timeline +GET /api/v1/engineering/projects/{projectId}/budget +``` + +The budget endpoint reads from the budget module when that module exists. + +## 29. Engineering Project Members + +Suggested fields: + +```text +id +organization_id +project_id +user_id +project_role +joined_at +left_at +``` + +Initial project-role vocabulary: + +```text +project_manager +engineer +designer +reviewer +inspector +viewer +contractor +``` + +Project role describes participation in one project. + +It is not a substitute for RBAC permission. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/members +POST /api/v1/engineering/projects/{projectId}/members +PATCH /api/v1/engineering/projects/{projectId}/members/{memberId} +DELETE /api/v1/engineering/projects/{projectId}/members/{memberId} +``` + +`DELETE` means end participation by setting `left_at`, not erase historical participation. + +## 30. Engineering Project Phases + +Suggested fields: + +```text +id +organization_id +project_id +name +sequence +status +start_date +end_date +created_at +updated_at +version +``` + +Typical initial statuses: + +```text +planned +active +completed +cancelled +``` + +Example phases: + +```text +Concept +Preliminary Design +Detailed Design +Construction +Inspection +Closeout +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/phases +POST /api/v1/engineering/projects/{projectId}/phases +PATCH /api/v1/engineering/projects/{projectId}/phases/{phaseId} + +POST /api/v1/engineering/projects/{projectId}/phases/{phaseId}/complete +POST /api/v1/engineering/projects/{projectId}/phases/reorder +``` + +Reorder request: + +```json +{ + "projectVersion": 12, + "orderedPhaseIds": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b301", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b302" + ] +} +``` + +Reordering is transactional. + +Sequences remain unique within a project after commit. + +## 31. Engineering Sites + +Suggested fields: + +```text +id +organization_id +project_id +name +address +latitude +longitude +created_at +updated_at +``` + +REST: + +```http +GET /api/v1/engineering/sites +GET /api/v1/engineering/sites/{siteId} + +POST /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/projects/{projectId}/sites + +PATCH /api/v1/engineering/sites/{siteId} +``` + +Global site listing is still tenant-scoped through `X-Organization-Id`. + +## 32. Engineering Tasks + +Suggested fields: + +```text +id +organization_id +project_id + +title +description + +status +priority + +created_by_user_id +assigned_to_user_id + +due_at +completed_at + +created_at +updated_at +version +``` + +Statuses: + +```text +todo +in_progress +completed +cancelled +``` + +Priorities: + +```text +low +medium +high +urgent +``` + +REST: + +```http +POST /api/v1/engineering/tasks +GET /api/v1/engineering/tasks +GET /api/v1/engineering/tasks/{taskId} +PATCH /api/v1/engineering/tasks/{taskId} + +POST /api/v1/engineering/tasks/{taskId}/complete +POST /api/v1/engineering/tasks/{taskId}/reopen +POST /api/v1/engineering/tasks/{taskId}/cancel +``` + +## 32A. Engineering Batch Operations + +Batch operations are useful for repetitive engineering workflows, but they must not bypass per-resource authorization or domain rules. + +Examples: + +```http +POST /api/v1/engineering/tasks/batch/assign +POST /api/v1/engineering/tasks/batch/complete + +POST /api/v1/engineering/time-entries/batch/submit +``` + +### Batch Execution Modes + +Every batch command explicitly defines one of: + +```text +atomic +partial +``` + +Atomic: + +```text +all resources succeed +or +entire operation fails +``` + +Partial: + +```text +each resource is evaluated independently +successful items commit +failed items return individual errors +``` + +Do not leave this behavior implicit. + +Example request: + +```json +{ + "taskIds": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c81", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c82", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c83" + ], + "assigneeUserId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d", + "mode": "partial" +} +``` + +Example response: + +```json +{ + "data": { + "succeeded": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c81", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c82" + ], + "failed": [ + { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c83", + "code": "RESOURCE_INVALID_STATE" + } + ] + } +} +``` + +### Authorization + +Each resource is evaluated for: + +```text +tenant +permission +scope +resource access +state validity +credential policy where applicable +``` + +Never authorize the first item and assume the remaining batch is equivalent. + +### Synchronous vs Asynchronous + +Small batches may execute synchronously. + +Large batches become jobs: + +```http +202 Accepted +``` + +with: + +```text +jobId +``` + +The synchronous/asynchronous threshold is configuration based on: + +```text +batch size +operation cost +database load +side effects +product tier +``` + +Financial or regulated batch actions require stricter idempotency and audit rules than ordinary task updates. + +--- + +## 33. Engineering Designs + +Suggested fields: + +```text +id +organization_id +project_id + +design_number +title +description +discipline + +status + +owner_user_id +prepared_by_user_id + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +States: + +```text +draft +under_review +changes_requested +approved +rejected +cancelled +withdrawn +superseded +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/designs +POST /api/v1/engineering/projects/{projectId}/designs + +GET /api/v1/engineering/designs/{designId} +PATCH /api/v1/engineering/designs/{designId} + +POST /api/v1/engineering/designs/{designId}/submit-review +POST /api/v1/engineering/designs/{designId}/request-changes +POST /api/v1/engineering/designs/{designId}/approve +POST /api/v1/engineering/designs/{designId}/reject +POST /api/v1/engineering/designs/{designId}/revise +POST /api/v1/engineering/designs/{designId}/cancel +POST /api/v1/engineering/designs/{designId}/withdraw +POST /api/v1/engineering/designs/{designId}/supersede + +POST /api/v1/engineering/designs/{designId}/assign +POST /api/v1/engineering/designs/{designId}/unassign + +GET /api/v1/engineering/designs/{designId}/versions +POST /api/v1/engineering/designs/{designId}/versions + +GET /api/v1/engineering/designs/{designId}/reviews +POST /api/v1/engineering/designs/{designId}/reviews +``` + +State machine: + +```text +draft + ├── submit-review ─────────────► under_review + └── cancel ────────────────────► cancelled + +under_review + ├── request-changes ───────────► changes_requested + ├── approve ───────────────────► approved + ├── reject ────────────────────► rejected + └── withdraw ──────────────────► withdrawn + +changes_requested + ├── submit-review ─────────────► under_review + └── withdraw ──────────────────► withdrawn + +rejected + └── revise ────────────────────► draft + +approved + └── supersede ─────────────────► superseded +``` + +Approval remains credential-aware, audited, and idempotent. + +Designs do not use generic archive/restore endpoints. Their professional lifecycle terminates through explicit state-machine outcomes such as `cancelled`, `withdrawn`, and `superseded`. Terminal designs remain queryable and auditable and are not hard-deleted through ordinary workflows. + +## 34. Design Versions and Reviews + +A design version is a logical professional revision. + +It may have multiple document files. + +Use: + +```text +engineering_design_versions +engineering_design_version_documents +engineering_design_reviews +``` + +### Design Version + +```text +id +organization_id +design_id +version_number +created_by_user_id +created_at +``` + +Unique: + +```text +(organization_id, design_id, version_number) +``` + +### Design Version Documents + +```text +id +organization_id +design_version_id +document_id +document_role +linked_by_user_id +linked_at +unlinked_at +``` + +Possible `document_role` values: + +```text +primary_drawing +calculation +supporting_document +specification +attachment +``` + +A design version therefore supports one or many documents without putting `document_id` directly on the version. + +### Design Review + +```text +id +organization_id +design_id +design_version_id +reviewer_user_id +status +comments +reviewed_at +created_at +``` + +Statuses: + +```text +pending +approved +changes_requested +rejected +``` + +All three tables are tenant-owned and carry direct `organization_id`. + +## 35. Engineering Inspections + +Inspection fields: + +```text +id +organization_id +project_id +site_id + +inspection_type +inspector_user_id + +status +outcome + +scheduled_at +started_at +performed_at +cancelled_at + +summary + +created_at +updated_at +version +``` + +Lifecycle: + +```text +draft +scheduled +in_progress +completed +cancelled +``` + +Outcome: + +```text +passed +passed_with_observations +followup_required +failed +``` + +`inspection_type` is an application/domain registry rather than a PostgreSQL enum. + +Initial common keys may include: + +```text +structural +mechanical +electrical +safety +final +``` + +Organizations/modules may add supported types through controlled configuration later. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/inspections +POST /api/v1/engineering/projects/{projectId}/inspections + +GET /api/v1/engineering/inspections/{inspectionId} +PATCH /api/v1/engineering/inspections/{inspectionId} + +POST /api/v1/engineering/inspections/{inspectionId}/schedule +POST /api/v1/engineering/inspections/{inspectionId}/start +POST /api/v1/engineering/inspections/{inspectionId}/complete +POST /api/v1/engineering/inspections/{inspectionId}/cancel + +GET /api/v1/engineering/inspections/{inspectionId}/findings +POST /api/v1/engineering/inspections/{inspectionId}/findings + +GET /api/v1/engineering/inspections/{inspectionId}/followups +POST /api/v1/engineering/inspections/{inspectionId}/followups +``` + +Inspection completion may create follow-up records. + +Lifecycle and outcome remain separate. + +## 36. Inspection Findings + +`engineering_inspection_findings`: + +```text +id +organization_id +inspection_id + +severity +description +status + +resolved_at +resolved_by_user_id + +created_at +updated_at +version +``` + +Severity: + +```text +observation +minor +major +critical +``` + +Status: + +```text +open +in_progress +resolved +accepted_risk +``` + +REST: + +```http +POST /api/v1/engineering/inspections/{inspectionId}/findings +PATCH /api/v1/engineering/inspection-findings/{findingId} +POST /api/v1/engineering/inspection-findings/{findingId}/resolve +``` + +`organization_id` is direct even though tenant ownership is also derivable through the inspection. + +### Follow-Up Resource + +Use: + +```text +engineering_inspection_followups +``` + +Fields: + +```text +id +organization_id +inspection_id + +followup_type + +linked_task_id nullable +linked_inspection_id nullable + +status + +created_by_user_id +created_at +completed_at +cancelled_at +``` + +`followup_type`: + +```text +corrective_task +followup_inspection +both +``` + +`status`: + +```text +open +in_progress +completed +cancelled +``` + +Tenant-safe foreign keys apply to the original inspection and any linked task/inspection. + +## 37. Engineering Specifications + +Use: + +```text +engineering_specifications +engineering_specification_documents +``` + +Suggested specification fields: + +```text +id +organization_id +project_id +specification_number +title +version +status +created_at +updated_at +``` + +Status values: + +```text +draft +active +superseded +archived +``` + +Specification document links: + +```text +id +organization_id +specification_id +document_id +document_role +linked_by_user_id +linked_at +unlinked_at +``` + +A specification may therefore have one or many current or historical document links. `document_role` is an application registry with initial values such as `primary`, `attachment`, and `supporting_document`. Tenant-safe foreign keys apply to both the specification and shared document. + +--- + +## 38. Engineering Change Requests + +**Deferred from the initial Engineering schema.** + +Change requests are a valid future engineering capability, but v4.1 does not create the table until these are specified: + +```text +relationship to project +relationship to design/specification +request origin +impact analysis +cost/schedule effects +review workflow +approval authority +state machine +document links +REST commands +audit requirements +``` + +Future candidate: + +```text +engineering_change_requests +``` + +This belongs in the Engineering extension backlog rather than a half-defined initial migration. + +## 38A. Engineering Project Budgets + +A single `budget_minor` column is sufficient only for a very early project total. + +When budget management enters scope, introduce: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries +``` + +### Budget + +Suggested fields: + +```text +id +organization_id +project_id + +name +currency_code +status + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +### Budget Item + +Suggested fields: + +```text +id +organization_id +budget_id + +category +description + +allocated_amount_minor + +created_at +updated_at +``` + +Do not casually store mutable: + +```text +spent_amount_minor +committed_amount_minor +``` + +as independent sources of truth if those values are derived from time entries, expenses, purchase commitments, or invoices. + +Prefer: + +```text +authoritative cost/commitment records + ↓ +derived budget projections +``` + +If denormalized totals are needed for performance, update them transactionally and reconcile them. + +Potential REST: + +```http +GET /api/v1/engineering/projects/{projectId}/budgets +POST /api/v1/engineering/projects/{projectId}/budgets +GET /api/v1/engineering/budgets/{budgetId} +PATCH /api/v1/engineering/budgets/{budgetId} + +POST /api/v1/engineering/budgets/{budgetId}/approve +GET /api/v1/engineering/budgets/{budgetId}/items +POST /api/v1/engineering/budgets/{budgetId}/items +``` + +Budget approval is an explicit command. + +--- + +## 39. Engineering Time Entries + +Suggested fields: + +```text +id +organization_id +project_id +user_id + +work_date +duration_minutes +description + +billable +billing_rate_minor +currency_code + +phase_id nullable +task_id nullable +design_id nullable +inspection_id nullable + +created_at +updated_at +version +``` + +The project is always required. + +A time entry may also identify one primary work item. + +Database check: + +```text +at most one of: +phase_id +task_id +design_id +inspection_id +``` + +Each optional foreign key is tenant- and project-aware. For example: + +```text +(organization_id, project_id, task_id) +→ engineering_tasks(organization_id, project_id, id) +``` + +and similarly for phase, design, and inspection. Supporting unique constraints on `(organization_id, project_id, id)` are required on each target table. + +This is a database-enforced invariant, not only an application validation rule: whenever an optional work-item ID is present, that work item must belong to the same organization and `project_id` as the time entry. + +This preserves relational integrity instead of using an unconstrained polymorphic `reference_type/reference_id`. + +REST: + +```http +POST /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries/{timeEntryId} +PATCH /api/v1/engineering/time-entries/{timeEntryId} + +POST /api/v1/engineering/time-entries/batch/submit +``` + +Duration is integer minutes. + +## 40. Legal Tables + +Initial legal-domain tables: + +```text +legal_clients +legal_matters +legal_matter_members +legal_cases +legal_case_parties +legal_courts +legal_hearings +legal_deadlines +legal_time_entries +legal_retainers +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +legal_matter_documents +legal_case_documents +``` + +There is no separate `legal_documents` ownership table. + +Documents remain shared infrastructure: + +```text +documents +document_versions +``` + +Legal relationships use: + +```text +legal_matter_documents +legal_case_documents +``` + +`legal_matter_documents` fields: + +```text +id +organization_id +matter_id +document_id +linked_by_user_id +linked_at +unlinked_at +``` + +`legal_case_documents` fields: + +```text +id +organization_id +case_id +document_id +linked_by_user_id +linked_at +unlinked_at +``` + +Both tables use tenant-safe foreign keys to their Legal parent and the shared `documents` table. `unlinked_at` preserves link history without deleting the shared document. + +REST namespace: + +```text +/api/v1/legal +``` + +Legal remains a later vertical. + +## 41. Legal Matters + +Suggested fields: + +```text +id +organization_id +client_id +matter_number +title +practice_area +responsible_lawyer_user_id +status +opened_date +closed_date +created_at +updated_at +``` + +--- + +## 42. Legal Cases + +Suggested fields: + +```text +id +organization_id +matter_id +case_number +court_id +jurisdiction +case_type +status +filed_date +created_at +updated_at +``` + +--- + +## 43. Legal Hearings + +Suggested fields: + +```text +id +organization_id +case_id +hearing_type +scheduled_at +courtroom +judge +status +notes +``` + +--- + +## 44. Legal Conflict Checks + +Suggested tables: + +```text +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +Conflict-check fields: + +```text +id +organization_id +potential_client_name +matter_description +requested_by_user_id +reviewed_by_user_id +status +decision +decision_reason +created_at +reviewed_at +version +``` + +Request example: + +```json +{ + "potentialClientName": "Acme Corporation", + "relatedParties": [ + { + "name": "John Smith", + "relationship": "CEO" + }, + { + "name": "Acme Subsidiary LLC", + "relationship": "Subsidiary" + } + ], + "matterDescription": "Corporate acquisition" +} +``` + +Response may contain possible matches: + +```json +{ + "data": { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cbd", + "status": "pending_review", + "potentialConflicts": [ + { + "type": "possible_direct_adversity", + "partyName": "Acme Corporation", + "existingMatterId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2ccd", + "existingMatterNumber": "MAT-2026-089" + } + ] + } +} +``` + +The system should distinguish: + +```text +automated possible match +``` + +from: + +```text +lawyer-approved conflict determination +``` + +The software may assist discovery; it should not silently make the professional judgment. + +Approvals and declines are auditable commands. + +## 45. Healthcare Tables + +Healthcare remains a later vertical. + +Minimum planned tables: + +```text +healthcare_patients +healthcare_patient_contacts +healthcare_patient_addresses + +healthcare_practitioners +healthcare_locations +healthcare_rooms + +healthcare_appointments +healthcare_encounters + +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments + +healthcare_diagnoses +healthcare_prescriptions +healthcare_insurance_policies +healthcare_allergies +healthcare_medications +``` + +All tenant-owned tables carry direct `organization_id`. + +Detailed healthcare interoperability, terminology, and jurisdiction rules require healthcare-specific design before implementation. + +## 46. Healthcare Patients + +Core patient: + +```text +id +organization_id +patient_number + +first_name +middle_name +last_name +date_of_birth + +administrative_gender nullable +sex_at_birth nullable +gender_identity nullable + +status + +created_at +updated_at +version +``` + +Exact demographic terminology and allowed values are finalized in the healthcare-domain specification. + +Do not make every field mandatory merely because it exists. + +### Patient Contact + +`healthcare_patient_contacts`: + +```text +id +organization_id +patient_id + +contact_type +value +is_primary + +created_at +updated_at +``` + +### Patient Address + +`healthcare_patient_addresses`: + +```text +id +organization_id +patient_id + +address_type +line_1 +line_2 +city +region +postal_code +country_code + +is_primary + +created_at +updated_at +``` + +Sensitive subresources remain permission-controlled. + +## 47. Healthcare Practitioners + +Suggested fields: + +```text +id +organization_id +user_id +professional_profile_id + +specialty +status + +created_at +updated_at +``` + +Professional licenses are not duplicated here. + +Multiple licenses/credentials live in: + +```text +professional_credentials +``` + +## 48. Healthcare Appointments + +Suggested fields: + +```text +id +organization_id + +patient_id +practitioner_id + +location_id nullable +room_id nullable + +appointment_type + +starts_at +ends_at + +status +reason + +created_at +updated_at +version +``` + +Planned supporting tables: + +`healthcare_locations`: + +```text +id +organization_id +name +address fields +timezone +status +``` + +`healthcare_rooms`: + +```text +id +organization_id +location_id +name +status +``` + +Exact scheduling rules are deferred to the healthcare vertical. + +## 49. Healthcare Encounters + +Suggested fields: + +```text +id +organization_id + +patient_id +practitioner_id +appointment_id nullable + +encounter_type + +reason_for_visit nullable + +started_at +ended_at + +status + +created_at +updated_at +version +``` + +Do not add a generic free-form `notes` field as a substitute for clinical records. + +Clinical narrative belongs in governed clinical-record structures. + +## 50. Clinical Records + +Use: + +```text +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +``` + +### Clinical Record + +```text +id +organization_id +patient_id +encounter_id +author_practitioner_id + +record_type +sensitivity_level +status + +signed_by_practitioner_id +signed_at + +created_at +updated_at +version +``` + +### Clinical Record Version + +```text +id +organization_id +record_id +version_number + +content_reference or governed content payload +created_by_practitioner_id +created_at +``` + +### Clinical Record Amendment + +```text +id +organization_id +record_id +source_version_id +result_version_id + +amended_by_practitioner_id + +amendment_type +amendment_reason + +created_at +``` + +Possible amendment types: + +```text +correction +addendum +clarification +``` + +Signed/finalized history is preserved. + +REST: + +```http +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} +PATCH /api/v1/healthcare/clinical-records/{recordId} +# Draft/editable only. + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend + +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log +``` + +## 51. Documents + +Shared document infrastructure: + +```text +documents +document_versions +document_categories +retention_policies +``` + +Binary data lives in S3-compatible object storage. + +### Document + +```text +id +organization_id +name +category_id +classification +retention_policy_id +current_version_id +created_by_user_id +created_at +updated_at +``` + +Classification: + +```text +public +internal +confidential +restricted +regulated +``` + +### Document Version + +```text +id +organization_id +document_id +version_number +storage_key +mime_type +size_bytes +content_hash +hash_algorithm +uploaded_by_user_id +created_at +``` + +Checksum is version-level authoritative data. + +### Document Category + +```text +id +organization_id +profession nullable +name +parent_category_id +created_at +``` + +Uniqueness requirement: + +```text +shared category: + unique organization_id + name where profession IS NULL + +profession category: + unique organization_id + profession + name where profession IS NOT NULL +``` + +Implementation options: + +```text +PostgreSQL null-aware unique constraint when supported +or +two partial unique indexes +``` + +The partial-index fallback does not depend on selecting PostgreSQL 18. + +### Retention Policy + +```text +id +organization_id + +name +profession nullable +classification nullable + +retention_period_days nullable +action + +created_at +updated_at +``` + +Initial actions: + +```text +review +archive +delete_when_legally_permitted +retain_indefinitely +``` + +`retention_period_days` has one meaning only: + +```text +action = retain_indefinitely + → retention_period_days MUST be null + +all other actions + → retention_period_days MUST be a positive integer +``` + +Null does not mean inherit, unconfigured, or unknown. Policy inheritance or an unconfigured state must be represented outside a persisted retention-policy row and specified separately before implementation. + +A retention policy describes configured behavior. + +Actual deletion remains subject to domain, contractual, privacy, and jurisdiction requirements. + +### Metadata + +JSONB is allowed only for genuinely extensible, non-authoritative metadata. + +Do not put authorization, lifecycle, retention state, or ownership into arbitrary JSON. + +## 52. Document Upload Flow + +### Standard Upload + +Request: + +```http +POST /api/v1/documents/upload-url +``` + +```json +{ + "name": "structural-calculations.pdf", + "categoryId": null, + "classification": "confidential", + "mimeType": "application/pdf", + "sizeBytes": 2457600, + "contentHash": "sha256:..." +} +``` + +Response: + +```json +{ + "data": { + "documentId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d01", + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d02", + "uploadUrl": "https://object-storage.example/...", + "expiresAt": "2026-08-26T13:00:00Z" + } +} +``` + +The frontend uploads directly to object storage. + +Finalize: + +```http +POST /api/v1/documents/{documentId}/complete-upload +``` + +```json +{ + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d02", + "contentHash": "sha256:..." +} +``` + +### Multipart Initialization + +```http +POST /api/v1/documents/multipart-uploads +``` + +Request: + +```json +{ + "name": "building-model.bin", + "categoryId": null, + "classification": "confidential", + "mimeType": "application/octet-stream", + "sizeBytes": 2147483648, + "contentHash": null +} +``` + +Response: + +```json +{ + "data": { + "documentId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d10", + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d11", + "uploadId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d12", + "recommendedPartSizeBytes": 67108864, + "expiresAt": "2026-08-27T12:00:00Z" + } +} +``` + +### Request Signed Part URLs + +```http +POST /api/v1/documents/{documentId}/multipart-uploads/{uploadId}/parts +``` + +```json +{ + "partNumbers": [1, 2, 3, 4] +} +``` + +Response: + +```json +{ + "data": [ + { + "partNumber": 1, + "uploadUrl": "https://object-storage.example/..." + } + ] +} +``` + +Binary parts go directly to object storage. + +### Complete Multipart Upload + +```http +POST /api/v1/documents/{documentId}/multipart-uploads/{uploadId}/complete +``` + +```json +{ + "parts": [ + { + "partNumber": 1, + "etag": "..." + } + ], + "contentHash": "sha256:..." +} +``` + +Abort: + +```http +DELETE /api/v1/documents/{documentId}/multipart-uploads/{uploadId} +``` + +Upload state: + +```text +initiated +uploading +completing +completed +aborted +expired +``` + +Workers clean up abandoned multipart uploads. + +Upload policy validates: + +```text +declared MIME +extension +content signature +size +checksum +quota +classification +malware status +``` + +## 53. Profession-Specific Document Links + +Use explicit relationship tables. + +Engineering: + +```text +engineering_project_documents +engineering_design_version_documents +engineering_inspection_documents +``` + +Legal: + +```text +legal_matter_documents +legal_case_documents +``` + +Healthcare: + +```text +healthcare_patient_documents +healthcare_encounter_documents +``` + +`engineering_design_version_documents` is authoritative for files belonging to a specific design revision. + +Do not also maintain an ambiguous `engineering_design_documents` relation to the unversioned design unless a later requirement introduces a separate clearly named supporting-document relationship. + +### Project Documents + +```text +engineering_project_documents +├── id +├── organization_id +├── project_id +├── document_id +├── category +├── linked_by_user_id +├── linked_at +└── unlinked_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/documents +POST /api/v1/engineering/projects/{projectId}/documents +DELETE /api/v1/engineering/project-documents/{documentLinkId} +``` + +`DELETE` temporally unlinks the relation when history must be preserved. + +## 54. Billing + +Shared financial core: + +```text +invoices +invoice_items +payments +``` + +### Invoice + +Core fields include: + +```text +id +organization_id +client/reference context +invoice_number +status +currency_code +subtotal_minor +tax_total_minor +total_minor +issued_at +due_at +paid_at +created_at +updated_at +version +``` + +### Invoice Item + +```text +id +organization_id +invoice_id + +description + +quantity +unit_price_minor +total_amount_minor + +position + +created_at +updated_at +``` + +`quantity` uses fixed-precision numeric semantics, not floating point. + +Money uses integer minor units. + +The invoice determines currency; invoice items do not independently choose a different currency unless multi-currency invoicing is intentionally designed later. + +### Profession-Specific Source Links + +The shared billing module does not use unconstrained: + +```text +reference_type +reference_id +``` + +to profession-owned tables. + +Profession modules create explicit links, for example: + +```text +engineering_invoice_item_time_entries +├── organization_id +├── invoice_item_id +└── time_entry_id +``` + +This preserves the rule that shared core does not depend on profession-table internals. + +REST: + +```http +POST /api/v1/invoices +GET /api/v1/invoices +GET /api/v1/invoices/{invoiceId} +PATCH /api/v1/invoices/{invoiceId} + +POST /api/v1/invoices/{invoiceId}/issue +POST /api/v1/invoices/{invoiceId}/void +POST /api/v1/invoices/{invoiceId}/payments +POST /api/v1/payments/{paymentId}/refund +``` + +## 55. Money Representation + +Use integer minor units: + +```json +{ + "amountMinor": 12550, + "currency": "USD" +} +``` + +Meaning: + +```text +$125.50 +``` + +Never use floating point for money. + +--- + +## 56. Audit Logging + +Use: + +```text +audit_events +``` + +Suggested fields: + +```text +id +organization_id + +actor_type +actor_user_id nullable +actor_service_account_id nullable + +action + +resource_type +resource_id + +request_id +correlation_id + +ip_address +user_agent + +metadata + +occurred_at +``` + +Audit records are append-only from normal application workflows. + +### Mandatory Examples + +Engineering: + +```text +engineering.projects.create +engineering.projects.close +engineering.designs.approve +engineering.inspections.complete +``` + +Legal: + +```text +legal.matters.create +legal.matters.close +legal.conflicts.approve +``` + +Healthcare: + +```text +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +``` + +### Privacy / Erasure Handling + +Append-only audit does not mean "store unlimited personal data forever." + +Audit metadata must be minimized at write time. + +Where privacy, contractual, or retention obligations require removal of personally identifying material, use a governed privacy process such as: + +```text +pseudonymize actor references +null/remove nonessential PII fields +replace identifiers with irreversible privacy references where appropriate +retain the security/business event itself when permitted/required +``` + +The exact action depends on jurisdiction and retention policy and must be reviewed before healthcare/legal production. + +Do not place passwords, tokens, full clinical content, secret keys, or unnecessary payment data in audit metadata. + +REST: + +```http +GET /api/v1/audit-events +``` + +No public mutation endpoints. + +## 57. Domain Events and Transactional Outbox + +Use: + +```text +outbox_events +``` + +Fields: + +```text +id +organization_id nullable for truly global events + +event_type +aggregate_type +aggregate_id + +payload + +request_id nullable +correlation_id +causation_id nullable + +occurred_at +available_at +processed_at + +attempt_count +last_error +dead_lettered_at +``` + +`correlation_id` groups one logical workflow across requests/jobs/events. + +`causation_id` identifies the event/command that directly caused this event when applicable. + +Transaction: + +```text +BEGIN +business change +audit event +outbox event +COMMIT +``` + +Delivery semantics are at-least-once. + +Worker claim uses row locking such as: + +```sql +SELECT id +FROM outbox_events +WHERE processed_at IS NULL + AND dead_lettered_at IS NULL + AND available_at <= now() +ORDER BY occurred_at +FOR UPDATE SKIP LOCKED +LIMIT 100; +``` + +Every external side-effect consumer must be idempotent. + +`FOR UPDATE SKIP LOCKED` prevents simultaneous claiming; it does not prevent duplicate side effects after a worker crash. + +## 57A. Webhooks and External Integrations + +Shared tables: + +```text +webhooks +webhook_event_subscriptions +webhook_deliveries +``` + +### Webhook + +```text +id +organization_id +url +status +secret_ciphertext or signing_key_reference +created_by_user_id +created_at +updated_at +``` + +### Subscription + +```text +id +organization_id +webhook_id +event_type +created_at +``` + +Unique: + +```text +(organization_id, webhook_id, event_type) +``` + +Only registered externally publishable event types may be subscribed. + +### Delivery + +```text +id +organization_id +webhook_id +event_id + +attempt_number +request_timestamp +response_status +response_summary + +delivered_at +failed_at +next_attempt_at +``` + +`event_id` references `outbox_events.id`. Because webhook deliveries are tenant-owned, only publishable outbox events with the same non-null `organization_id` may be delivered: + +```text +(organization_id, event_id) +→ outbox_events(organization_id, id) +``` + +The webhook publisher allowlists externally publishable `event_type` values before creating delivery records. The stable outbox event ID is also the consumer deduplication key. + +Configuration REST: + +```http +GET /api/v1/webhooks +POST /api/v1/webhooks +GET /api/v1/webhooks/{webhookId} +PATCH /api/v1/webhooks/{webhookId} +DELETE /api/v1/webhooks/{webhookId} + +POST /api/v1/webhooks/{webhookId}/test +POST /api/v1/webhooks/{webhookId}/rotate-secret +``` + +Delivery REST: + +```http +GET /api/v1/webhook-deliveries +GET /api/v1/webhook-deliveries/{deliveryId} +POST /api/v1/webhook-deliveries/{deliveryId}/retry +``` + +If HMAC signing is used, signing material is encrypted/recoverable with managed key protection. + +A one-way secret hash is insufficient for outbound HMAC signing. + +Webhook consumers deduplicate using stable event IDs. + +## 58. Background Jobs + +Workers handle: + +```text +notifications +reports/PDFs +file scanning +document processing +imports +exports +bulk operations +webhooks +search indexing +large data operations +``` + +Use shared tenant-owned: + +```text +jobs +``` + +Fields: + +```text +id +organization_id +requested_by_user_id + +job_type +status + +input_reference +result_reference +progress_percent + +created_at +started_at +completed_at +failed_at + +error_code +error_summary +``` + +`input_reference` and `result_reference` are nullable typed JSONB reference envelopes, not arbitrary blobs or public URLs. Their schema is registered per `job_type`. + +Allowed reference kinds initially include: + +```text +document_version +object_storage_key +query_snapshot +job +``` + +Object-storage references contain internal storage keys; APIs generate time-limited signed URLs when access is authorized. Resource IDs inside an envelope are validated for tenant ownership when the job is created. Large inputs and outputs live in documents or object storage rather than inside the job row. + +States: + +```text +queued +running +completed +failed +cancelled +``` + +REST: + +```http +GET /api/v1/jobs/{jobId} +GET /api/v1/jobs/{jobId}/result +POST /api/v1/jobs/{jobId}/cancel +``` + +These endpoints are tenant-scoped through the standard: + +```http +X-Organization-Id +``` + +They do not need `/organizations/{id}/jobs` because the platform already chose header-based tenant context. + +Large import/export operations return: + +```http +202 Accepted +``` + +with a job ID. + +## 59. Redis + +Use Redis as an acceleration and coordination layer, not the authoritative system of record. + +Appropriate uses: + +```text +job queue +rate-limit counters +short-lived authorization caches +organization configuration cache +session lookup acceleration +idempotency lookup acceleration +distributed locks when justified +``` + +### Cache Layers + +L1 optional application-memory cache: + +```text +static permission definitions +non-sensitive configuration +``` + +L2 Redis shared cache: + +```text +organization settings +membership snapshots +role permission snapshots +rate-limit counters +session lookup cache +recent idempotency lookups +``` + +CDN: + +```text +frontend static assets +explicitly public assets only +``` + +Do not cache private professional API responses at a CDN by default. + +### Cache Invalidation + +Invalidate or version caches when: + +```text +membership changes +role permissions change +organization settings change +professional credentials change +session is revoked +profession module enablement changes +``` + +High-risk authorization decisions must not depend solely on stale cached credential state. + +### Idempotency Durability + +Redis may improve idempotency lookup latency, but PostgreSQL remains authoritative for high-risk commands. + +## 60. Pagination + +Use cursor pagination. + +Defaults: + +```text +default limit = 25 +maximum limit = 100 +offset pagination = not supported +``` + +Example: + +```http +GET /api/v1/engineering/projects?limit=25 +``` + +Response: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Rules: + +```text +cursor is opaque +sort order must be deterministic +cursor encodes/represents the selected sort position +unsupported limits return validation errors rather than silent huge responses +``` + +## 61. Filtering + +Use explicit resource-specific filters. + +Examples: + +```http +GET /api/v1/engineering/projects?status=active&discipline=structural +GET /api/v1/engineering/tasks?status=todo&assignedToUserId=0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d +``` + +Do not build a generic query DSL in v1. + +--- + +## 62. Sorting + +Examples: + +```http +GET /api/v1/engineering/projects?sort=createdAt +GET /api/v1/engineering/projects?sort=-createdAt +``` + +Only explicitly supported fields may be sorted. + +--- + +## 63. Search + +Start with PostgreSQL search. + +Engineering search may cover: + +```text +project number +project name +client name +``` + +Legal: + +```text +matter number +client +case number +``` + +Healthcare: + +```text +patient number +patient identity +``` + +Healthcare search requires stricter privacy and authorization controls. + +Potential PostgreSQL capabilities: + +- B-tree indexes for exact/filter queries +- PostgreSQL full-text search where appropriate +- `pg_trgm` only when fuzzy search requirements justify it + +Do not introduce Elasticsearch/OpenSearch until real query volume, relevance requirements, or indexing features justify another distributed system. + +Do not create every conceivable search index on day one. Indexes cost memory, storage, and write performance. + +## 64. Optimistic Concurrency + +Important mutable resources should use a version field. + +Example: + +```json +{ + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c5d", + "version": 6 +} +``` + +Update: + +```json +{ + "version": 6, + "name": "Central Tower Phase II" +} +``` + +If the current database version differs: + +```text +409 CONCURRENT_MODIFICATION +``` + +--- + +## 65. Domain-Oriented REST + +Important state transitions use explicit command endpoints. + +Good: + +```http +POST /engineering/projects/{id}/close +POST /engineering/designs/{id}/approve +POST /engineering/tasks/{id}/complete +POST /engineering/inspections/{id}/complete +POST /invoices/{id}/issue +``` + +Avoid: + +```http +PATCH /resource/{id} +{ + "status": "approved" +} +``` + +when the change has significant rules or side effects. + +--- + +## 66. Transaction Boundaries + +Create project: + +```text +BEGIN + +create project +assign project manager +write audit event +write outbox event + +COMMIT +``` + +Approve design: + +```text +BEGIN + +validate permission +validate project access +validate credentials +validate design state +create review result +mark approved +write audit event +write outbox event + +COMMIT +``` + +--- + +## 67. Request Context + +Every authenticated request should resolve: + +```text +RequestContext +{ + requestId + userId + sessionId + organizationId + membershipId + permissions +} +``` + +Profession modules consume this context. + +--- + +## 68. Request IDs + +Every request has: + +```http +X-Request-Id +``` + +If missing, the server generates one. + +Use it in: + +- logs +- audit context +- error diagnostics +- asynchronous correlation + +--- + +## 69. OpenAPI + +Maintain: + +```text +openapi.yaml +``` + +Use OpenAPI 3.1. + +Production server example: + +```yaml +servers: + - url: https://api.example.com/api/v1 +``` + +The server URL and path definitions must remain consistent with the platform base path. + +OpenAPI defines: + +- routes +- request DTOs +- response DTOs +- security schemes +- organization header +- request IDs +- idempotency header +- pagination +- filters +- error schemas +- examples +- profession tags + +Security scheme: + +```yaml +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +``` + +Reusable headers/parameters: + +```text +X-Organization-Id +X-Request-Id +Idempotency-Key +limit +cursor +``` + +CI must validate the OpenAPI document. + +Contract tests should detect drift between implementation and specification. + +Generated clients may be used by the separate frontends, but generated transport code should not dictate frontend domain architecture. + +## 70. DTO Rule + +Database models are not public API contracts. + +Use: + +```text +Request DTO +Response DTO +``` + +A database migration should not accidentally change the public API. + +--- + +## 71. Backend Module Structure + +Recommended: + +```text +src/ +├── core/ +│ ├── auth/ +│ ├── organizations/ +│ ├── memberships/ +│ ├── authorization/ +│ ├── documents/ +│ ├── billing/ +│ ├── audit/ +│ └── events/ +│ +├── engineering/ +│ ├── clients/ +│ ├── projects/ +│ ├── project-members/ +│ ├── phases/ +│ ├── sites/ +│ ├── tasks/ +│ ├── designs/ +│ ├── inspections/ +│ └── specifications/ +│ +├── legal/ +│ ├── clients/ +│ ├── matters/ +│ ├── cases/ +│ ├── hearings/ +│ ├── conflicts/ +│ └── retainers/ +│ +└── healthcare/ + ├── patients/ + ├── practitioners/ + ├── appointments/ + ├── encounters/ + ├── records/ + └── prescriptions/ +``` + +--- + +## 72. Internal Module Structure + +Example: + +```text +projects/ +├── domain/ +│ ├── project.entity.ts +│ ├── project-status.ts +│ └── project.errors.ts +│ +├── application/ +│ ├── commands/ +│ │ ├── create-project.ts +│ │ ├── update-project.ts +│ │ └── close-project.ts +│ │ +│ └── queries/ +│ ├── get-project.ts +│ └── list-projects.ts +│ +├── infrastructure/ +│ └── project.repository.ts +│ +└── api/ + ├── project.controller.ts + ├── project.request.ts + └── project.response.ts +``` + +--- + +## 73. Controllers + +Controllers should handle: + +```text +HTTP +authentication context +input DTO parsing +application command/query invocation +response mapping +``` + +Controllers should not contain: + +```text +business rules +raw SQL +role logic +transaction orchestration +email sending +audit implementation +``` + +--- + +## 74. Commands and Queries + +Mutations use commands. + +Examples: + +```text +CreateEngineeringProjectCommand +ApproveEngineeringDesignCommand +CloseLegalMatterCommand +CompleteHealthcareEncounterCommand +``` + +Reads use queries. + +Examples: + +```text +GetEngineeringProjectQuery +ListLegalMattersQuery +GetHealthcarePatientQuery +``` + +--- + +## 75. Repositories + +Use domain-specific repositories. + +Examples: + +```text +EngineeringProjectRepository +LegalMatterRepository +HealthcarePatientRepository +``` + +Avoid one massive generic repository abstraction that eventually needs dozens of flags. + +--- + +## 76. Security Baseline + +Minimum controls: + +```text +TLS everywhere +strong password hashing +short-lived access tokens +refresh-token rotation/reuse detection +server-side session revocation + +rate limiting +anti-automation controls + +RBAC +resource policies +credential-aware authorization +tenant isolation + +input validation +SQL injection protection + +signed object-storage URLs +file-content validation +malware scanning + +audit trails +secret management +encryption at rest + +dependency/image scanning + +request/correlation IDs +backup and restore testing +``` + +### Web Security / CORS + +ADR-009 defines environment-specific web security. + +Baseline requirements: + +```text +explicit CORS allowlist +no wildcard credentialed CORS +allowed methods/headers documented +preflight behavior tested +HSTS at the edge for production HTTPS +X-Content-Type-Options: nosniff +secure cookie attributes when cookies are used +CSP on browser frontends +frame-ancestor/clickjacking policy on frontends +referrer policy appropriate to the frontend +``` + +Security headers belong at the appropriate application/CDN/gateway layer. + +### Rate Limiting + +Policies are endpoint-specific and configurable. + +Return: + +```http +429 Too Many Requests +Retry-After: ... +``` + +### Secrets + +Production secrets live outside source control, preferably in managed secret/key systems. + +JWT signing keys support rotation. + +## 77. Data Classification + +Suggested classes: + +### Public + +```text +marketing configuration +``` + +### Internal + +```text +organization settings +tasks +``` + +### Confidential + +```text +engineering documents +legal matters +billing +``` + +### Highly Sensitive + +```text +clinical records +professional credentials +authentication secrets +``` + +--- + +## 78. Healthcare Security + +Before healthcare production use, define: + +```text +privacy model +minimum-necessary access model +clinical access policies +break-glass/emergency access policy if required +audit policy +record-signing policy +amendment policy +retention policy +credential policy +scope-of-practice policy +jurisdiction requirements +encryption strategy +consent requirements +data residency requirements +backup/restore handling +export/portability requirements +breach-response requirements +``` + +Healthcare is a stricter security tier. + +Key rules: + +1. default patient responses do not contain all available PHI +2. clinical record reads may be auditable events +3. signed records are immutable except through explicit amendment/version workflows +4. prescribing authorization is jurisdiction-specific +5. privileged clinical commands revalidate professional authority +6. caches must not allow revoked credentials to remain effective for high-risk writes +7. healthcare search results themselves are protected data +8. access logs may require dedicated permissions +9. do not claim regulatory compliance from architecture alone + +## 79. Observability + +Use: + +```text +structured logs +metrics +distributed tracing +request IDs +correlation IDs +``` + +Recommended: + +```text +OpenTelemetry +``` + +### Core Metrics + +API: + +```text +api_requests_total +api_errors_total +api_request_duration_seconds +``` + +Authentication: + +```text +auth_login_attempts_total +auth_token_refresh_total +auth_refresh_reuse_detections_total +auth_sessions_revoked_total +``` + +Authorization/security: + +```text +cross_tenant_access_attempts_total +tenant_isolation_invariant_failures_total +authorization_denials_total +credential_policy_denials_total +rate_limit_events_total +``` + +Important distinction: + +```text +cross_tenant_access_attempt += +request attempted another tenant's resource +``` + +This may be a stale link, mistake, or attack. + +```text +tenant_isolation_invariant_failure += +our system nearly or actually created/returned cross-tenant data +``` + +That is a high-severity internal correctness/security incident. + +Outbox/jobs/webhooks: + +```text +outbox_events_pending +outbox_events_failed_total +outbox_processing_duration_seconds + +jobs_queued +jobs_failed_total +job_duration_seconds + +webhook_delivery_attempts_total +webhook_delivery_failures_total +webhook_delivery_latency_seconds +``` + +Database: + +```text +db_pool_active +db_pool_waiting +db_query_duration_seconds +db_transaction_duration_seconds +``` + +Business metrics may include: + +```text +engineering_projects_created_total +engineering_designs_approved_total +engineering_inspections_completed_total +invoices_issued_total +``` + +Avoid patient-specific or sensitive identifiers in metric labels. + +### Alerts + +Examples: + +```text +refresh token reuse detected +tenant isolation invariant failure +outbox backlog exceeds SLO +webhook failure spike +database pool saturation +error-rate spike +latency regression +backup failure +malware scanner unavailable +``` + +Thresholds are calibrated from real environments rather than copied from a review document. + +### SLOs + +Define by endpoint class. + +Interactive CRUD, reports, file orchestration, and background jobs should not share one arbitrary latency target. + +## 80. Logging + +Useful fields: + +```text +request_id +route +method +status +duration +user_id when appropriate +organization_id when appropriate +``` + +Never log: + +```text +passwords +tokens +clinical record text +full sensitive documents +payment secrets +``` + +--- + +## 81. Testing Strategy + +### Unit Tests + +Test: + +```text +domain rules +state transitions +authorization policies +credential policies +money calculations +idempotency request hashing +``` + +### Property-Based Tests + +Use property-based testing for high-value domain state machines. + +Candidates: + +```text +engineering design lifecycle +engineering inspection lifecycle +invoice lifecycle +payment state transitions +membership/role invariants +``` + +Correct properties: + +```text +every successful transition ends in a valid state + +every forbidden transition is rejected + +terminal states reject prohibited actions + +required invariants survive every valid transition + +transition sequences never bypass required approval/credential rules +``` + +Do not assert that every random state/action pair succeeds. Many are supposed to fail. + +### Integration Tests + +Test: + +```text +repositories +tenant-aware foreign keys +PostgreSQL constraints +transactions +outbox persistence +idempotency persistence +cache invalidation +job persistence +webhook delivery persistence +``` + +### API Tests + +Every important endpoint covers: + +```text +happy path +request validation +authentication +organization context +permission denial +scope denial +credential denial where relevant +cross-tenant access +concurrent modification +invalid state transition +idempotent replay +idempotency conflict +audit creation +outbox creation +``` + +### Outbox Reliability / Chaos Tests + +Test: + +```text +worker crash before side effect +worker crash after side effect but before marking processed +two workers competing for same row +temporary dependency outage +retry/backoff behavior +dead-letter behavior +consumer idempotency +lost worker wake-up +replay +``` + +The dangerous scenario is: + +```text +external side effect succeeds +worker dies +event retries +``` + +Tests must prove the consumer does not create an unacceptable duplicate. + +### Tenant Security Tests + +Test both: + +```text +external cross-tenant access attempts +``` + +and: + +```text +internal cross-tenant data invariant failures +``` + +These are different classes of failure. + +### Performance Tests + +Create realistic profiles: + +```text +interactive reads +interactive writes +search +dashboard read models +reporting +file upload orchestration +outbox processing +webhook bursts +notification bursts +``` + +Measure: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +``` + +Set production SLO gates only after a realistic baseline exists. + +### Coverage + +Track code coverage. + +Do not treat a single percentage such as `90%` as proof of quality. + +Critical-path expectations are stronger: + +```text +all tenant-isolation paths tested +all financial commands tested +all regulated commands tested +all state transitions tested +all critical authorization policies tested +``` + +## 82. Tenant Security Tests + +For every major resource, attempt: + +```text +Organization A resource +using Organization B context +``` + +Test: + +```text +read +update +delete/action +list filtering +search +documents +``` + +Expected result: + +```text +404 / denied +``` + +--- + +## 83. Engineering MVP + +Engineering is the first vertical. + +Initial features: + +```text +Authentication +Organization management +Users / memberships / roles +Engineering clients +Projects +Project members +Project phases +Tasks +Sites +Documents +Basic design records +Inspections +Time entries +Basic billing +Audit history +``` + +Do not initially build: + +```text +advanced CAD integration +BIM integration +full document markup +advanced resource planning +procurement +complex accounting +AI design analysis +IoT integrations +``` + +--- + +## 84. Engineering MVP Workflow + +```text +User registers + ↓ +Creates engineering organization + ↓ +Invites engineer + ↓ +Assigns role + ↓ +Creates client + ↓ +Creates project + ↓ +Assigns project team + ↓ +Creates project phases + ↓ +Creates tasks + ↓ +Uploads documents + ↓ +Creates design + ↓ +Reviews / approves design + ↓ +Schedules inspection + ↓ +Records inspection findings + ↓ +Records engineering time + ↓ +Creates invoice + ↓ +Records payment + ↓ +Closes project + ↓ +Audit history contains lifecycle +``` + +--- + +## 85. Development Phases + +### Phase 0: Architecture Foundation + +Deliver: + +```text +domain boundaries +database conventions +REST conventions +authorization model +session/token model +idempotency strategy +error taxonomy +OpenAPI skeleton +engineering state machines +migration conventions +threat model +initial ADRs +risk register +``` + +### Phase 1: Shared Platform Core + +Build: + +```text +auth +sessions +refresh-token families +token rotation/revocation + +users +organizations +organization professions + +membership invitations +memberships +roles +permissions +authorization + +audit +outbox + +request context +idempotency +rate limiting +observability +``` + +### Phase 2: Engineering CRM + +Build: + +```text +engineering clients +engineering client contacts +client archive/restore +``` + +### Phase 3: Engineering Projects + +Build: + +```text +projects +project members +project phases +activation/close/archive +``` + +### Phase 4: Work and Site Management + +Build: + +```text +tasks +task batch operations +sites +``` + +### Phase 5: Documents + +Build: + +```text +documents +versions +categories +classification +retention references +signed uploads +multipart uploads +content verification +malware scanning +engineering document links +``` + +### Phase 6: Engineering Designs + +Build: + +```text +designs +assignments +versions +reviews +cancel/withdraw semantics +credential-aware approval +audit +outbox +idempotency +``` + +### Phase 7: Engineering Inspections + +Build: + +```text +inspection lifecycle +inspection outcome +findings +corrective work +follow-up inspections +attachments +audit +outbox +idempotency +``` + +### Phase 8: Time, Budgets, and Billing + +Build: + +```text +time entries +batch timesheet submission +project budgets when required +invoices +payments +financial idempotency +reconciliation +``` + +### Phase 9: Notifications, Jobs, and Webhooks + +Build: + +```text +notifications +email +async jobs +imports/exports +webhooks +delivery/retry +dead-letter handling +``` + +### Phase 10: Reporting and Search + +Build: + +```text +project status +overdue work +inspection status +billable time +revenue +outstanding invoices +dashboard read models +``` + +### Phase 11: Engineering Client Portal + +Build: + +```text +portal account invitations +external project grants +published project documents +client review/acceptance workflow +portal audit +portal-specific frontend +``` + +Do not expose professional approval actions to client portal accounts. + +### Phase 12: Legal Vertical + +Validate shared core against: + +```text +matters +cases +conflicts +deadlines +retainers +restricted access / ethical walls +``` + +### Phase 13: Healthcare Readiness and Vertical + +Before implementation: + +```text +healthcare threat model +privacy review +jurisdiction analysis +scope-of-practice policy +record signing/amendment model +retention model +audit requirements +``` + +### Estimation Rule + +These are dependency-ordered milestones. + +They are not calendar promises. + +Calendar estimates require: + +```text +team size +frontend/UX scope +cloud decisions +third-party providers +security requirements +QA capacity +domain-expert availability +``` + +## 86. Legal Expansion + +Only after engineering proves the shared platform assumptions. + +Build: + +```text +Legal Client + ↓ +Matter + ↓ +Case + ↓ +Hearings / Deadlines / Documents +``` + +Do not redesign engineering around legal terminology. + +Extract only genuinely reusable infrastructure. + +--- + +## 87. Healthcare Expansion + +Healthcare comes after: + +- core platform is stable +- audit model is proven +- permission model is proven +- tenant isolation is tested +- retention and encryption strategies are defined + +Healthcare should be treated as its own security and compliance workstream. + +--- + +## 88. Deployment Environments + +Use: + +```text +development +testing +staging +production +``` + +Each environment has independent: + +```text +database +object storage +secrets +queues +API keys +``` + +--- + +## 89. Initial Deployment Architecture + +```text +CDN + │ + ├── Engineering Web + ├── Legal Web + └── Healthcare Web + +Load Balancer + │ + Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Queue + │ + Workers +``` + +Prefer managed infrastructure where practical. + +--- + +## 90. Backup Strategy + +Database: + +```text +automated backups +point-in-time recovery +tested restores +``` + +Object storage: + +```text +versioning +retention policies +backup or replication where required +``` + +A backup strategy is incomplete until restoration is tested. + +--- + +## 91. Migration Strategy + +Use explicit immutable migration files. + +Recommended naming: + +```text +YYYYMMDDHHMMSS_description.sql +``` + +Example: + +```text +20260826010000_create_organizations.sql +20260826011000_create_users.sql +20260826012000_create_memberships.sql +20260826013000_create_rbac.sql +20260826014000_create_audit_outbox.sql +20260826015000_create_engineering_clients.sql +``` + +### UUID Standard + +The platform uses UUIDv7. + +Supported implementation choices: + +```text +PostgreSQL 18+: + use native uuidv7() if database-generated identifiers are desired + +Earlier PostgreSQL: + generate UUIDv7 in the application or use a controlled extension +``` + +Database columns remain PostgreSQL `UUID`. + +The rule is consistency, not ideological loyalty to one generation layer. + +Do not silently fall back to UUIDv4 while documenting UUIDv7. + +### Production Migration Rules + +Use expand/contract: + +```text +1. add backward-compatible schema +2. deploy code supporting old + new schema +3. backfill/migrate +4. switch reads/writes +5. observe +6. remove obsolete schema later +``` + +For destructive changes: + +```text +backup/restore plan +compatibility window +production-like dry run +explicit approval +post-migration verification +``` + +Do not assume a destructive database migration can always be reversed by a simple down migration. + +Never use automatic ORM schema synchronization in production. + +## 91A. Architecture Decision Records + +v4 stops treating technology suggestions as automatically settled architecture. + +Create ADRs before implementation locks in: + +```text +ADR-001 Backend Framework +ADR-002 SQL / ORM / Query Layer +ADR-003 Queue Implementation +ADR-004 PostgreSQL Minimum Version +ADR-005 Error Format / RFC 9457 Compatibility +ADR-006 Rate-Limit Header Convention +ADR-007 Webhook Signing Strategy +ADR-008 Object Storage Provider / Multipart Strategy +ADR-009 Web Security / CORS / Browser Headers +ADR-010 Machine Authentication / API Key Policy +``` + +Each ADR should include: + +```text +context +decision +alternatives considered +tradeoffs +security impact +operational impact +migration/exit path +date +status +``` + +The architecture currently fixes capabilities and boundaries. + +It does not require a framework merely because a review document described it positively. + +--- + +## 92. Technology Recommendation + +The following are preferred candidates, not all final decisions. + +### Fixed Platform Choices + +```text +API style: REST +Contract: OpenAPI 3.1 +Primary language: TypeScript +Primary database: PostgreSQL +Architecture: Modular Monolith +Observability standard: OpenTelemetry +Object storage model: S3-compatible +Container model: Docker/OCI +``` + +### ADR-Gated Choices + +Backend framework candidates: + +```text +NestJS +Fastify-centered custom application structure +``` + +SQL / persistence candidates: + +```text +Drizzle +Kysely +Prisma +direct SQL for specialized queries +``` + +Queue candidates: + +```text +BullMQ / Redis +managed cloud queue +``` + +PostgreSQL baseline: + +```text +PostgreSQL 18+ +``` + +is attractive because of native UUIDv7 and current capabilities, but the minimum supported version must be confirmed against: + +```text +hosting provider availability +operations policy +extension requirements +upgrade policy +support lifecycle +``` + +Do not claim one ORM is categorically "faster" or "better" without workload-specific evidence. + +The selected stack should preserve: + +```text +transaction control +explicit SQL visibility +tenant-safe query design +migration control +observability +testability +``` + +## 93. REST API Milestones + +### Milestone 1: Platform Access and Security + +```http +POST /auth/register +POST /auth/login + +POST /auth/token/refresh +POST /auth/token/revoke +POST /auth/token/revoke-all + +GET /auth/sessions +DELETE /auth/sessions/{sessionId} + +GET /me + +POST /organizations +GET /me/organizations + +POST /membership-invitations +GET /memberships + +GET /roles +POST /roles +GET /permissions +``` + +Includes: + +```text +explicit organization context +session revocation +refresh-token reuse detection +audit foundation +outbox foundation +idempotency foundation +rate limiting +``` + +### Milestone 2: Engineering Clients + +```http +GET /engineering/clients +POST /engineering/clients +GET /engineering/clients/{id} +PATCH /engineering/clients/{id} +POST /engineering/clients/{id}/archive +POST /engineering/clients/{id}/restore +GET /engineering/clients/{id}/projects +``` + +### Milestone 3: Engineering Projects + +```http +GET /engineering/projects +POST /engineering/projects +GET /engineering/projects/{id} +PATCH /engineering/projects/{id} + +POST /engineering/projects/{id}/activate +POST /engineering/projects/{id}/close +POST /engineering/projects/{id}/archive + +GET /engineering/projects/{id}/summary +``` + +Timeline and budget read models follow when the frontend requires them. + +### Milestone 4: Collaboration + +```http +POST /engineering/projects/{id}/members +GET /engineering/projects/{id}/members + +POST /engineering/tasks +GET /engineering/tasks +POST /engineering/tasks/{id}/complete +``` + +### Milestone 5: Sites and Documents + +Build: + +```text +engineering sites +signed file uploads +document versions +malware scanning +project document links +``` + +### Milestone 6: Designs + +Build: + +```text +design lifecycle +versions +reviews +submit-review +request-changes +approve +reject +supersede +credential validation +audit + outbox + idempotency +``` + +### Milestone 7: Inspections + +Build: + +```text +schedule +start +complete +cancel +findings +finding resolution +audit + outbox + idempotency +``` + +### Milestone 8: Commercial Workflows + +Build: + +```text +time entries +invoices +payments +refunds +financial idempotency +reports +``` + +## 94. Architecture Rules to Freeze + +1. REST is the primary frontend and integration API. +2. Base path is `/api/v1`. +3. OpenAPI 3.1 is the public API contract. +4. GraphQL is not part of v1. +5. Start as one modular monolith backend. +6. Each profession has its own frontend. +7. Each profession owns its domain tables and state machines. +8. Shared modules provide infrastructure, not forced domain abstractions. +9. Public serialized IDs are raw UUIDv7. +10. Database ID columns use PostgreSQL UUID. +11. Human-readable business references are separate from resource IDs. +12. Every tenant-owned row carries direct `organization_id`. +13. Tenant-scoped requests require explicit `X-Organization-Id`. +14. Tenant boundaries are enforced in queries and database constraints. +15. Cross-tenant resources appear nonexistent. +16. API JSON/query parameter names use camelCase; DB identifiers use snake_case. +17. Authorization is server-side and deny-by-default. +18. `assigned` scope is defined per resource policy, never inferred generically. +19. Roles and professional credentials are separate. +20. A professional profile may own multiple credentials. +21. Sessions and refresh tokens are separate resources. +22. Refresh tokens rotate within families and support reuse detection. +23. Machine identities use service accounts/API keys, not fake human memberships. +24. Important domain transitions use explicit REST command endpoints. +25. High-risk commands use durable idempotency. +26. Batch custom actions use `/{collection}/batch/{action}`. +27. Every batch defines atomic or partial semantics. +28. Every batch item receives independent authorization/domain validation. +29. Large batches become asynchronous jobs. +30. Project phases are authoritative; duplicated project `stage` is not stored. +31. Project budgets use the dedicated budget model; project `budget_minor` is not authoritative. +32. Engineering time entries may attribute time to one explicit primary work item using tenant- and project-consistent composite foreign keys. +33. Design versions and engineering specifications use explicit document-link tables with one-to-many cardinality. +34. All design/review/version/finding/follow-up subresources carry `organization_id`. +35. Inspection lifecycle and inspection outcome are separate. +36. Inspection follow-ups are explicit resources. +37. Engineering change requests remain deferred until fully specified. +38. Shared documents own document records; profession modules own link tables. +39. Legal does not duplicate shared document ownership. +40. Large files use object-storage multipart uploads. +41. Application servers do not proxy multi-gigabyte chunks. +42. Document checksums belong to document versions. +43. Document classification is multi-level. +44. Document retention is explicit policy. +45. Document category uniqueness must work for nullable profession values on the selected PostgreSQL version. +46. Project document linkage does not imply client-portal publication. +47. External publication requires explicit publication records. +48. Client portal accounts are not internal memberships. +49. Client acceptance is not professional engineering approval. +50. Domain events use a transactional outbox. +51. Outbox delivery is at-least-once. +52. Outbox events carry correlation/causation identifiers. +53. External side-effect consumers are idempotent. +54. Webhook subscriptions and deliveries are tenant-owned. +55. HMAC signing secrets are securely recoverable/encrypted, not only hashed. +56. Jobs are tenant-scoped by the standard organization header. +57. PostgreSQL is the authoritative transactional datastore. +58. Redis is acceleration/coordination, not critical source of truth. +59. Search starts with PostgreSQL. +60. Collections use cursor pagination, default 25 and max 100. +61. Important mutable resources use optimistic concurrency. +62. Database entities are not serialized directly. +63. Errors use stable codes. +64. `429` responses use `Retry-After`; exact quota headers are an API decision. +65. Business records use explicit archive/revoke/unlink/hard-delete lifecycle policies. +66. Financial/professional/audit records are not casually hard-deleted. +67. Audit metadata is minimized and supports governed privacy transformation when required. +68. Important/regulated actions are audited. +69. Signed clinical records use sign/amend/version workflows. +70. Prescribing authority remains jurisdiction/scope-of-practice policy. +71. Production migrations use expand/contract. +72. Destructive changes are not assumed trivially reversible. +73. Secrets remain outside source control. +74. CORS and browser security policy are explicit ADR/configuration. +75. Rate limits are calibrated by evidence. +76. CI validates types, tests, OpenAPI, migrations, and security checks. +77. Property-based tests cover high-value state machines. +78. Outbox/job/webhook reliability is tested under failure/concurrency. +79. Critical-path tests matter more than vanity coverage percentages. +80. Framework/ORM/queue/PostgreSQL-minimum choices require ADRs. +81. Engineering is the first vertical. +82. Client portal follows internal Engineering MVP foundations. +83. Legal follows after Engineering validates shared assumptions. +84. Healthcare requires dedicated privacy/security/domain design before implementation. +85. Architecture documentation never equates "designed for" with "certified/compliant". + +## 95. Required Design Artifacts + +Maintain: + +```text +01_PROJECT_ARCHITECTURE.md +02_DATABASE_CONVENTIONS.md +03_AUTHORIZATION_MODEL.md +04_AUTH_SESSION_MODEL.md + +05_ENGINEERING_DOMAIN.md +06_ENGINEERING_DATABASE_SCHEMA.md +07_ENGINEERING_STATE_MACHINES.md + +08_API_CONVENTIONS.md +09_ENGINEERING_API_SPEC.md +10_OPENAPI.yaml + +11_FRONTEND_ARCHITECTURE.md +12_CLIENT_PORTAL_SECURITY_MODEL.md + +13_DOCUMENT_SECURITY_MODEL.md +14_LARGE_FILE_UPLOAD_MODEL.md + +15_WEBHOOK_INTEGRATION_MODEL.md +16_ASYNC_JOB_MODEL.md + +17_SECURITY_MODEL.md +18_DEPLOYMENT_ARCHITECTURE.md +19_OBSERVABILITY_MODEL.md +20_TESTING_STRATEGY.md + +21_ARCHITECTURE_DECISION_RECORDS/ +22_RISK_REGISTER.md +23_MVP_BACKLOG.md +``` + +Important ADRs: + +```text +backend framework +persistence/query layer +queue implementation +PostgreSQL minimum version +error format +rate-limit headers +webhook signing +object-storage provider +``` + +## 96. Recommended Implementation Order + +```text +Foundation + ↓ +Authentication + ↓ +Organizations + ↓ +Memberships + ↓ +RBAC + ↓ +Engineering Clients + ↓ +Engineering Projects + ↓ +Project Team + ↓ +Tasks + ↓ +Sites + ↓ +Documents + ↓ +Designs + ↓ +Inspections + ↓ +Time Tracking + ↓ +Billing + ↓ +Notifications + ↓ +Reports + ↓ +Legal Vertical + ↓ +Healthcare Vertical +``` + +--- + +## 97A. Database Indexing Strategy + +All tenant-owned tables need efficient tenant scoping. + +Baseline: + +```text +(organization_id, id) +``` + +Common list access often benefits from: + +```text +(organization_id, created_at) +``` + +Query-specific examples: + +```text +(organization_id, status) +(organization_id, client_id) +(organization_id, project_id) +(organization_id, assigned_to_user_id) +``` + +### Rules + +1. every index corresponds to a known query, ordering, or constraint +2. column order follows real predicates +3. validate with `EXPLAIN (ANALYZE, BUFFERS)` +4. include production-like cardinality in testing +5. measure write amplification +6. do not index every field +7. introduce trigram/full-text indexes only for actual search requirements + +Potential later tools: + +```text +covering indexes +materialized views +read replicas +table partitioning +external search +``` + +These are evidence-driven scaling mechanisms, not baseline dependencies. + +### Document Category Uniqueness + +If a nullable field such as profession participates in uniqueness: + +```text +organization_id +profession nullable +name +``` + +do not assume plain uniqueness treats NULL as one shared value. + +Use PostgreSQL-supported null-aware uniqueness or partial unique indexes according to the selected PostgreSQL version. + +--- + +## 97B. CI/CD and Deployment Gates + +Pipeline stages: + +```text +lint/typecheck + ↓ +unit tests + ↓ +integration tests + ↓ +OpenAPI validation + contract tests + ↓ +security/dependency scan + ↓ +container build + image scan + ↓ +migration compatibility check + ↓ +deploy development + ↓ +smoke tests + ↓ +deploy staging + ↓ +E2E + performance/security baseline + ↓ +manual production approval + ↓ +production deployment + ↓ +post-deploy verification +``` + +Production deployment should support: + +```text +rolling or blue/green application deployment +backward-compatible database migrations +health checks +fast application rollback +feature flags for incomplete features +observability gates +``` + +Database schema rollback is not treated as equivalent to application rollback. + + +### Feature Flags + +Feature flags used for deployment safety are operational configuration, not automatically a business database table. + +Initial implementation may use: + +```text +environment/config-service flags +``` + +for global rollout and kill switches. + +If per-organization feature rollout is later required, introduce an explicit tenant-owned model such as: + +```text +organization_feature_flags +``` + +through an ADR/migration. + +Do not overload `organization_professions` with unrelated product experiments. + + +### Configuration and Secrets + +Non-secret configuration may use environment variables. + +Secrets should use a managed secret store where possible: + +```text +database credentials +Redis credentials +JWT/private signing keys +object storage credentials +SMTP/API provider credentials +monitoring credentials +``` + +Do not publish real secrets in sample configuration. + +Organization profession enablement remains primarily data-driven through `organization_professions`. + +Global feature flags may be used for staged rollout, kill switches, or incomplete features. + +--- + +## 97C. Review-Driven Deferred Decisions + +The following ideas are valid possibilities but are explicitly **not frozen into v1**: + +```text +read replicas +materialized views +Elasticsearch/OpenSearch +universal 100 MB file limit +fixed 100 req/min user limit +fixed 1000 req/hour organization limit +specific cache-hit-ratio target +specific p95 latency promise +database-per-tenant +microservices +GraphQL +``` + +These require evidence from: + +```text +load tests +security analysis +customer requirements +compliance requirements +real production workloads +``` + +This prevents benchmark-shaped guesses from becoming architecture law. + +--- + +## 97D. Provisional Performance Objectives + +Performance numbers in architecture are starting hypotheses, not guarantees. + +Initial engineering objectives may begin with: + +```text +Interactive read: + target p95 <= 500 ms + +Interactive mutation: + target p95 <= 750 ms + +Simple list/search: + target p95 <= 800 ms + +Upload authorization: + target p95 <= 300 ms + +Background outbox pickup: + target <= 5 seconds under normal operating conditions +``` + +These are revised after realistic testing. + +Track: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +outbox lag +``` + +Different endpoint classes receive different SLOs. + +Do not use file-transfer completion time as an API SLO when bytes travel directly between client and object storage. + +--- + +## 97E. Risk Register + +Maintain a living risk register. + +Suggested structure: + +| Risk | Impact | Mitigation | Owner | Phase | Status | +|---|---|---|---|---|---| +| Cross-tenant data exposure | Critical | Tenant-aware FKs, scoped queries, security tests | Backend/Security | P0 | Open | +| Non-idempotent outbox side effect | Critical | Consumer dedupe, provider idempotency, chaos tests | Backend | P0 | Open | +| Migration failure | High | Expand/contract, dry runs, backups | Backend/Platform | P0 | Open | +| Engineering workflow mismatch | High | Domain expert validation | Product/Engineering SME | MVP | Open | +| Portal authorization leak | Critical | Separate external access model, publication grants | Backend/Security | Portal | Open | +| Webhook delivery instability | Medium | Retry, dead-letter, replay, metrics | Backend | Integrations | Open | +| Large upload abandonment | Medium | Multipart expiry and cleanup | Backend/Platform | Documents | Open | +| Documentation drift | Medium | OpenAPI validation, ADRs, CI | Engineering | Continuous | Open | + +Do not pretend likelihood labels are quantitative unless the team defines and uses a scoring method. + +--- + +## 97F. Architecture Change Governance + +v4 is the last broad platform-architecture revision before Engineering MVP implementation. + +New discoveries should normally become: + +```text +ADR +OpenAPI change +database migration +domain-state-machine update +security decision +backlog item +runbook +``` + +rather than a new full architecture rewrite. + +Reopen the broad architecture only when a discovery invalidates one of these foundational assumptions: + +```text +tenant model +profession separation +shared-core boundary +REST API model +data ownership +security trust boundary +deployment topology +database architecture +``` + +This prevents design review from becoming an infinite recursion problem. + +--- + +## 97G. Production Readiness Gates + +Architecture being coherent does not mean production is safe. + +Before production, require evidence in these categories. + +### Security + +```text +TLS configured +password hashing configured +refresh rotation/reuse detection tested +session revocation tested +tenant isolation tests passing +authorization/credential policies tested +rate limiting active +secrets managed outside source control +file security scanning active +security review completed +``` + +### Reliability + +```text +database backups automated +restore tested +object storage recovery strategy tested +outbox monitoring active +job queue monitoring active +webhook retry/dead-letter behavior tested +health checks configured +dependency failures tested +``` + +### Data Integrity + +```text +tenant-aware foreign keys present where required +financial invariants tested +migration tested on production-like data +idempotency tested for high-risk commands +optimistic concurrency tested +audit integrity tested +``` + +### Contract / API + +```text +OpenAPI validates +contract tests pass +error schema consistent +versioning rules documented +client SDK generation validated if used +``` + +### Performance + +```text +load test executed +realistic SLOs defined +database pool configured +key queries analyzed +outbox/job backlogs remain within SLO +``` + +### Critical Domain Coverage + +Rather than a magic overall coverage number, require explicit test coverage for: + +```text +tenant boundaries +design approval +inspection completion +invoice issue +payment/refund +membership privilege changes +clinical record signing/amendment when healthcare exists +prescribing authorization when healthcare exists +``` + +### Release Gate Principle + +No single metric such as: + +```text +90% test coverage +``` + +is sufficient evidence of production readiness. + +Quality gates are based on critical behavior, not vanity percentages. + +--- + +## 97. Final Design Position + +The platform is: + +```text +One Shared Platform + │ + ├── Shared Identity / Sessions + ├── Shared Security / Authorization + ├── Shared Documents / Multipart Uploads + ├── Shared Financial Core + ├── Shared Audit / Outbox + ├── Shared Jobs / Webhooks / Notifications + │ + ├── Engineering Internal Product + │ ├── Engineering Frontend + │ ├── Engineering REST APIs + │ ├── Engineering State Machines + │ └── Engineering Tables + │ + ├── Engineering Client Portal + │ ├── External Portal Frontend + │ ├── Portal Accounts + │ ├── Project Grants + │ ├── Published Documents + │ └── Client Review / Acceptance + │ + ├── Legal Product + │ ├── Legal Frontend + │ ├── Legal REST APIs + │ └── Legal Tables + │ + └── Healthcare Product + ├── Healthcare Frontend + ├── Healthcare REST APIs + ├── Healthcare Security Policies + └── Healthcare Tables +``` + +The system shares infrastructure where reuse is valuable while preserving profession-specific domain semantics and trust boundaries. + +v4 is the final broad architecture baseline for Engineering MVP implementation. + +From this point forward, architecture detail should primarily move into: + +```text +ADRs +OpenAPI +database schema/migrations +state-machine specifications +security policies +implementation backlog +runbooks +``` + +rather than repeatedly rewriting the entire architecture plan. + +This document does not itself prove: + +```text +regulatory compliance +production certification +security certification +performance at a specific scale +``` + +Those require implementation evidence, security review, domain validation, operational testing, restore testing, and measured production-like workloads. + + + + +--- + +# v4.1 Changelog + +v4.1 resolves implementation-contract issues without changing the core architecture. + +```text +✓ raw UUIDv7 API ID contract +✓ camelCase API / snake_case database naming convention +✓ direct organization_id on tenant subresources +✓ invitation role assignments +✓ service accounts and hashed API keys +✓ multiple professional credentials per profile +✓ global archive/revoke/unlink/hard-delete policy +✓ project stage duplication removed +✓ project budget_minor removed +✓ project phase reorder command +✓ global engineering site listing +✓ task status and priority vocabularies +✓ design revise endpoint +✓ design-version many-document cardinality +✓ design review/version tenant keys +✓ inspection finding tenant keys +✓ explicit inspection follow-up table +✓ change requests deferred until fully specified +✓ time-entry work-item attribution +✓ time-entry project/work-item consistency constraints +✓ specification many-document cardinality and status values +✓ legal_documents duplication removed +✓ legal matter/case document-link schemas +✓ healthcare placeholder schemas clarified +✓ invoice-item schema defined +✓ document retention-policy schema +✓ explicit retention-period null semantics +✓ version-independent document-category uniqueness fallback +✓ standard upload DTO +✓ multipart-init/parts/complete DTOs +✓ webhook subscription schema +✓ webhook delivery references outbox events +✓ typed job input/result references +✓ logout endpoint +✓ assigned-scope resolution rules +✓ audit privacy transformation strategy +✓ outbox correlation and causation IDs +✓ CORS/browser-security ADR +✓ feature-flag strategy clarified +✓ jobs confirmed tenant-scoped via X-Organization-Id +``` + +The next artifacts should be implementation-specific: + +```text +ADRs +Engineering OpenAPI +Engineering database migrations +Engineering state-machine spec +Engineering MVP backlog +``` diff --git a/professional_management_platform_rest_plan_v4_3.md b/professional_management_platform_rest_plan_v4_3.md new file mode 100644 index 0000000..ad16410 --- /dev/null +++ b/professional_management_platform_rest_plan_v4_3.md @@ -0,0 +1,6773 @@ +# Professional Management Platform +## Full REST-First System Design Plan + +> **Revision:** v4.1 — Consistency and Implementation-Contract Cleanup +> **Status:** Locked broad architecture baseline with implementation-blocking contradictions resolved. Subsequent detail belongs in ADRs, OpenAPI, migrations, domain specifications, and backlog items. +> **Primary vertical:** Engineering +> **API style:** REST + JSON + OpenAPI 3.1 +> **Backend style:** Modular monolith +> **Data:** PostgreSQL + profession-specific tables +> **Tenant model:** Organization-scoped, explicit tenant context + +### v4.1 Cleanup Notes + +v4.1 does not redesign the platform. It resolves contradictions and fills implementation contracts discovered during detailed review. + +Resolved: + +- raw UUIDv7 is now the serialized/API identifier format +- database columns remain UUID; prefixed strings are not public IDs +- all tenant-owned subresources carry direct `organization_id` +- design-version document cardinality is explicit and relational +- engineering time entries can be attributed to a specific work item +- time-entry work-item links are constrained to the time entry's project +- engineering specifications use explicit many-document link records +- project-level `budget_minor` is removed in favor of the dedicated budget model +- duplicate `legal_documents` ownership is removed +- legal matter/case document-link schemas are defined +- batch custom-action paths use one documented convention +- membership invitations can pre-assign multiple roles +- missing design `revise` command is added +- inspection follow-ups now have a table and lifecycle +- change requests are moved out of the initial Engineering schema until specified +- service accounts and API keys are defined for machine access +- service-account role assignments have an explicit relational schema +- API JSON, query parameters, and path parameter names use camelCase; database columns use snake_case +- professional profiles support multiple professional credentials +- deletion/archival/revocation/unlink behavior is globally defined +- invoice-item fields are specified +- deferred healthcare placeholder tables receive minimum schemas or explicit deferral notes +- document upload and multipart DTOs are defined +- webhook subscription fields are defined +- `POST /auth/logout` is restored +- portal review-request and portal capability schemas are defined +- project-role, task-status, priority, and inspection-outcome values are defined +- document-category uniqueness has a version-independent fallback +- retention policy fields are defined +- retention-period null semantics are explicit +- pagination defaults are explicit +- `assigned` authorization scope has resource-specific resolution rules +- feature-flag behavior is clarified +- audit privacy minimization/anonymization strategy is documented +- outbox correlation and causation IDs are added +- webhook delivery event references and job reference envelopes are defined +- CORS and web-security configuration is moved into a required ADR +- project `stage` duplication is removed; project phases remain authoritative +- phase reordering, site listing, appointment locations, and encounter reason fields are clarified +- jobs remain tenant-scoped through the standard organization header rather than path nesting + +--- +--- +--- +--- + +## 2. Core Architecture Decision + +The platform will use: + +- REST +- JSON +- OpenAPI +- Versioned endpoints +- PostgreSQL +- Modular monolith backend +- Profession-specific frontends +- Profession-specific database tables +- Shared identity, security, billing, documents, audit, and infrastructure + +Base API path: + +```text +/api/v1 +``` + +GraphQL is not part of v1. + +--- + +## 3. High-Level Architecture + +```text + FRONTENDS + + ┌──────────────────┼──────────────────┐ + │ │ │ + Engineering Web Legal Web Healthcare Web + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ + REST API + /api/v1 + │ + ┌───────────┼───────────┐ + │ │ │ + Core Engineering Legal + │ │ │ + │ Healthcare │ + │ │ │ + └───────────┼───────────┘ + │ + PostgreSQL + │ + ┌───────────────┼────────────────┐ + │ │ │ + Shared Tables Profession Tables Audit/Event Tables +``` + +Shared infrastructure: + +```text +PostgreSQL +Redis +Object Storage +Queue / Workers +Audit +Notifications +Billing +Observability +``` + +--- + +## 4. System Architecture Strategy + +Start with a modular monolith. + +Do not start with microservices. + +Initial deployment: + +```text +Frontend Apps + │ + ▼ +Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Worker Queue +``` + +Benefits: + +- simpler transactions +- easier development +- easier deployment +- clearer domain boundaries +- lower operational burden +- easier refactoring +- future service extraction remains possible + +--- + +## 5. Repository Structure + +Recommended monorepo: + +```text +professional-platform/ +│ +├── apps/ +│ ├── engineering-web/ +│ ├── legal-web/ +│ ├── healthcare-web/ +│ ├── platform-admin/ +│ ├── api/ +│ └── workers/ +│ +├── packages/ +│ ├── ui/ +│ ├── api-client/ +│ ├── auth-client/ +│ ├── validation/ +│ ├── types/ +│ ├── config/ +│ └── testing/ +│ +├── database/ +│ ├── migrations/ +│ ├── seeds/ +│ └── scripts/ +│ +├── infrastructure/ +│ ├── docker/ +│ ├── deployment/ +│ └── monitoring/ +│ +└── docs/ + ├── architecture/ + ├── api/ + ├── security/ + └── domains/ +``` + +--- + +## 6. Frontend Strategy + +Every profession receives its own frontend application. + +Avoid one giant frontend filled with profession checks. + +### Engineering Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Projects +Project Phases +Project Team +Sites +Designs +Design Reviews +Inspections +Specifications +Tasks +Documents +Timesheets +Billing +Reports +Administration +``` + +### Legal Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Matters +Cases +Hearings +Courts +Deadlines +Documents +Conflict Checks +Time Tracking +Retainers +Billing +Reports +Administration +``` + +### Healthcare Frontend + +Suggested navigation: + +```text +Dashboard +Patients +Appointments +Practitioners +Encounters +Clinical Records +Diagnoses +Prescriptions +Insurance +Documents +Billing +Reports +Administration +``` + +### Platform Admin Frontend + +Suggested functions: + +```text +Organizations +Users +Profession Modules +Subscriptions +System Health +Audit +Support +Global Configuration +``` + +Platform administrators and organization administrators are separate concepts. + +--- + +## 7. REST API Structure + +Shared endpoints: + +```text +/api/v1/auth +/api/v1/me +/api/v1/organizations +/api/v1/memberships +/api/v1/membership-invitations +/api/v1/roles +/api/v1/permissions +/api/v1/documents +/api/v1/invoices +/api/v1/payments +/api/v1/audit-events +``` + +Engineering: + +```text +/api/v1/engineering/clients +/api/v1/engineering/projects +/api/v1/engineering/project-members +/api/v1/engineering/phases +/api/v1/engineering/sites +/api/v1/engineering/tasks +/api/v1/engineering/designs +/api/v1/engineering/inspections +/api/v1/engineering/specifications +/api/v1/engineering/time-entries +``` + +Legal: + +```text +/api/v1/legal/clients +/api/v1/legal/matters +/api/v1/legal/cases +/api/v1/legal/hearings +/api/v1/legal/deadlines +/api/v1/legal/conflict-checks +/api/v1/legal/retainers +/api/v1/legal/time-entries +``` + +Healthcare: + +```text +/api/v1/healthcare/patients +/api/v1/healthcare/practitioners +/api/v1/healthcare/appointments +/api/v1/healthcare/encounters +/api/v1/healthcare/clinical-records +/api/v1/healthcare/diagnoses +/api/v1/healthcare/prescriptions +/api/v1/healthcare/insurance +``` + +--- + +## 8. REST Conventions + +All APIs use JSON over HTTPS. + +Typical tenant-scoped request: + +```http +Authorization: Bearer +X-Organization-Id: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d +X-Request-Id: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff +Content-Type: application/json +``` + +### API Naming Convention + +Public API representation: + +```text +JSON properties: camelCase +query parameters: camelCase +path parameter names in documentation: camelCase +HTTP headers: conventional HTTP header casing +``` + +Database representation: + +```text +table names: snake_case +column names: snake_case +constraint/index names: snake_case +``` + +Example: + +```http +GET /api/v1/engineering/tasks?assignedToUserId=&createdAfter=2026-08-01T00:00:00Z +``` + +```json +{ + "assignedToUserId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d", + "createdAt": "2026-08-26T12:00:00Z" +} +``` + +maps internally to columns such as: + +```text +assigned_to_user_id +created_at +``` + +### Organization Context + +`X-Organization-Id` is mandatory for every tenant-scoped endpoint. + +Global endpoints such as these do not require tenant context: + +```http +POST /api/v1/auth/login +POST /api/v1/auth/token/refresh +GET /api/v1/me +GET /api/v1/me/organizations +GET /api/v1/auth/sessions +``` + +Tenant-context resolution: + +```yaml +header_missing_on_tenant_endpoint: + status: 400 + code: ORGANIZATION_CONTEXT_REQUIRED + +organization_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + +membership_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + +membership_inactive: + status: 403 + code: AUTHZ_MEMBERSHIP_INACTIVE + +organization_inactive: + status: 403 + code: AUTHZ_ORGANIZATION_INACTIVE + +resource_organization_mismatch: + status: 404 + code: RESOURCE_NOT_FOUND +``` + +### Idempotency + +Use: + +```http +Idempotency-Key: 8f7d6c5e-4b3a-4b1c-9d8e-7f6a5b4c3d2e +``` + +Required where duplicate execution can create material side effects. + +PostgreSQL is authoritative for critical idempotency records. + +Redis may accelerate lookup. + +### Batch Custom-Action Convention + +For collection-level custom commands use: + +```text +/{collection}/batch/{action} +``` + +Examples: + +```http +POST /api/v1/engineering/tasks/batch/assign +POST /api/v1/engineering/tasks/batch/complete +POST /api/v1/engineering/time-entries/batch/submit +``` + +Do not mix `batch-assign`, colon-style custom methods, and `/batch/assign` in the same API. + +### Rate-Limit Responses + +```http +429 Too Many Requests +Retry-After: +``` + +Additional rate-limit metadata may be exposed according to the selected gateway/standard. + +Do not freeze legacy `X-RateLimit-*` names here. + +### Error Standard Decision + +The current error envelope remains: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff" + } +} +``` + +ADR-005 decides whether OpenAPI v1 aligns this with RFC 9457 Problem Details. + +Do not silently change the envelope during implementation. + +## 9. Standard Response Format + +Single resource: + +```json +{ + "data": { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c5d", + "name": "Central Tower" + } +} +``` + +Collection: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Standard error: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "req_123" + } +} +``` + +Clients depend on `error.code`, not message text. + +### Error Taxonomy + +Authentication: + +```text +AUTH_INVALID_CREDENTIALS +AUTH_TOKEN_EXPIRED +AUTH_TOKEN_INVALID +AUTH_MFA_REQUIRED +AUTH_SESSION_REVOKED +AUTH_REFRESH_TOKEN_REUSED +``` + +Authorization: + +```text +AUTHZ_PERMISSION_DENIED +AUTHZ_ORGANIZATION_INACTIVE +AUTHZ_MEMBERSHIP_INACTIVE +AUTHZ_CREDENTIAL_INVALID +AUTHZ_SCOPE_MISMATCH +``` + +Tenant context: + +```text +ORGANIZATION_CONTEXT_REQUIRED +``` + +Resource/state: + +```text +RESOURCE_NOT_FOUND +RESOURCE_ALREADY_EXISTS +RESOURCE_CONCURRENT_MODIFICATION +RESOURCE_INVALID_STATE +RESOURCE_ARCHIVED +``` + +Validation: + +```text +VALIDATION_ERROR +VALIDATION_REQUIRED_FIELD +VALIDATION_INVALID_FORMAT +VALIDATION_BUSINESS_RULE +``` + +Idempotency: + +```text +IDEMPOTENCY_KEY_REQUIRED +IDEMPOTENCY_KEY_CONFLICT +``` + +Rate limiting: + +```text +RATE_LIMIT_EXCEEDED +``` + +System/dependency: + +```text +INTERNAL_ERROR +SERVICE_UNAVAILABLE +DATABASE_UNAVAILABLE +DEPENDENCY_FAILED +``` + +Validation example: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed.", + "requestId": "req_123", + "details": { + "fields": [ + { + "field": "email", + "code": "INVALID_FORMAT", + "message": "Must be a valid email address" + } + ] + } + } +} +``` + +Business-state example: + +```json +{ + "error": { + "code": "RESOURCE_INVALID_STATE", + "message": "Cannot approve design in current state.", + "requestId": "req_123", + "details": { + "resourceType": "engineering_design", + "resourceId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c6d", + "currentState": "draft", + "requiredState": "under_review", + "allowedActions": [ + "submit_review" + ] + } + } +} +``` + +Do not expose internal stack traces, SQL, policy internals, secrets, or cross-tenant information. + +## 10. HTTP Status Rules + +```text +200 Success +201 Created +202 Accepted +204 No Content +400 Bad Request +401 Unauthorized +403 Forbidden +404 Not Found +409 Conflict +422 Validation Error +429 Too Many Requests +500 Internal Server Error +``` + +Cross-tenant resource access should return 404. + +--- + +## 11. API Versioning + +Current API: + +```text +/api/v1 +``` + +Breaking changes require: + +```text +/api/v2 +``` + +Additive fields generally do not require a new version. + +--- + +## 11A. Identifier Convention + +The serialized identifier standard is **raw UUIDv7**. + +Example: + +```text +0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d +``` + +Database: + +```sql +id UUID PRIMARY KEY +``` + +API: + +```json +{ + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d" +} +``` + +Do not serialize IDs as: + +```text +org_ +user_ +project_ +``` + +unless a future ADR explicitly changes the public identifier contract. + +Human-friendly resource references use separate fields such as: + +```text +projectNumber +matterNumber +patientNumber +invoiceNumber +``` + +This separates machine identity from business/display references. + +UUID generation is decided by ADR-004: + +```text +PostgreSQL-native UUIDv7 when supported and selected +or +application-generated UUIDv7 +``` + +The API format is identical either way. + +--- + +## 12. Authentication + +Initial human authentication: + +```text +Email ++ +Password ++ +Short-Lived Access Token ++ +Opaque Refresh Token ++ +Server-Side Session +``` + +REST: + +```http +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/logout + +POST /api/v1/auth/token/refresh +POST /api/v1/auth/token/revoke +POST /api/v1/auth/token/revoke-all + +GET /api/v1/auth/sessions +DELETE /api/v1/auth/sessions/{sessionId} + +GET /api/v1/me +``` + +`POST /auth/logout` revokes the current session. + +`DELETE /auth/sessions/{sessionId}` allows a user to revoke a specific session, such as another device. + +### Access Token + +```yaml +format: JWT +lifetime: short-lived +signed: true +encrypted: false +claims: + - sub + - sessionId + - issuer + - audience + - issuedAt + - expiresAt +``` + +Organization context is not trusted from the token as authorization authority. + +### Sessions + +```text +sessions +├── id +├── user_id +├── device metadata +├── created_at +├── last_active_at +├── expires_at +├── revoked_at +└── revocation_reason +``` + +### Refresh Tokens + +```text +refresh_tokens +├── id +├── session_id +├── family_id +├── token_hash +├── issued_at +├── expires_at +├── rotated_at +├── replaced_by_token_id +├── revoked_at +└── revocation_reason +``` + +Constraints/indexes: + +```text +UNIQUE(token_hash) +INDEX(family_id) +INDEX(session_id) +``` + +`family_id` is not unique. + +### Refresh Reuse Detection + +Use of a previously rotated token triggers: + +```text +revoke token family +revoke affected session +security audit event +reauthentication +``` + +Policy may escalate to all-session revocation. + +Future human authentication: + +- MFA +- WebAuthn/passkeys +- OIDC/SSO +- enterprise identity providers + + +## 12A. Service Accounts and API Keys + +Machine-to-machine access is separate from human sessions. + +Use: + +```text +service_accounts +api_keys +service_account_roles +``` + +### Service Account + +Suggested fields: + +```text +id +organization_id +name +description +status +created_by_user_id +created_at +updated_at +revoked_at +``` + +### API Key + +Suggested fields: + +```text +id +organization_id +service_account_id + +key_prefix +secret_hash + +created_at +expires_at +last_used_at +revoked_at +revocation_reason +``` + +Raw API-key secrets are shown only once. + +Store only a secure hash of the secret. + +`key_prefix` is safe display material for identifying a key in administration screens. + +### Service Account Role + +`service_account_roles` uses the same organization-scoped role registry as human RBAC assignments. + +Suggested fields: + +```text +id +organization_id +service_account_id +role_id +created_at +``` + +Unique: + +```text +(organization_id, service_account_id, role_id) +``` + +Tenant-safe foreign keys require the service account and role to belong to the same organization as the assignment. + +### Authorization + +Service accounts use explicit organization-scoped permissions, preferably through: + +```text +service_account_roles +``` + +with the same registered permission vocabulary used by RBAC. + +They do not become fake human memberships. + +### Audit + +Audit actors support: + +```text +actor_type = user +actor_type = service_account +actor_type = system +``` + +Machine authentication is required when public/integration API access is implemented; it does not block the earliest internal Engineering UI slice. + +--- + +## 13. Shared Core Backend + +Recommended modules: + +```text +core/ +├── auth/ +├── users/ +├── organizations/ +├── memberships/ +├── roles/ +├── permissions/ +├── authorization/ +├── documents/ +├── billing/ +├── notifications/ +├── audit/ +└── events/ +``` + +Dependency rule: + +```text +Profession module → Core +``` + +Never: + +```text +Core → Profession module +``` + +--- + +## 14. Organizations + +Organizations are tenants. + +Examples: + +```text +Atlas Structural Engineering +Smith & Associates Law +North Shore Medical Practice +``` + +Suggested fields: + +```text +id +name +slug +status +country_code +timezone +currency_code +created_at +updated_at +``` + +--- + +## 15. Profession Enablement + +Use: + +```text +organization_professions +``` + +Suggested fields: + +```text +organization_id +profession +enabled_at +configuration +``` + +Possible professions: + +```text +engineering +legal +healthcare +``` + +An organization may eventually enable more than one profession module. + +--- + +## 16. Users and Memberships + +Users are global identities. + +A user gains tenant access through membership. + +```text +User + │ + ▼ +Membership + │ + ▼ +Organization +``` + +Suggested `users` fields: + +```text +id +email +first_name +last_name +phone +avatar_url +status +created_at +updated_at +``` + +Suggested `memberships` fields: + +```text +id +organization_id +user_id +status +joined_at +created_at +updated_at +``` + +--- + +## 17. Membership Invitations + +Keep invitations separate from memberships. + +Tables: + +```text +membership_invitations +membership_invitation_roles +``` + +`membership_invitations`: + +```text +id +organization_id +email +invited_by_user_id +expires_at +accepted_at +revoked_at +created_at +``` + +`membership_invitation_roles`: + +```text +organization_id +invitation_id +role_id +created_at +``` + +Use tenant-aware foreign keys so invitation roles cannot reference another organization's role. + +Flow: + +```text +Invitation + Intended Roles + ↓ + Accepted + ↓ + User + ↓ + Membership + ↓ + Membership Roles +``` + +At acceptance: + +1. validate invitation token and expiry +2. validate invited email/account policy +3. create membership +4. copy valid intended roles to membership-role assignments +5. mark invitation accepted +6. audit +7. emit outbox event + +If an intended role was revoked/deleted before acceptance, acceptance fails safely or drops that role according to explicit organization policy. + +## 18. Authorization + +Use: + +```text +RBAC ++ +Permission Scope ++ +Resource Policies ++ +Professional Qualification Policies ++ +Domain State Rules +``` + +Decision flow: + +```text +Authenticated User + ↓ +Explicit Organization Context + ↓ +Active Membership + ↓ +Enabled Profession Module + ↓ +Roles + ↓ +Permissions + ↓ +Permission Scope + ↓ +Tenant-scoped Resource Query + ↓ +Resource Policy + ↓ +Credential/Jurisdiction Policy + ↓ +Domain State Rule + ↓ +ALLOW / DENY +``` + +Default decision: + +```text +DENY +``` + +Authorization rules: + +1. Controllers never perform ad-hoc role comparisons. +2. Tenant resource queries always include `organization_id`. +3. Do not load an arbitrary resource first and then discover it belongs to another tenant. +4. High-risk professional actions perform credential checks at command execution time. +5. A permission grants the ability to attempt an action, not a guarantee the domain state allows it. +6. Cross-tenant resources appear nonexistent. +7. Profession module enablement is checked before profession-specific authorization. + +## 19. Roles and Permissions + +Roles are organization-scoped collections of permissions. + +Example roles: + +```text +Owner +Administrator +Project Manager +Engineer +Reviewer +Inspector +Lawyer +Paralegal +Doctor +Nurse +Billing Manager +Viewer +``` + +Roles are not professional credentials. + +### Engineering Permissions + +```text +engineering.clients.read +engineering.clients.create +engineering.clients.update +engineering.clients.archive + +engineering.projects.read +engineering.projects.create +engineering.projects.update +engineering.projects.activate +engineering.projects.close +engineering.projects.archive + +engineering.project_members.manage +engineering.phases.manage +engineering.tasks.manage +engineering.sites.manage + +engineering.documents.read +engineering.documents.upload +engineering.documents.delete + +engineering.designs.read +engineering.designs.create +engineering.designs.update +engineering.designs.review +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede + +engineering.inspections.read +engineering.inspections.manage +engineering.inspections.complete + +engineering.time_entries.manage +engineering.reports.read +``` + +### Legal Permissions + +```text +legal.clients.read +legal.clients.create +legal.clients.update + +legal.matters.read +legal.matters.create +legal.matters.update +legal.matters.close +legal.matters.reopen + +legal.cases.read +legal.cases.manage +legal.hearings.manage +legal.deadlines.manage + +legal.documents.read +legal.documents.upload + +legal.conflicts.manage +legal.conflicts.approve + +legal.retainers.manage +legal.time_entries.manage +``` + +### Healthcare Permissions + +```text +healthcare.patients.read +healthcare.patients.create +healthcare.patients.update + +healthcare.appointments.read +healthcare.appointments.manage + +healthcare.encounters.read +healthcare.encounters.manage + +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.records.access_log.read + +healthcare.prescriptions.read +healthcare.prescriptions.write +healthcare.prescriptions.sign + +healthcare.insurance.read +healthcare.insurance.manage +``` + +### Shared Permissions + +```text +documents.read +documents.upload + +billing.read +invoices.create +invoices.issue +invoices.void +payments.record +payments.refund + +members.read +members.invite +members.update +members.remove + +roles.read +roles.manage + +audit.read +``` + +Avoid vague permissions such as `admin_everything` in normal tenant RBAC. + +## 20. Permission Scopes + +Initial scopes: + +```text +assigned +organization +``` + +Example: + +```text +Engineer: +engineering.projects.read = assigned + +Principal Engineer: +engineering.projects.read = organization +``` + +`assigned` is not magic. Each resource policy defines how assignment is resolved. + +### Engineering Project + +Assigned when: + +```text +engineering_project_members.user_id = ctx.userId +AND engineering_project_members.left_at IS NULL +``` + +or when the user is the active project manager, if project-manager assignment is modeled separately. + +### Engineering Task + +Assigned when: + +```text +engineering_tasks.assigned_to_user_id = ctx.userId +``` + +For tasks linked to a project, parent-project access may also be required. + +### Engineering Design + +Assigned when an active row exists in: + +```text +engineering_design_assignments +``` + +for the user and an allowed assignment role. + +### Engineering Inspection + +Assigned when: + +```text +engineering_inspections.inspector_user_id = ctx.userId +``` + +or an explicit inspection assignment exists if the model later supports multiple inspectors. + +### Derived Client Access + +An assigned professional may access a client only through a policy that derives access from authorized projects. + +Project assignment must not automatically grant access to every project belonging to that client. + +Future scopes may include: + +```text +owned +team +department +restricted +``` + +Do not add them before a real workflow requires them. + +## 21. Professional Credentials + +Professional identity and credentials are separate from RBAC. + +Use: + +```text +professional_profiles +professional_credentials +``` + +### Professional Profile + +One organization/user/profession relationship. + +Suggested fields: + +```text +id +organization_id +user_id +profession +title +status +created_at +updated_at +``` + +### Professional Credential + +One profile may hold many credentials. + +Suggested fields: + +```text +id +organization_id +professional_profile_id + +credential_type +credential_number +issuing_authority +jurisdiction +discipline + +status +valid_from +expires_at + +verified_at +verified_by_user_id + +created_at +updated_at +``` + +Examples: + +```text +professional engineering license in jurisdiction A +professional engineering license in jurisdiction B +specialty certification +medical license +controlled-substance prescribing registration where applicable +``` + +Credential policy evaluates the set of active credentials rather than one `primary_license_number`. + +High-risk actions such as design approval, record signing, or prescribing use authoritative or revocation-aware credential state. + +Prescribing remains jurisdiction/scope-of-practice policy, not a hard-coded profession test. + +## 22. Database Architecture + +Use PostgreSQL. + +Start with: + +```text +One database ++ +Shared schema ++ +Profession-specific tables +``` + +Do not begin with database-per-profession or database-per-customer unless compliance or residency requirements force that choice. + +--- + +## 23. Shared Tables + +Recommended shared tables: + +```text +organizations +organization_professions + +users +user_credentials +sessions + +memberships +membership_invitations + +roles +permissions +role_permissions +membership_roles + +professional_profiles + +documents +document_versions + +invoices +invoice_items +payments + +notifications +notification_deliveries + +audit_events +outbox_events +``` + +--- + +## 24. Multi-Tenancy Rule + +Every tenant-owned row must contain: + +```text +organization_id +``` + +Examples: + +```text +engineering_projects.organization_id +legal_matters.organization_id +healthcare_patients.organization_id +``` + +Enforce tenant boundaries at: + +- API layer +- authorization layer +- repository/query layer +- database constraints + +--- + +## 25. Tenant-Safe Foreign Keys + +Use composite tenant-aware foreign keys when possible. + +Example: + +```text +engineering_projects +organization_id +client_id +``` + +references: + +```text +engineering_clients +organization_id +id +``` + +This prevents linking a resource from one organization to another organization's data. + +--- + +## 21A. Deletion, Archival, Revocation, and Unlink Policy + +`DELETE` does not have one universal persistence meaning. + +Use four lifecycle behaviors. + +### Archive / Domain Inactivation + +For business records whose history matters: + +```text +engineering clients +engineering projects +legal matters +healthcare patients +documents where retention requires history +``` + +Typical fields: + +```text +status +archived_at +archived_by_user_id +``` + +Restore is permitted only when domain, retention, and organization policy allow it. + +### Revoke + +For access/security resources: + +```text +sessions +refresh tokens +API keys +membership invitations +portal grants +webhook credentials +``` + +Use: + +```text +revoked_at +revoked_by +revocation_reason +``` + +### Temporal Unlink + +For relationship records where the historical relationship matters: + +```text +project documents +project members +design assignments +portal document publications +``` + +Use: + +```text +unlinked_at +left_at +unassigned_at +revoked_at +``` + +rather than deleting historical evidence. + +### Hard Delete + +Reserved for genuinely disposable or never-committed data, such as: + +```text +expired pending upload artifacts +failed temporary staging objects +unreferenced draft configuration where audit/retention does not require history +``` + +Hard deletion of financial, professional, audit, signed clinical, or issued business records is forbidden unless an explicit retention/privacy policy defines the operation. + +Every resource specification must declare its lifecycle behavior. + +--- + +# Engineering Domain + +## 26. Engineering Tables + +Initial Engineering MVP tables: + +```text +engineering_clients +engineering_client_contacts + +engineering_projects +engineering_project_members +engineering_project_phases +engineering_sites +engineering_tasks + +engineering_designs +engineering_design_assignments +engineering_design_versions +engineering_design_version_documents +engineering_design_reviews + +engineering_inspections +engineering_inspection_findings +engineering_inspection_followups + +engineering_specifications +engineering_specification_documents + +engineering_time_entries +``` + +Later Engineering extensions: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries + +engineering_change_requests +``` + +`engineering_change_requests` is not part of the initial schema until its lifecycle, relationships, and REST contract are specified. + +## 27. Engineering Clients + +Suggested core client fields: + +```text +id +organization_id +client_type +display_name +legal_name +status +created_at +updated_at +version +``` + +Do not permanently squeeze all contacts into one `email`, one `phone`, and one `contact_name`. + +Engineering customers commonly have multiple: + +```text +technical contacts +billing contacts +executive contacts +site contacts +contract contacts +``` + +Use: + +```text +engineering_client_contacts +``` + +Suggested contact fields: + +```text +id +organization_id +client_id + +name +title +department + +email +phone + +contact_type +is_primary + +created_at +updated_at +``` + +Client REST: + +```http +GET /api/v1/engineering/clients +POST /api/v1/engineering/clients +GET /api/v1/engineering/clients/{clientId} +PATCH /api/v1/engineering/clients/{clientId} + +POST /api/v1/engineering/clients/{clientId}/archive +POST /api/v1/engineering/clients/{clientId}/restore + +GET /api/v1/engineering/clients/{clientId}/projects +GET /api/v1/engineering/clients/{clientId}/invoices +``` + +Contact REST: + +```http +GET /api/v1/engineering/clients/{clientId}/contacts +POST /api/v1/engineering/clients/{clientId}/contacts +PATCH /api/v1/engineering/clients/{clientId}/contacts/{contactId} +DELETE /api/v1/engineering/clients/{clientId}/contacts/{contactId} +``` + +Delete may be implemented as archival when contact history matters. + +Client restore is allowed only when organization policy and retention rules permit it. + +## 27A. Engineering Client Portal + +External clients are not internal organization members. + +Use shared authentication identities where practical, but create a separate authorization boundary. + +```text +User + │ + ├── Internal Membership + │ ↓ + │ Organization Staff Access + │ + └── Client Portal Account + ↓ + Engineering Client Contact + ↓ + Project Access Grants +``` + +Suggested tables: + +```text +engineering_client_portal_accounts +engineering_client_portal_project_grants +engineering_project_document_publications +engineering_client_review_requests +``` + +### Portal Account + +Suggested fields: + +```text +id +organization_id +user_id +engineering_client_contact_id + +status + +invited_by_user_id +invited_at +accepted_at + +revoked_at +revoked_by_user_id +``` + +Portal accounts are not placed in `memberships`. + +### Project Grant + +Suggested fields: + +```text +id +organization_id +portal_account_id +project_id + +access_profile + +granted_by_user_id +granted_at +expires_at +revoked_at +``` + +Initial access capabilities may include: + +```text +project.status.read +project.documents.read_published +project.comments.create +project.files.submit +client_review.respond +``` + +The access model may later normalize capabilities into a grant table if simple profiles become insufficient. + +### Separate Frontend + +Recommended: + +```text +apps/ +├── engineering-web/ +└── engineering-client-portal/ +``` + +The internal engineering frontend and external portal do not share authorization assumptions. + +### Client Acceptance Is Not Engineering Approval + +Never represent client acceptance with: + +```text +engineering.designs.approve +``` + +Professional engineering approval is reserved for qualified internal/authorized professionals. + +Client-facing review should use separate concepts such as: + +```text +engineering.client_reviews.request +engineering.client_reviews.respond +engineering.client_reviews.accept +engineering.client_reviews.request_changes +``` + +Example: + +```http +POST /api/v1/engineering/client-review-requests/{reviewId}/accept +POST /api/v1/engineering/client-review-requests/{reviewId}/request-changes +``` + +A client acceptance may be commercially meaningful without being a professional engineering approval. + +### Portal Security Rules + +1. portal access is deny-by-default +2. every portal request remains organization-scoped +3. portal users only access explicitly granted projects +4. project membership does not apply to portal users +5. internal RBAC roles do not automatically apply to portal users +6. portal account revocation is immediate +7. portal grants may expire +8. sensitive document access requires explicit publication +9. portal activity is audited according to organization policy +10. professional approval endpoints are never exposed through portal grants + +--- + +## 27B. External Document Publication + +A document being linked to an engineering project does **not** make it externally visible. + +Use: + +```text +engineering_project_document_publications +``` + +Suggested fields: + +```text +id +organization_id + +project_document_link_id + +audience_type +portal_account_id nullable +client_id nullable + +published_by_user_id +published_at + +expires_at +revoked_at +revoked_by_user_id +``` + +Possible audiences: + +```text +all_active_client_portal_accounts_for_project +specific_portal_account +specific_client_contact +``` + +External download checks: + +```text +authenticated portal user ++ +active portal account ++ +active project grant ++ +active document publication ++ +publication not expired/revoked ++ +document classification allows publication ++ +download permission +``` + +This prevents an internal project document from appearing in the client portal merely because it is linked to the project. + +## 28. Engineering Projects + +Suggested fields: + +```text +id +organization_id +client_id + +project_number +name +description +discipline + +status + +project_manager_user_id + +start_date +expected_completion_date +completed_date + +created_at +updated_at +version +``` + +`stage` is removed from the project row because project phases are the authoritative workflow decomposition. + +If the frontend needs a "current stage", derive it from the active/current project phase or maintain an explicitly documented `current_phase_id` pointer. + +Project `budget_minor` is also removed. + +Detailed project budgets belong to the dedicated budget model. + +REST: + +```http +GET /api/v1/engineering/projects +POST /api/v1/engineering/projects +GET /api/v1/engineering/projects/{projectId} +PATCH /api/v1/engineering/projects/{projectId} + +POST /api/v1/engineering/projects/{projectId}/activate +POST /api/v1/engineering/projects/{projectId}/close +POST /api/v1/engineering/projects/{projectId}/archive +``` + +Purpose-built reads: + +```http +GET /api/v1/engineering/projects/{projectId}/summary +GET /api/v1/engineering/projects/{projectId}/timeline +GET /api/v1/engineering/projects/{projectId}/budget +``` + +The budget endpoint reads from the budget module when that module exists. + +## 29. Engineering Project Members + +Suggested fields: + +```text +id +organization_id +project_id +user_id +project_role +joined_at +left_at +``` + +Initial project-role vocabulary: + +```text +project_manager +engineer +designer +reviewer +inspector +viewer +contractor +``` + +Project role describes participation in one project. + +It is not a substitute for RBAC permission. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/members +POST /api/v1/engineering/projects/{projectId}/members +PATCH /api/v1/engineering/projects/{projectId}/members/{memberId} +DELETE /api/v1/engineering/projects/{projectId}/members/{memberId} +``` + +`DELETE` means end participation by setting `left_at`, not erase historical participation. + +## 30. Engineering Project Phases + +Suggested fields: + +```text +id +organization_id +project_id +name +sequence +status +start_date +end_date +created_at +updated_at +version +``` + +Typical initial statuses: + +```text +planned +active +completed +cancelled +``` + +Example phases: + +```text +Concept +Preliminary Design +Detailed Design +Construction +Inspection +Closeout +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/phases +POST /api/v1/engineering/projects/{projectId}/phases +PATCH /api/v1/engineering/projects/{projectId}/phases/{phaseId} + +POST /api/v1/engineering/projects/{projectId}/phases/{phaseId}/complete +POST /api/v1/engineering/projects/{projectId}/phases/reorder +``` + +Reorder request: + +```json +{ + "projectVersion": 12, + "orderedPhaseIds": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b301", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b302" + ] +} +``` + +Reordering is transactional. + +Sequences remain unique within a project after commit. + +## 31. Engineering Sites + +Suggested fields: + +```text +id +organization_id +project_id +name +address +latitude +longitude +created_at +updated_at +``` + +REST: + +```http +GET /api/v1/engineering/sites +GET /api/v1/engineering/sites/{siteId} + +POST /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/projects/{projectId}/sites + +PATCH /api/v1/engineering/sites/{siteId} +``` + +Global site listing is still tenant-scoped through `X-Organization-Id`. + +## 32. Engineering Tasks + +Suggested fields: + +```text +id +organization_id +project_id + +title +description + +status +priority + +created_by_user_id +assigned_to_user_id + +due_at +completed_at + +created_at +updated_at +version +``` + +Statuses: + +```text +todo +in_progress +completed +cancelled +``` + +Priorities: + +```text +low +medium +high +urgent +``` + +REST: + +```http +POST /api/v1/engineering/tasks +GET /api/v1/engineering/tasks +GET /api/v1/engineering/tasks/{taskId} +PATCH /api/v1/engineering/tasks/{taskId} + +POST /api/v1/engineering/tasks/{taskId}/complete +POST /api/v1/engineering/tasks/{taskId}/reopen +POST /api/v1/engineering/tasks/{taskId}/cancel +``` + +## 32A. Engineering Batch Operations + +Batch operations are useful for repetitive engineering workflows, but they must not bypass per-resource authorization or domain rules. + +Examples: + +```http +POST /api/v1/engineering/tasks/batch/assign +POST /api/v1/engineering/tasks/batch/complete + +POST /api/v1/engineering/time-entries/batch/submit +``` + +### Batch Execution Modes + +Every batch command explicitly defines one of: + +```text +atomic +partial +``` + +Atomic: + +```text +all resources succeed +or +entire operation fails +``` + +Partial: + +```text +each resource is evaluated independently +successful items commit +failed items return individual errors +``` + +Do not leave this behavior implicit. + +Example request: + +```json +{ + "taskIds": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c81", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c82", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c83" + ], + "assigneeUserId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d", + "mode": "partial" +} +``` + +Example response: + +```json +{ + "data": { + "succeeded": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c81", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c82" + ], + "failed": [ + { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c83", + "code": "RESOURCE_INVALID_STATE" + } + ] + } +} +``` + +### Authorization + +Each resource is evaluated for: + +```text +tenant +permission +scope +resource access +state validity +credential policy where applicable +``` + +Never authorize the first item and assume the remaining batch is equivalent. + +### Synchronous vs Asynchronous + +Small batches may execute synchronously. + +Large batches become jobs: + +```http +202 Accepted +``` + +with: + +```text +jobId +``` + +The synchronous/asynchronous threshold is configuration based on: + +```text +batch size +operation cost +database load +side effects +product tier +``` + +Financial or regulated batch actions require stricter idempotency and audit rules than ordinary task updates. + +--- + +## 33. Engineering Designs + +Suggested fields: + +```text +id +organization_id +project_id + +design_number +title +description +discipline + +status + +owner_user_id +prepared_by_user_id + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +States: + +```text +draft +under_review +changes_requested +approved +rejected +cancelled +withdrawn +superseded +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/designs +POST /api/v1/engineering/projects/{projectId}/designs + +GET /api/v1/engineering/designs/{designId} +PATCH /api/v1/engineering/designs/{designId} + +POST /api/v1/engineering/designs/{designId}/submit-review +POST /api/v1/engineering/designs/{designId}/request-changes +POST /api/v1/engineering/designs/{designId}/approve +POST /api/v1/engineering/designs/{designId}/reject +POST /api/v1/engineering/designs/{designId}/revise +POST /api/v1/engineering/designs/{designId}/cancel +POST /api/v1/engineering/designs/{designId}/withdraw +POST /api/v1/engineering/designs/{designId}/supersede + +POST /api/v1/engineering/designs/{designId}/assign +POST /api/v1/engineering/designs/{designId}/unassign + +GET /api/v1/engineering/designs/{designId}/versions +POST /api/v1/engineering/designs/{designId}/versions + +GET /api/v1/engineering/designs/{designId}/reviews +POST /api/v1/engineering/designs/{designId}/reviews +``` + +State machine: + +```text +draft + ├── submit-review ─────────────► under_review + └── cancel ────────────────────► cancelled + +under_review + ├── request-changes ───────────► changes_requested + ├── approve ───────────────────► approved + ├── reject ────────────────────► rejected + └── withdraw ──────────────────► withdrawn + +changes_requested + ├── submit-review ─────────────► under_review + └── withdraw ──────────────────► withdrawn + +rejected + └── revise ────────────────────► draft + +approved + └── supersede ─────────────────► superseded +``` + +Approval remains credential-aware, audited, and idempotent. + +Designs do not use generic archive/restore endpoints. Their professional lifecycle terminates through explicit state-machine outcomes such as `cancelled`, `withdrawn`, and `superseded`. Terminal designs remain queryable and auditable and are not hard-deleted through ordinary workflows. + +## 34. Design Versions and Reviews + +A design version is a logical professional revision. + +It may have multiple document files. + +Use: + +```text +engineering_design_versions +engineering_design_version_documents +engineering_design_reviews +``` + +### Design Version + +```text +id +organization_id +design_id +version_number +created_by_user_id +created_at +``` + +Unique: + +```text +(organization_id, design_id, version_number) +``` + +### Design Version Documents + +```text +id +organization_id +design_version_id +document_id +document_role +linked_by_user_id +linked_at +unlinked_at +``` + +Possible `document_role` values: + +```text +primary_drawing +calculation +supporting_document +specification +attachment +``` + +A design version therefore supports one or many documents without putting `document_id` directly on the version. + +### Design Review + +```text +id +organization_id +design_id +design_version_id +reviewer_user_id +status +comments +reviewed_at +created_at +``` + +Statuses: + +```text +pending +approved +changes_requested +rejected +``` + +All three tables are tenant-owned and carry direct `organization_id`. + +## 35. Engineering Inspections + +Inspection fields: + +```text +id +organization_id +project_id +site_id + +inspection_type +inspector_user_id + +status +outcome + +scheduled_at +started_at +performed_at +cancelled_at + +summary + +created_at +updated_at +version +``` + +Lifecycle: + +```text +draft +scheduled +in_progress +completed +cancelled +``` + +Outcome: + +```text +passed +passed_with_observations +followup_required +failed +``` + +`inspection_type` is an application/domain registry rather than a PostgreSQL enum. + +Initial common keys may include: + +```text +structural +mechanical +electrical +safety +final +``` + +Organizations/modules may add supported types through controlled configuration later. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/inspections +POST /api/v1/engineering/projects/{projectId}/inspections + +GET /api/v1/engineering/inspections/{inspectionId} +PATCH /api/v1/engineering/inspections/{inspectionId} + +POST /api/v1/engineering/inspections/{inspectionId}/schedule +POST /api/v1/engineering/inspections/{inspectionId}/start +POST /api/v1/engineering/inspections/{inspectionId}/complete +POST /api/v1/engineering/inspections/{inspectionId}/cancel + +GET /api/v1/engineering/inspections/{inspectionId}/findings +POST /api/v1/engineering/inspections/{inspectionId}/findings + +GET /api/v1/engineering/inspections/{inspectionId}/followups +POST /api/v1/engineering/inspections/{inspectionId}/followups +``` + +Inspection completion may create follow-up records. + +Lifecycle and outcome remain separate. + +## 36. Inspection Findings + +`engineering_inspection_findings`: + +```text +id +organization_id +inspection_id + +severity +description +status + +resolved_at +resolved_by_user_id + +created_at +updated_at +version +``` + +Severity: + +```text +observation +minor +major +critical +``` + +Status: + +```text +open +in_progress +resolved +accepted_risk +``` + +REST: + +```http +POST /api/v1/engineering/inspections/{inspectionId}/findings +PATCH /api/v1/engineering/inspection-findings/{findingId} +POST /api/v1/engineering/inspection-findings/{findingId}/resolve +``` + +`organization_id` is direct even though tenant ownership is also derivable through the inspection. + +### Follow-Up Resource + +Use: + +```text +engineering_inspection_followups +``` + +Fields: + +```text +id +organization_id +inspection_id + +followup_type + +linked_task_id nullable +linked_inspection_id nullable + +status + +created_by_user_id +created_at +completed_at +cancelled_at +``` + +`followup_type`: + +```text +corrective_task +followup_inspection +both +``` + +`status`: + +```text +open +in_progress +completed +cancelled +``` + +Tenant-safe foreign keys apply to the original inspection and any linked task/inspection. + +## 37. Engineering Specifications + +Use: + +```text +engineering_specifications +engineering_specification_documents +``` + +Suggested specification fields: + +```text +id +organization_id +project_id +specification_number +title +version +status +created_at +updated_at +``` + +Status values: + +```text +draft +active +superseded +archived +``` + +Specification document links: + +```text +id +organization_id +specification_id +document_id +document_role +linked_by_user_id +linked_at +unlinked_at +``` + +A specification may therefore have one or many current or historical document links. `document_role` is an application registry with initial values such as `primary`, `attachment`, and `supporting_document`. Tenant-safe foreign keys apply to both the specification and shared document. + +--- + +## 38. Engineering Change Requests + +**Deferred from the initial Engineering schema.** + +Change requests are a valid future engineering capability, but v4.1 does not create the table until these are specified: + +```text +relationship to project +relationship to design/specification +request origin +impact analysis +cost/schedule effects +review workflow +approval authority +state machine +document links +REST commands +audit requirements +``` + +Future candidate: + +```text +engineering_change_requests +``` + +This belongs in the Engineering extension backlog rather than a half-defined initial migration. + +## 38A. Engineering Project Budgets + +A single `budget_minor` column is sufficient only for a very early project total. + +When budget management enters scope, introduce: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries +``` + +### Budget + +Suggested fields: + +```text +id +organization_id +project_id + +name +currency_code +status + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +### Budget Item + +Suggested fields: + +```text +id +organization_id +budget_id + +category +description + +allocated_amount_minor + +created_at +updated_at +``` + +Do not casually store mutable: + +```text +spent_amount_minor +committed_amount_minor +``` + +as independent sources of truth if those values are derived from time entries, expenses, purchase commitments, or invoices. + +Prefer: + +```text +authoritative cost/commitment records + ↓ +derived budget projections +``` + +If denormalized totals are needed for performance, update them transactionally and reconcile them. + +Potential REST: + +```http +GET /api/v1/engineering/projects/{projectId}/budgets +POST /api/v1/engineering/projects/{projectId}/budgets +GET /api/v1/engineering/budgets/{budgetId} +PATCH /api/v1/engineering/budgets/{budgetId} + +POST /api/v1/engineering/budgets/{budgetId}/approve +GET /api/v1/engineering/budgets/{budgetId}/items +POST /api/v1/engineering/budgets/{budgetId}/items +``` + +Budget approval is an explicit command. + +--- + +## 39. Engineering Time Entries + +Suggested fields: + +```text +id +organization_id +project_id +user_id + +work_date +duration_minutes +description + +billable +billing_rate_minor +currency_code + +phase_id nullable +task_id nullable +design_id nullable +inspection_id nullable + +created_at +updated_at +version +``` + +The project is always required. + +A time entry may also identify one primary work item. + +Database check: + +```text +at most one of: +phase_id +task_id +design_id +inspection_id +``` + +Each optional foreign key is tenant- and project-aware. For example: + +```text +(organization_id, project_id, task_id) +→ engineering_tasks(organization_id, project_id, id) +``` + +and similarly for phase, design, and inspection. Supporting unique constraints on `(organization_id, project_id, id)` are required on each target table. + +This is a database-enforced invariant, not only an application validation rule: whenever an optional work-item ID is present, that work item must belong to the same organization and `project_id` as the time entry. + +This preserves relational integrity instead of using an unconstrained polymorphic `reference_type/reference_id`. + +REST: + +```http +POST /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries/{timeEntryId} +PATCH /api/v1/engineering/time-entries/{timeEntryId} + +POST /api/v1/engineering/time-entries/batch/submit +``` + +Duration is integer minutes. + +## 40. Legal Tables + +Initial legal-domain tables: + +```text +legal_clients +legal_matters +legal_matter_members +legal_cases +legal_case_parties +legal_courts +legal_hearings +legal_deadlines +legal_time_entries +legal_retainers +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +legal_matter_documents +legal_case_documents +``` + +There is no separate `legal_documents` ownership table. + +Documents remain shared infrastructure: + +```text +documents +document_versions +``` + +Legal relationships use: + +```text +legal_matter_documents +legal_case_documents +``` + +`legal_matter_documents` fields: + +```text +id +organization_id +matter_id +document_id +linked_by_user_id +linked_at +unlinked_at +``` + +`legal_case_documents` fields: + +```text +id +organization_id +case_id +document_id +linked_by_user_id +linked_at +unlinked_at +``` + +Both tables use tenant-safe foreign keys to their Legal parent and the shared `documents` table. `unlinked_at` preserves link history without deleting the shared document. + +REST namespace: + +```text +/api/v1/legal +``` + +Legal remains a later vertical. + +## 41. Legal Matters + +Suggested fields: + +```text +id +organization_id +client_id +matter_number +title +practice_area +responsible_lawyer_user_id +status +opened_date +closed_date +created_at +updated_at +``` + +--- + +## 42. Legal Cases + +Suggested fields: + +```text +id +organization_id +matter_id +case_number +court_id +jurisdiction +case_type +status +filed_date +created_at +updated_at +``` + +--- + +## 43. Legal Hearings + +Suggested fields: + +```text +id +organization_id +case_id +hearing_type +scheduled_at +courtroom +judge +status +notes +``` + +--- + +## 44. Legal Conflict Checks + +Suggested tables: + +```text +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +Conflict-check fields: + +```text +id +organization_id +potential_client_name +matter_description +requested_by_user_id +reviewed_by_user_id +status +decision +decision_reason +created_at +reviewed_at +version +``` + +Request example: + +```json +{ + "potentialClientName": "Acme Corporation", + "relatedParties": [ + { + "name": "John Smith", + "relationship": "CEO" + }, + { + "name": "Acme Subsidiary LLC", + "relationship": "Subsidiary" + } + ], + "matterDescription": "Corporate acquisition" +} +``` + +Response may contain possible matches: + +```json +{ + "data": { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cbd", + "status": "pending_review", + "potentialConflicts": [ + { + "type": "possible_direct_adversity", + "partyName": "Acme Corporation", + "existingMatterId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2ccd", + "existingMatterNumber": "MAT-2026-089" + } + ] + } +} +``` + +The system should distinguish: + +```text +automated possible match +``` + +from: + +```text +lawyer-approved conflict determination +``` + +The software may assist discovery; it should not silently make the professional judgment. + +Approvals and declines are auditable commands. + +## 45. Healthcare Tables + +Healthcare remains a later vertical. + +Minimum planned tables: + +```text +healthcare_patients +healthcare_patient_contacts +healthcare_patient_addresses + +healthcare_practitioners +healthcare_locations +healthcare_rooms + +healthcare_appointments +healthcare_encounters + +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments + +healthcare_diagnoses +healthcare_prescriptions +healthcare_insurance_policies +healthcare_allergies +healthcare_medications +``` + +All tenant-owned tables carry direct `organization_id`. + +Detailed healthcare interoperability, terminology, and jurisdiction rules require healthcare-specific design before implementation. + +## 46. Healthcare Patients + +Core patient: + +```text +id +organization_id +patient_number + +first_name +middle_name +last_name +date_of_birth + +administrative_gender nullable +sex_at_birth nullable +gender_identity nullable + +status + +created_at +updated_at +version +``` + +Exact demographic terminology and allowed values are finalized in the healthcare-domain specification. + +Do not make every field mandatory merely because it exists. + +### Patient Contact + +`healthcare_patient_contacts`: + +```text +id +organization_id +patient_id + +contact_type +value +is_primary + +created_at +updated_at +``` + +### Patient Address + +`healthcare_patient_addresses`: + +```text +id +organization_id +patient_id + +address_type +line_1 +line_2 +city +region +postal_code +country_code + +is_primary + +created_at +updated_at +``` + +Sensitive subresources remain permission-controlled. + +## 47. Healthcare Practitioners + +Suggested fields: + +```text +id +organization_id +user_id +professional_profile_id + +specialty +status + +created_at +updated_at +``` + +Professional licenses are not duplicated here. + +Multiple licenses/credentials live in: + +```text +professional_credentials +``` + +## 48. Healthcare Appointments + +Suggested fields: + +```text +id +organization_id + +patient_id +practitioner_id + +location_id nullable +room_id nullable + +appointment_type + +starts_at +ends_at + +status +reason + +created_at +updated_at +version +``` + +Planned supporting tables: + +`healthcare_locations`: + +```text +id +organization_id +name +address fields +timezone +status +``` + +`healthcare_rooms`: + +```text +id +organization_id +location_id +name +status +``` + +Exact scheduling rules are deferred to the healthcare vertical. + +## 49. Healthcare Encounters + +Suggested fields: + +```text +id +organization_id + +patient_id +practitioner_id +appointment_id nullable + +encounter_type + +reason_for_visit nullable + +started_at +ended_at + +status + +created_at +updated_at +version +``` + +Do not add a generic free-form `notes` field as a substitute for clinical records. + +Clinical narrative belongs in governed clinical-record structures. + +## 50. Clinical Records + +Use: + +```text +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +``` + +### Clinical Record + +```text +id +organization_id +patient_id +encounter_id +author_practitioner_id + +record_type +sensitivity_level +status + +signed_by_practitioner_id +signed_at + +created_at +updated_at +version +``` + +### Clinical Record Version + +```text +id +organization_id +record_id +version_number + +content_reference or governed content payload +created_by_practitioner_id +created_at +``` + +### Clinical Record Amendment + +```text +id +organization_id +record_id +source_version_id +result_version_id + +amended_by_practitioner_id + +amendment_type +amendment_reason + +created_at +``` + +Possible amendment types: + +```text +correction +addendum +clarification +``` + +Signed/finalized history is preserved. + +REST: + +```http +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} +PATCH /api/v1/healthcare/clinical-records/{recordId} +# Draft/editable only. + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend + +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log +``` + +## 51. Documents + +Shared document infrastructure: + +```text +documents +document_versions +document_categories +retention_policies +``` + +Binary data lives in S3-compatible object storage. + +### Document + +```text +id +organization_id +name +category_id +classification +retention_policy_id +current_version_id +created_by_user_id +created_at +updated_at +``` + +Classification: + +```text +public +internal +confidential +restricted +regulated +``` + +### Document Version + +```text +id +organization_id +document_id +version_number +storage_key +mime_type +size_bytes +content_hash +hash_algorithm +uploaded_by_user_id +created_at +``` + +Checksum is version-level authoritative data. + +### Document Category + +```text +id +organization_id +profession nullable +name +parent_category_id +created_at +``` + +Uniqueness requirement: + +```text +shared category: + unique organization_id + name where profession IS NULL + +profession category: + unique organization_id + profession + name where profession IS NOT NULL +``` + +Implementation options: + +```text +PostgreSQL null-aware unique constraint when supported +or +two partial unique indexes +``` + +The partial-index fallback does not depend on selecting PostgreSQL 18. + +### Retention Policy + +```text +id +organization_id + +name +profession nullable +classification nullable + +retention_period_days nullable +action + +created_at +updated_at +``` + +Initial actions: + +```text +review +archive +delete_when_legally_permitted +retain_indefinitely +``` + +`retention_period_days` has one meaning only: + +```text +action = retain_indefinitely + → retention_period_days MUST be null + +all other actions + → retention_period_days MUST be a positive integer +``` + +Null does not mean inherit, unconfigured, or unknown. Policy inheritance or an unconfigured state must be represented outside a persisted retention-policy row and specified separately before implementation. + +A retention policy describes configured behavior. + +Actual deletion remains subject to domain, contractual, privacy, and jurisdiction requirements. + +### Metadata + +JSONB is allowed only for genuinely extensible, non-authoritative metadata. + +Do not put authorization, lifecycle, retention state, or ownership into arbitrary JSON. + +## 52. Document Upload Flow + +### Standard Upload + +Request: + +```http +POST /api/v1/documents/upload-url +``` + +```json +{ + "name": "structural-calculations.pdf", + "categoryId": null, + "classification": "confidential", + "mimeType": "application/pdf", + "sizeBytes": 2457600, + "contentHash": "sha256:..." +} +``` + +Response: + +```json +{ + "data": { + "documentId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d01", + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d02", + "uploadUrl": "https://object-storage.example/...", + "expiresAt": "2026-08-26T13:00:00Z" + } +} +``` + +The frontend uploads directly to object storage. + +Finalize: + +```http +POST /api/v1/documents/{documentId}/complete-upload +``` + +```json +{ + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d02", + "contentHash": "sha256:..." +} +``` + +### Multipart Initialization + +```http +POST /api/v1/documents/multipart-uploads +``` + +Request: + +```json +{ + "name": "building-model.bin", + "categoryId": null, + "classification": "confidential", + "mimeType": "application/octet-stream", + "sizeBytes": 2147483648, + "contentHash": null +} +``` + +Response: + +```json +{ + "data": { + "documentId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d10", + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d11", + "uploadId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d12", + "recommendedPartSizeBytes": 67108864, + "expiresAt": "2026-08-27T12:00:00Z" + } +} +``` + +### Request Signed Part URLs + +```http +POST /api/v1/documents/{documentId}/multipart-uploads/{uploadId}/parts +``` + +```json +{ + "partNumbers": [1, 2, 3, 4] +} +``` + +Response: + +```json +{ + "data": [ + { + "partNumber": 1, + "uploadUrl": "https://object-storage.example/..." + } + ] +} +``` + +Binary parts go directly to object storage. + +### Complete Multipart Upload + +```http +POST /api/v1/documents/{documentId}/multipart-uploads/{uploadId}/complete +``` + +```json +{ + "parts": [ + { + "partNumber": 1, + "etag": "..." + } + ], + "contentHash": "sha256:..." +} +``` + +Abort: + +```http +DELETE /api/v1/documents/{documentId}/multipart-uploads/{uploadId} +``` + +Upload state: + +```text +initiated +uploading +completing +completed +aborted +expired +``` + +Workers clean up abandoned multipart uploads. + +Upload policy validates: + +```text +declared MIME +extension +content signature +size +checksum +quota +classification +malware status +``` + +## 53. Profession-Specific Document Links + +Use explicit relationship tables. + +Engineering: + +```text +engineering_project_documents +engineering_design_version_documents +engineering_inspection_documents +``` + +Legal: + +```text +legal_matter_documents +legal_case_documents +``` + +Healthcare: + +```text +healthcare_patient_documents +healthcare_encounter_documents +``` + +`engineering_design_version_documents` is authoritative for files belonging to a specific design revision. + +Do not also maintain an ambiguous `engineering_design_documents` relation to the unversioned design unless a later requirement introduces a separate clearly named supporting-document relationship. + +### Project Documents + +```text +engineering_project_documents +├── id +├── organization_id +├── project_id +├── document_id +├── category +├── linked_by_user_id +├── linked_at +└── unlinked_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/documents +POST /api/v1/engineering/projects/{projectId}/documents +DELETE /api/v1/engineering/project-documents/{documentLinkId} +``` + +`DELETE` temporally unlinks the relation when history must be preserved. + +## 54. Billing + +Shared financial core: + +```text +invoices +invoice_items +payments +``` + +### Invoice + +Core fields include: + +```text +id +organization_id +client/reference context +invoice_number +status +currency_code +subtotal_minor +tax_total_minor +total_minor +issued_at +due_at +paid_at +created_at +updated_at +version +``` + +### Invoice Item + +```text +id +organization_id +invoice_id + +description + +quantity +unit_price_minor +total_amount_minor + +position + +created_at +updated_at +``` + +`quantity` uses fixed-precision numeric semantics, not floating point. + +Money uses integer minor units. + +The invoice determines currency; invoice items do not independently choose a different currency unless multi-currency invoicing is intentionally designed later. + +### Profession-Specific Source Links + +The shared billing module does not use unconstrained: + +```text +reference_type +reference_id +``` + +to profession-owned tables. + +Profession modules create explicit links, for example: + +```text +engineering_invoice_item_time_entries +├── organization_id +├── invoice_item_id +└── time_entry_id +``` + +This preserves the rule that shared core does not depend on profession-table internals. + +REST: + +```http +POST /api/v1/invoices +GET /api/v1/invoices +GET /api/v1/invoices/{invoiceId} +PATCH /api/v1/invoices/{invoiceId} + +POST /api/v1/invoices/{invoiceId}/issue +POST /api/v1/invoices/{invoiceId}/void +POST /api/v1/invoices/{invoiceId}/payments +POST /api/v1/payments/{paymentId}/refund +``` + +## 55. Money Representation + +Use integer minor units: + +```json +{ + "amountMinor": 12550, + "currency": "USD" +} +``` + +Meaning: + +```text +$125.50 +``` + +Never use floating point for money. + +--- + +## 56. Audit Logging + +Use: + +```text +audit_events +``` + +Suggested fields: + +```text +id +organization_id + +actor_type +actor_user_id nullable +actor_service_account_id nullable + +action + +resource_type +resource_id + +request_id +correlation_id + +ip_address +user_agent + +metadata + +occurred_at +``` + +Audit records are append-only from normal application workflows. + +### Mandatory Examples + +Engineering: + +```text +engineering.projects.create +engineering.projects.close +engineering.designs.approve +engineering.inspections.complete +``` + +Legal: + +```text +legal.matters.create +legal.matters.close +legal.conflicts.approve +``` + +Healthcare: + +```text +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +``` + +### Privacy / Erasure Handling + +Append-only audit does not mean "store unlimited personal data forever." + +Audit metadata must be minimized at write time. + +Where privacy, contractual, or retention obligations require removal of personally identifying material, use a governed privacy process such as: + +```text +pseudonymize actor references +null/remove nonessential PII fields +replace identifiers with irreversible privacy references where appropriate +retain the security/business event itself when permitted/required +``` + +The exact action depends on jurisdiction and retention policy and must be reviewed before healthcare/legal production. + +Do not place passwords, tokens, full clinical content, secret keys, or unnecessary payment data in audit metadata. + +REST: + +```http +GET /api/v1/audit-events +``` + +No public mutation endpoints. + +## 57. Domain Events and Transactional Outbox + +Use: + +```text +outbox_events +``` + +Fields: + +```text +id +organization_id nullable for truly global events + +event_type +aggregate_type +aggregate_id + +payload + +request_id nullable +correlation_id +causation_id nullable + +occurred_at +available_at +processed_at + +attempt_count +last_error +dead_lettered_at +``` + +`correlation_id` groups one logical workflow across requests/jobs/events. + +`causation_id` identifies the event/command that directly caused this event when applicable. + +Transaction: + +```text +BEGIN +business change +audit event +outbox event +COMMIT +``` + +Delivery semantics are at-least-once. + +Worker claim uses row locking such as: + +```sql +SELECT id +FROM outbox_events +WHERE processed_at IS NULL + AND dead_lettered_at IS NULL + AND available_at <= now() +ORDER BY occurred_at +FOR UPDATE SKIP LOCKED +LIMIT 100; +``` + +Every external side-effect consumer must be idempotent. + +`FOR UPDATE SKIP LOCKED` prevents simultaneous claiming; it does not prevent duplicate side effects after a worker crash. + +## 57A. Webhooks and External Integrations + +Shared tables: + +```text +webhooks +webhook_event_subscriptions +webhook_deliveries +``` + +### Webhook + +```text +id +organization_id +url +status +secret_ciphertext or signing_key_reference +created_by_user_id +created_at +updated_at +``` + +### Subscription + +```text +id +organization_id +webhook_id +event_type +created_at +``` + +Unique: + +```text +(organization_id, webhook_id, event_type) +``` + +Only registered externally publishable event types may be subscribed. + +### Delivery + +```text +id +organization_id +webhook_id +event_id + +attempt_number +request_timestamp +response_status +response_summary + +delivered_at +failed_at +next_attempt_at +``` + +`event_id` references `outbox_events.id`. Because webhook deliveries are tenant-owned, only publishable outbox events with the same non-null `organization_id` may be delivered: + +```text +(organization_id, event_id) +→ outbox_events(organization_id, id) +``` + +The webhook publisher allowlists externally publishable `event_type` values before creating delivery records. The stable outbox event ID is also the consumer deduplication key. + +Configuration REST: + +```http +GET /api/v1/webhooks +POST /api/v1/webhooks +GET /api/v1/webhooks/{webhookId} +PATCH /api/v1/webhooks/{webhookId} +DELETE /api/v1/webhooks/{webhookId} + +POST /api/v1/webhooks/{webhookId}/test +POST /api/v1/webhooks/{webhookId}/rotate-secret +``` + +Delivery REST: + +```http +GET /api/v1/webhook-deliveries +GET /api/v1/webhook-deliveries/{deliveryId} +POST /api/v1/webhook-deliveries/{deliveryId}/retry +``` + +If HMAC signing is used, signing material is encrypted/recoverable with managed key protection. + +A one-way secret hash is insufficient for outbound HMAC signing. + +Webhook consumers deduplicate using stable event IDs. + +## 58. Background Jobs + +Workers handle: + +```text +notifications +reports/PDFs +file scanning +document processing +imports +exports +bulk operations +webhooks +search indexing +large data operations +``` + +Use shared tenant-owned: + +```text +jobs +``` + +Fields: + +```text +id +organization_id +requested_by_user_id + +job_type +status + +input_reference +result_reference +progress_percent + +created_at +started_at +completed_at +failed_at + +error_code +error_summary +``` + +`input_reference` and `result_reference` are nullable typed JSONB reference envelopes, not arbitrary blobs or public URLs. Their schema is registered per `job_type`. + +Allowed reference kinds initially include: + +```text +document_version +object_storage_key +query_snapshot +job +``` + +Object-storage references contain internal storage keys; APIs generate time-limited signed URLs when access is authorized. Resource IDs inside an envelope are validated for tenant ownership when the job is created. Large inputs and outputs live in documents or object storage rather than inside the job row. + +States: + +```text +queued +running +completed +failed +cancelled +``` + +REST: + +```http +GET /api/v1/jobs/{jobId} +GET /api/v1/jobs/{jobId}/result +POST /api/v1/jobs/{jobId}/cancel +``` + +These endpoints are tenant-scoped through the standard: + +```http +X-Organization-Id +``` + +They do not need `/organizations/{id}/jobs` because the platform already chose header-based tenant context. + +Large import/export operations return: + +```http +202 Accepted +``` + +with a job ID. + +## 59. Redis + +Use Redis as an acceleration and coordination layer, not the authoritative system of record. + +Appropriate uses: + +```text +job queue +rate-limit counters +short-lived authorization caches +organization configuration cache +session lookup acceleration +idempotency lookup acceleration +distributed locks when justified +``` + +### Cache Layers + +L1 optional application-memory cache: + +```text +static permission definitions +non-sensitive configuration +``` + +L2 Redis shared cache: + +```text +organization settings +membership snapshots +role permission snapshots +rate-limit counters +session lookup cache +recent idempotency lookups +``` + +CDN: + +```text +frontend static assets +explicitly public assets only +``` + +Do not cache private professional API responses at a CDN by default. + +### Cache Invalidation + +Invalidate or version caches when: + +```text +membership changes +role permissions change +organization settings change +professional credentials change +session is revoked +profession module enablement changes +``` + +High-risk authorization decisions must not depend solely on stale cached credential state. + +### Idempotency Durability + +Redis may improve idempotency lookup latency, but PostgreSQL remains authoritative for high-risk commands. + +## 60. Pagination + +Use cursor pagination. + +Defaults: + +```text +default limit = 25 +maximum limit = 100 +offset pagination = not supported +``` + +Example: + +```http +GET /api/v1/engineering/projects?limit=25 +``` + +Response: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Rules: + +```text +cursor is opaque +sort order must be deterministic +cursor encodes/represents the selected sort position +unsupported limits return validation errors rather than silent huge responses +``` + +## 61. Filtering + +Use explicit resource-specific filters. + +Examples: + +```http +GET /api/v1/engineering/projects?status=active&discipline=structural +GET /api/v1/engineering/tasks?status=todo&assignedToUserId=0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d +``` + +Do not build a generic query DSL in v1. + +--- + +## 62. Sorting + +Examples: + +```http +GET /api/v1/engineering/projects?sort=createdAt +GET /api/v1/engineering/projects?sort=-createdAt +``` + +Only explicitly supported fields may be sorted. + +--- + +## 63. Search + +Start with PostgreSQL search. + +Engineering search may cover: + +```text +project number +project name +client name +``` + +Legal: + +```text +matter number +client +case number +``` + +Healthcare: + +```text +patient number +patient identity +``` + +Healthcare search requires stricter privacy and authorization controls. + +Potential PostgreSQL capabilities: + +- B-tree indexes for exact/filter queries +- PostgreSQL full-text search where appropriate +- `pg_trgm` only when fuzzy search requirements justify it + +Do not introduce Elasticsearch/OpenSearch until real query volume, relevance requirements, or indexing features justify another distributed system. + +Do not create every conceivable search index on day one. Indexes cost memory, storage, and write performance. + +## 64. Optimistic Concurrency + +Important mutable resources should use a version field. + +Example: + +```json +{ + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c5d", + "version": 6 +} +``` + +Update: + +```json +{ + "version": 6, + "name": "Central Tower Phase II" +} +``` + +If the current database version differs: + +```text +409 CONCURRENT_MODIFICATION +``` + +--- + +## 65. Domain-Oriented REST + +Important state transitions use explicit command endpoints. + +Good: + +```http +POST /engineering/projects/{id}/close +POST /engineering/designs/{id}/approve +POST /engineering/tasks/{id}/complete +POST /engineering/inspections/{id}/complete +POST /invoices/{id}/issue +``` + +Avoid: + +```http +PATCH /resource/{id} +{ + "status": "approved" +} +``` + +when the change has significant rules or side effects. + +--- + +## 66. Transaction Boundaries + +Create project: + +```text +BEGIN + +create project +assign project manager +write audit event +write outbox event + +COMMIT +``` + +Approve design: + +```text +BEGIN + +validate permission +validate project access +validate credentials +validate design state +create review result +mark approved +write audit event +write outbox event + +COMMIT +``` + +--- + +## 67. Request Context + +Every authenticated request should resolve: + +```text +RequestContext +{ + requestId + userId + sessionId + organizationId + membershipId + permissions +} +``` + +Profession modules consume this context. + +--- + +## 68. Request IDs + +Every request has: + +```http +X-Request-Id +``` + +If missing, the server generates one. + +Use it in: + +- logs +- audit context +- error diagnostics +- asynchronous correlation + +--- + +## 69. OpenAPI + +Maintain: + +```text +openapi.yaml +``` + +Use OpenAPI 3.1. + +Production server example: + +```yaml +servers: + - url: https://api.example.com/api/v1 +``` + +The server URL and path definitions must remain consistent with the platform base path. + +OpenAPI defines: + +- routes +- request DTOs +- response DTOs +- security schemes +- organization header +- request IDs +- idempotency header +- pagination +- filters +- error schemas +- examples +- profession tags + +Security scheme: + +```yaml +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +``` + +Reusable headers/parameters: + +```text +X-Organization-Id +X-Request-Id +Idempotency-Key +limit +cursor +``` + +CI must validate the OpenAPI document. + +Contract tests should detect drift between implementation and specification. + +Generated clients may be used by the separate frontends, but generated transport code should not dictate frontend domain architecture. + +## 70. DTO Rule + +Database models are not public API contracts. + +Use: + +```text +Request DTO +Response DTO +``` + +A database migration should not accidentally change the public API. + +--- + +## 71. Backend Module Structure + +Recommended: + +```text +src/ +├── core/ +│ ├── auth/ +│ ├── organizations/ +│ ├── memberships/ +│ ├── authorization/ +│ ├── documents/ +│ ├── billing/ +│ ├── audit/ +│ └── events/ +│ +├── engineering/ +│ ├── clients/ +│ ├── projects/ +│ ├── project-members/ +│ ├── phases/ +│ ├── sites/ +│ ├── tasks/ +│ ├── designs/ +│ ├── inspections/ +│ └── specifications/ +│ +├── legal/ +│ ├── clients/ +│ ├── matters/ +│ ├── cases/ +│ ├── hearings/ +│ ├── conflicts/ +│ └── retainers/ +│ +└── healthcare/ + ├── patients/ + ├── practitioners/ + ├── appointments/ + ├── encounters/ + ├── records/ + └── prescriptions/ +``` + +--- + +## 72. Internal Module Structure + +Example: + +```text +projects/ +├── domain/ +│ ├── project.entity.ts +│ ├── project-status.ts +│ └── project.errors.ts +│ +├── application/ +│ ├── commands/ +│ │ ├── create-project.ts +│ │ ├── update-project.ts +│ │ └── close-project.ts +│ │ +│ └── queries/ +│ ├── get-project.ts +│ └── list-projects.ts +│ +├── infrastructure/ +│ └── project.repository.ts +│ +└── api/ + ├── project.controller.ts + ├── project.request.ts + └── project.response.ts +``` + +--- + +## 73. Controllers + +Controllers should handle: + +```text +HTTP +authentication context +input DTO parsing +application command/query invocation +response mapping +``` + +Controllers should not contain: + +```text +business rules +raw SQL +role logic +transaction orchestration +email sending +audit implementation +``` + +--- + +## 74. Commands and Queries + +Mutations use commands. + +Examples: + +```text +CreateEngineeringProjectCommand +ApproveEngineeringDesignCommand +CloseLegalMatterCommand +CompleteHealthcareEncounterCommand +``` + +Reads use queries. + +Examples: + +```text +GetEngineeringProjectQuery +ListLegalMattersQuery +GetHealthcarePatientQuery +``` + +--- + +## 75. Repositories + +Use domain-specific repositories. + +Examples: + +```text +EngineeringProjectRepository +LegalMatterRepository +HealthcarePatientRepository +``` + +Avoid one massive generic repository abstraction that eventually needs dozens of flags. + +--- + +## 76. Security Baseline + +Minimum controls: + +```text +TLS everywhere +strong password hashing +short-lived access tokens +refresh-token rotation/reuse detection +server-side session revocation + +rate limiting +anti-automation controls + +RBAC +resource policies +credential-aware authorization +tenant isolation + +input validation +SQL injection protection + +signed object-storage URLs +file-content validation +malware scanning + +audit trails +secret management +encryption at rest + +dependency/image scanning + +request/correlation IDs +backup and restore testing +``` + +### Web Security / CORS + +ADR-009 defines environment-specific web security. + +Baseline requirements: + +```text +explicit CORS allowlist +no wildcard credentialed CORS +allowed methods/headers documented +preflight behavior tested +HSTS at the edge for production HTTPS +X-Content-Type-Options: nosniff +secure cookie attributes when cookies are used +CSP on browser frontends +frame-ancestor/clickjacking policy on frontends +referrer policy appropriate to the frontend +``` + +Security headers belong at the appropriate application/CDN/gateway layer. + +### Rate Limiting + +Policies are endpoint-specific and configurable. + +Return: + +```http +429 Too Many Requests +Retry-After: ... +``` + +### Secrets + +Production secrets live outside source control, preferably in managed secret/key systems. + +JWT signing keys support rotation. + +## 77. Data Classification + +Suggested classes: + +### Public + +```text +marketing configuration +``` + +### Internal + +```text +organization settings +tasks +``` + +### Confidential + +```text +engineering documents +legal matters +billing +``` + +### Highly Sensitive + +```text +clinical records +professional credentials +authentication secrets +``` + +--- + +## 78. Healthcare Security + +Before healthcare production use, define: + +```text +privacy model +minimum-necessary access model +clinical access policies +break-glass/emergency access policy if required +audit policy +record-signing policy +amendment policy +retention policy +credential policy +scope-of-practice policy +jurisdiction requirements +encryption strategy +consent requirements +data residency requirements +backup/restore handling +export/portability requirements +breach-response requirements +``` + +Healthcare is a stricter security tier. + +Key rules: + +1. default patient responses do not contain all available PHI +2. clinical record reads may be auditable events +3. signed records are immutable except through explicit amendment/version workflows +4. prescribing authorization is jurisdiction-specific +5. privileged clinical commands revalidate professional authority +6. caches must not allow revoked credentials to remain effective for high-risk writes +7. healthcare search results themselves are protected data +8. access logs may require dedicated permissions +9. do not claim regulatory compliance from architecture alone + +## 79. Observability + +Use: + +```text +structured logs +metrics +distributed tracing +request IDs +correlation IDs +``` + +Recommended: + +```text +OpenTelemetry +``` + +### Core Metrics + +API: + +```text +api_requests_total +api_errors_total +api_request_duration_seconds +``` + +Authentication: + +```text +auth_login_attempts_total +auth_token_refresh_total +auth_refresh_reuse_detections_total +auth_sessions_revoked_total +``` + +Authorization/security: + +```text +cross_tenant_access_attempts_total +tenant_isolation_invariant_failures_total +authorization_denials_total +credential_policy_denials_total +rate_limit_events_total +``` + +Important distinction: + +```text +cross_tenant_access_attempt += +request attempted another tenant's resource +``` + +This may be a stale link, mistake, or attack. + +```text +tenant_isolation_invariant_failure += +our system nearly or actually created/returned cross-tenant data +``` + +That is a high-severity internal correctness/security incident. + +Outbox/jobs/webhooks: + +```text +outbox_events_pending +outbox_events_failed_total +outbox_processing_duration_seconds + +jobs_queued +jobs_failed_total +job_duration_seconds + +webhook_delivery_attempts_total +webhook_delivery_failures_total +webhook_delivery_latency_seconds +``` + +Database: + +```text +db_pool_active +db_pool_waiting +db_query_duration_seconds +db_transaction_duration_seconds +``` + +Business metrics may include: + +```text +engineering_projects_created_total +engineering_designs_approved_total +engineering_inspections_completed_total +invoices_issued_total +``` + +Avoid patient-specific or sensitive identifiers in metric labels. + +### Alerts + +Examples: + +```text +refresh token reuse detected +tenant isolation invariant failure +outbox backlog exceeds SLO +webhook failure spike +database pool saturation +error-rate spike +latency regression +backup failure +malware scanner unavailable +``` + +Thresholds are calibrated from real environments rather than copied from a review document. + +### SLOs + +Define by endpoint class. + +Interactive CRUD, reports, file orchestration, and background jobs should not share one arbitrary latency target. + +## 80. Logging + +Useful fields: + +```text +request_id +route +method +status +duration +user_id when appropriate +organization_id when appropriate +``` + +Never log: + +```text +passwords +tokens +clinical record text +full sensitive documents +payment secrets +``` + +--- + +## 81. Testing Strategy + +### Unit Tests + +Test: + +```text +domain rules +state transitions +authorization policies +credential policies +money calculations +idempotency request hashing +``` + +### Property-Based Tests + +Use property-based testing for high-value domain state machines. + +Candidates: + +```text +engineering design lifecycle +engineering inspection lifecycle +invoice lifecycle +payment state transitions +membership/role invariants +``` + +Correct properties: + +```text +every successful transition ends in a valid state + +every forbidden transition is rejected + +terminal states reject prohibited actions + +required invariants survive every valid transition + +transition sequences never bypass required approval/credential rules +``` + +Do not assert that every random state/action pair succeeds. Many are supposed to fail. + +### Integration Tests + +Test: + +```text +repositories +tenant-aware foreign keys +PostgreSQL constraints +transactions +outbox persistence +idempotency persistence +cache invalidation +job persistence +webhook delivery persistence +``` + +### API Tests + +Every important endpoint covers: + +```text +happy path +request validation +authentication +organization context +permission denial +scope denial +credential denial where relevant +cross-tenant access +concurrent modification +invalid state transition +idempotent replay +idempotency conflict +audit creation +outbox creation +``` + +### Outbox Reliability / Chaos Tests + +Test: + +```text +worker crash before side effect +worker crash after side effect but before marking processed +two workers competing for same row +temporary dependency outage +retry/backoff behavior +dead-letter behavior +consumer idempotency +lost worker wake-up +replay +``` + +The dangerous scenario is: + +```text +external side effect succeeds +worker dies +event retries +``` + +Tests must prove the consumer does not create an unacceptable duplicate. + +### Tenant Security Tests + +Test both: + +```text +external cross-tenant access attempts +``` + +and: + +```text +internal cross-tenant data invariant failures +``` + +These are different classes of failure. + +### Performance Tests + +Create realistic profiles: + +```text +interactive reads +interactive writes +search +dashboard read models +reporting +file upload orchestration +outbox processing +webhook bursts +notification bursts +``` + +Measure: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +``` + +Set production SLO gates only after a realistic baseline exists. + +### Coverage + +Track code coverage. + +Do not treat a single percentage such as `90%` as proof of quality. + +Critical-path expectations are stronger: + +```text +all tenant-isolation paths tested +all financial commands tested +all regulated commands tested +all state transitions tested +all critical authorization policies tested +``` + +## 82. Tenant Security Tests + +For every major resource, attempt: + +```text +Organization A resource +using Organization B context +``` + +Test: + +```text +read +update +delete/action +list filtering +search +documents +``` + +Expected result: + +```text +404 / denied +``` + +--- + +## 83. Engineering MVP + +Engineering is the first vertical. + +Initial features: + +```text +Authentication +Organization management +Users / memberships / roles +Engineering clients +Projects +Project members +Project phases +Tasks +Sites +Documents +Basic design records +Inspections +Time entries +Basic billing +Audit history +``` + +Do not initially build: + +```text +advanced CAD integration +BIM integration +full document markup +advanced resource planning +procurement +complex accounting +AI design analysis +IoT integrations +``` + +--- + +## 84. Engineering MVP Workflow + +```text +User registers + ↓ +Creates engineering organization + ↓ +Invites engineer + ↓ +Assigns role + ↓ +Creates client + ↓ +Creates project + ↓ +Assigns project team + ↓ +Creates project phases + ↓ +Creates tasks + ↓ +Uploads documents + ↓ +Creates design + ↓ +Reviews / approves design + ↓ +Schedules inspection + ↓ +Records inspection findings + ↓ +Records engineering time + ↓ +Creates invoice + ↓ +Records payment + ↓ +Closes project + ↓ +Audit history contains lifecycle +``` + +--- + +## 85. Development Phases + +### Phase 0: Architecture Foundation + +Deliver: + +```text +domain boundaries +database conventions +REST conventions +authorization model +session/token model +idempotency strategy +error taxonomy +OpenAPI skeleton +engineering state machines +migration conventions +threat model +initial ADRs +risk register +``` + +### Phase 1: Shared Platform Core + +Build: + +```text +auth +sessions +refresh-token families +token rotation/revocation + +users +organizations +organization professions + +membership invitations +memberships +roles +permissions +authorization + +audit +outbox + +request context +idempotency +rate limiting +observability +``` + +### Phase 2: Engineering CRM + +Build: + +```text +engineering clients +engineering client contacts +client archive/restore +``` + +### Phase 3: Engineering Projects + +Build: + +```text +projects +project members +project phases +activation/close/archive +``` + +### Phase 4: Work and Site Management + +Build: + +```text +tasks +task batch operations +sites +``` + +### Phase 5: Documents + +Build: + +```text +documents +versions +categories +classification +retention references +signed uploads +multipart uploads +content verification +malware scanning +engineering document links +``` + +### Phase 6: Engineering Designs + +Build: + +```text +designs +assignments +versions +reviews +cancel/withdraw semantics +credential-aware approval +audit +outbox +idempotency +``` + +### Phase 7: Engineering Inspections + +Build: + +```text +inspection lifecycle +inspection outcome +findings +corrective work +follow-up inspections +attachments +audit +outbox +idempotency +``` + +### Phase 8: Time, Budgets, and Billing + +Build: + +```text +time entries +batch timesheet submission +project budgets when required +invoices +payments +financial idempotency +reconciliation +``` + +### Phase 9: Notifications, Jobs, and Webhooks + +Build: + +```text +notifications +email +async jobs +imports/exports +webhooks +delivery/retry +dead-letter handling +``` + +### Phase 10: Reporting and Search + +Build: + +```text +project status +overdue work +inspection status +billable time +revenue +outstanding invoices +dashboard read models +``` + +### Phase 11: Engineering Client Portal + +Build: + +```text +portal account invitations +external project grants +published project documents +client review/acceptance workflow +portal audit +portal-specific frontend +``` + +Do not expose professional approval actions to client portal accounts. + +### Phase 12: Legal Vertical + +Validate shared core against: + +```text +matters +cases +conflicts +deadlines +retainers +restricted access / ethical walls +``` + +### Phase 13: Healthcare Readiness and Vertical + +Before implementation: + +```text +healthcare threat model +privacy review +jurisdiction analysis +scope-of-practice policy +record signing/amendment model +retention model +audit requirements +``` + +### Estimation Rule + +These are dependency-ordered milestones. + +They are not calendar promises. + +Calendar estimates require: + +```text +team size +frontend/UX scope +cloud decisions +third-party providers +security requirements +QA capacity +domain-expert availability +``` + +## 86. Legal Expansion + +Only after engineering proves the shared platform assumptions. + +Build: + +```text +Legal Client + ↓ +Matter + ↓ +Case + ↓ +Hearings / Deadlines / Documents +``` + +Do not redesign engineering around legal terminology. + +Extract only genuinely reusable infrastructure. + +--- + +## 87. Healthcare Expansion + +Healthcare comes after: + +- core platform is stable +- audit model is proven +- permission model is proven +- tenant isolation is tested +- retention and encryption strategies are defined + +Healthcare should be treated as its own security and compliance workstream. + +--- + +## 88. Deployment Environments + +Use: + +```text +development +testing +staging +production +``` + +Each environment has independent: + +```text +database +object storage +secrets +queues +API keys +``` + +--- + +## 89. Initial Deployment Architecture + +```text +CDN + │ + ├── Engineering Web + ├── Legal Web + └── Healthcare Web + +Load Balancer + │ + Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Queue + │ + Workers +``` + +Prefer managed infrastructure where practical. + +--- + +## 90. Backup Strategy + +Database: + +```text +automated backups +point-in-time recovery +tested restores +``` + +Object storage: + +```text +versioning +retention policies +backup or replication where required +``` + +A backup strategy is incomplete until restoration is tested. + +--- + +## 91. Migration Strategy + +Use explicit immutable migration files. + +Recommended naming: + +```text +YYYYMMDDHHMMSS_description.sql +``` + +Example: + +```text +20260826010000_create_organizations.sql +20260826011000_create_users.sql +20260826012000_create_memberships.sql +20260826013000_create_rbac.sql +20260826014000_create_audit_outbox.sql +20260826015000_create_engineering_clients.sql +``` + +### UUID Standard + +The platform uses UUIDv7. + +Supported implementation choices: + +```text +PostgreSQL 18+: + use native uuidv7() if database-generated identifiers are desired + +Earlier PostgreSQL: + generate UUIDv7 in the application or use a controlled extension +``` + +Database columns remain PostgreSQL `UUID`. + +The rule is consistency, not ideological loyalty to one generation layer. + +Do not silently fall back to UUIDv4 while documenting UUIDv7. + +### Production Migration Rules + +Use expand/contract: + +```text +1. add backward-compatible schema +2. deploy code supporting old + new schema +3. backfill/migrate +4. switch reads/writes +5. observe +6. remove obsolete schema later +``` + +For destructive changes: + +```text +backup/restore plan +compatibility window +production-like dry run +explicit approval +post-migration verification +``` + +Do not assume a destructive database migration can always be reversed by a simple down migration. + +Never use automatic ORM schema synchronization in production. + +## 91A. Architecture Decision Records + +v4 stops treating technology suggestions as automatically settled architecture. + +Create ADRs before implementation locks in: + +```text +ADR-001 Backend Framework +ADR-002 SQL / ORM / Query Layer +ADR-003 Queue Implementation +ADR-004 PostgreSQL Minimum Version +ADR-005 Error Format / RFC 9457 Compatibility +ADR-006 Rate-Limit Header Convention +ADR-007 Webhook Signing Strategy +ADR-008 Object Storage Provider / Multipart Strategy +ADR-009 Web Security / CORS / Browser Headers +ADR-010 Machine Authentication / API Key Policy +``` + +Each ADR should include: + +```text +context +decision +alternatives considered +tradeoffs +security impact +operational impact +migration/exit path +date +status +``` + +The architecture currently fixes capabilities and boundaries. + +It does not require a framework merely because a review document described it positively. + +--- + +## 92. Technology Recommendation + +The following are preferred candidates, not all final decisions. + +### Fixed Platform Choices + +```text +API style: REST +Contract: OpenAPI 3.1 +Primary language: TypeScript +Primary database: PostgreSQL +Architecture: Modular Monolith +Observability standard: OpenTelemetry +Object storage model: S3-compatible +Container model: Docker/OCI +``` + +### ADR-Gated Choices + +Backend framework candidates: + +```text +NestJS +Fastify-centered custom application structure +``` + +SQL / persistence candidates: + +```text +Drizzle +Kysely +Prisma +direct SQL for specialized queries +``` + +Queue candidates: + +```text +BullMQ / Redis +managed cloud queue +``` + +PostgreSQL baseline: + +```text +PostgreSQL 18+ +``` + +is attractive because of native UUIDv7 and current capabilities, but the minimum supported version must be confirmed against: + +```text +hosting provider availability +operations policy +extension requirements +upgrade policy +support lifecycle +``` + +Do not claim one ORM is categorically "faster" or "better" without workload-specific evidence. + +The selected stack should preserve: + +```text +transaction control +explicit SQL visibility +tenant-safe query design +migration control +observability +testability +``` + +## 93. REST API Milestones + +### Milestone 1: Platform Access and Security + +```http +POST /auth/register +POST /auth/login + +POST /auth/token/refresh +POST /auth/token/revoke +POST /auth/token/revoke-all + +GET /auth/sessions +DELETE /auth/sessions/{sessionId} + +GET /me + +POST /organizations +GET /me/organizations + +POST /membership-invitations +GET /memberships + +GET /roles +POST /roles +GET /permissions +``` + +Includes: + +```text +explicit organization context +session revocation +refresh-token reuse detection +audit foundation +outbox foundation +idempotency foundation +rate limiting +``` + +### Milestone 2: Engineering Clients + +```http +GET /engineering/clients +POST /engineering/clients +GET /engineering/clients/{id} +PATCH /engineering/clients/{id} +POST /engineering/clients/{id}/archive +POST /engineering/clients/{id}/restore +GET /engineering/clients/{id}/projects +``` + +### Milestone 3: Engineering Projects + +```http +GET /engineering/projects +POST /engineering/projects +GET /engineering/projects/{id} +PATCH /engineering/projects/{id} + +POST /engineering/projects/{id}/activate +POST /engineering/projects/{id}/close +POST /engineering/projects/{id}/archive + +GET /engineering/projects/{id}/summary +``` + +Timeline and budget read models follow when the frontend requires them. + +### Milestone 4: Collaboration + +```http +POST /engineering/projects/{id}/members +GET /engineering/projects/{id}/members + +POST /engineering/tasks +GET /engineering/tasks +POST /engineering/tasks/{id}/complete +``` + +### Milestone 5: Sites and Documents + +Build: + +```text +engineering sites +signed file uploads +document versions +malware scanning +project document links +``` + +### Milestone 6: Designs + +Build: + +```text +design lifecycle +versions +reviews +submit-review +request-changes +approve +reject +supersede +credential validation +audit + outbox + idempotency +``` + +### Milestone 7: Inspections + +Build: + +```text +schedule +start +complete +cancel +findings +finding resolution +audit + outbox + idempotency +``` + +### Milestone 8: Commercial Workflows + +Build: + +```text +time entries +invoices +payments +refunds +financial idempotency +reports +``` + +## 94. Architecture Rules to Freeze + +1. REST is the primary frontend and integration API. +2. Base path is `/api/v1`. +3. OpenAPI 3.1 is the public API contract. +4. GraphQL is not part of v1. +5. Start as one modular monolith backend. +6. Each profession has its own frontend. +7. Each profession owns its domain tables and state machines. +8. Shared modules provide infrastructure, not forced domain abstractions. +9. Public serialized IDs are raw UUIDv7. +10. Database ID columns use PostgreSQL UUID. +11. Human-readable business references are separate from resource IDs. +12. Every tenant-owned row carries direct `organization_id`. +13. Tenant-scoped requests require explicit `X-Organization-Id`. +14. Tenant boundaries are enforced in queries and database constraints. +15. Cross-tenant resources appear nonexistent. +16. API JSON/query parameter names use camelCase; DB identifiers use snake_case. +17. Authorization is server-side and deny-by-default. +18. `assigned` scope is defined per resource policy, never inferred generically. +19. Roles and professional credentials are separate. +20. A professional profile may own multiple credentials. +21. Sessions and refresh tokens are separate resources. +22. Refresh tokens rotate within families and support reuse detection. +23. Machine identities use service accounts/API keys, not fake human memberships. +24. Important domain transitions use explicit REST command endpoints. +25. High-risk commands use durable idempotency. +26. Batch custom actions use `/{collection}/batch/{action}`. +27. Every batch defines atomic or partial semantics. +28. Every batch item receives independent authorization/domain validation. +29. Large batches become asynchronous jobs. +30. Project phases are authoritative; duplicated project `stage` is not stored. +31. Project budgets use the dedicated budget model; project `budget_minor` is not authoritative. +32. Engineering time entries may attribute time to one explicit primary work item using tenant- and project-consistent composite foreign keys. +33. Design versions and engineering specifications use explicit document-link tables with one-to-many cardinality. +34. All design/review/version/finding/follow-up subresources carry `organization_id`. +35. Inspection lifecycle and inspection outcome are separate. +36. Inspection follow-ups are explicit resources. +37. Engineering change requests remain deferred until fully specified. +38. Shared documents own document records; profession modules own link tables. +39. Legal does not duplicate shared document ownership. +40. Large files use object-storage multipart uploads. +41. Application servers do not proxy multi-gigabyte chunks. +42. Document checksums belong to document versions. +43. Document classification is multi-level. +44. Document retention is explicit policy. +45. Document category uniqueness must work for nullable profession values on the selected PostgreSQL version. +46. Project document linkage does not imply client-portal publication. +47. External publication requires explicit publication records. +48. Client portal accounts are not internal memberships. +49. Client acceptance is not professional engineering approval. +50. Domain events use a transactional outbox. +51. Outbox delivery is at-least-once. +52. Outbox events carry correlation/causation identifiers. +53. External side-effect consumers are idempotent. +54. Webhook subscriptions and deliveries are tenant-owned. +55. HMAC signing secrets are securely recoverable/encrypted, not only hashed. +56. Jobs are tenant-scoped by the standard organization header. +57. PostgreSQL is the authoritative transactional datastore. +58. Redis is acceleration/coordination, not critical source of truth. +59. Search starts with PostgreSQL. +60. Collections use cursor pagination, default 25 and max 100. +61. Important mutable resources use optimistic concurrency. +62. Database entities are not serialized directly. +63. Errors use stable codes. +64. `429` responses use `Retry-After`; exact quota headers are an API decision. +65. Business records use explicit archive/revoke/unlink/hard-delete lifecycle policies. +66. Financial/professional/audit records are not casually hard-deleted. +67. Audit metadata is minimized and supports governed privacy transformation when required. +68. Important/regulated actions are audited. +69. Signed clinical records use sign/amend/version workflows. +70. Prescribing authority remains jurisdiction/scope-of-practice policy. +71. Production migrations use expand/contract. +72. Destructive changes are not assumed trivially reversible. +73. Secrets remain outside source control. +74. CORS and browser security policy are explicit ADR/configuration. +75. Rate limits are calibrated by evidence. +76. CI validates types, tests, OpenAPI, migrations, and security checks. +77. Property-based tests cover high-value state machines. +78. Outbox/job/webhook reliability is tested under failure/concurrency. +79. Critical-path tests matter more than vanity coverage percentages. +80. Framework/ORM/queue/PostgreSQL-minimum choices require ADRs. +81. Engineering is the first vertical. +82. Client portal follows internal Engineering MVP foundations. +83. Legal follows after Engineering validates shared assumptions. +84. Healthcare requires dedicated privacy/security/domain design before implementation. +85. Architecture documentation never equates "designed for" with "certified/compliant". + +## 95. Required Design Artifacts + +Maintain: + +```text +01_PROJECT_ARCHITECTURE.md +02_DATABASE_CONVENTIONS.md +03_AUTHORIZATION_MODEL.md +04_AUTH_SESSION_MODEL.md + +05_ENGINEERING_DOMAIN.md +06_ENGINEERING_DATABASE_SCHEMA.md +07_ENGINEERING_STATE_MACHINES.md + +08_API_CONVENTIONS.md +09_ENGINEERING_API_SPEC.md +10_OPENAPI.yaml + +11_FRONTEND_ARCHITECTURE.md +12_CLIENT_PORTAL_SECURITY_MODEL.md + +13_DOCUMENT_SECURITY_MODEL.md +14_LARGE_FILE_UPLOAD_MODEL.md + +15_WEBHOOK_INTEGRATION_MODEL.md +16_ASYNC_JOB_MODEL.md + +17_SECURITY_MODEL.md +18_DEPLOYMENT_ARCHITECTURE.md +19_OBSERVABILITY_MODEL.md +20_TESTING_STRATEGY.md + +21_ARCHITECTURE_DECISION_RECORDS/ +22_RISK_REGISTER.md +23_MVP_BACKLOG.md +``` + +Important ADRs: + +```text +backend framework +persistence/query layer +queue implementation +PostgreSQL minimum version +error format +rate-limit headers +webhook signing +object-storage provider +``` + +## 96. Recommended Implementation Order + +```text +Foundation + ↓ +Authentication + ↓ +Organizations + ↓ +Memberships + ↓ +RBAC + ↓ +Engineering Clients + ↓ +Engineering Projects + ↓ +Project Team + ↓ +Tasks + ↓ +Sites + ↓ +Documents + ↓ +Designs + ↓ +Inspections + ↓ +Time Tracking + ↓ +Billing + ↓ +Notifications + ↓ +Reports + ↓ +Legal Vertical + ↓ +Healthcare Vertical +``` + +--- + +## 97A. Database Indexing Strategy + +All tenant-owned tables need efficient tenant scoping. + +Baseline: + +```text +(organization_id, id) +``` + +Common list access often benefits from: + +```text +(organization_id, created_at) +``` + +Query-specific examples: + +```text +(organization_id, status) +(organization_id, client_id) +(organization_id, project_id) +(organization_id, assigned_to_user_id) +``` + +### Rules + +1. every index corresponds to a known query, ordering, or constraint +2. column order follows real predicates +3. validate with `EXPLAIN (ANALYZE, BUFFERS)` +4. include production-like cardinality in testing +5. measure write amplification +6. do not index every field +7. introduce trigram/full-text indexes only for actual search requirements + +Potential later tools: + +```text +covering indexes +materialized views +read replicas +table partitioning +external search +``` + +These are evidence-driven scaling mechanisms, not baseline dependencies. + +### Document Category Uniqueness + +If a nullable field such as profession participates in uniqueness: + +```text +organization_id +profession nullable +name +``` + +do not assume plain uniqueness treats NULL as one shared value. + +Use PostgreSQL-supported null-aware uniqueness or partial unique indexes according to the selected PostgreSQL version. + +--- + +## 97B. CI/CD and Deployment Gates + +Pipeline stages: + +```text +lint/typecheck + ↓ +unit tests + ↓ +integration tests + ↓ +OpenAPI validation + contract tests + ↓ +security/dependency scan + ↓ +container build + image scan + ↓ +migration compatibility check + ↓ +deploy development + ↓ +smoke tests + ↓ +deploy staging + ↓ +E2E + performance/security baseline + ↓ +manual production approval + ↓ +production deployment + ↓ +post-deploy verification +``` + +Production deployment should support: + +```text +rolling or blue/green application deployment +backward-compatible database migrations +health checks +fast application rollback +feature flags for incomplete features +observability gates +``` + +Database schema rollback is not treated as equivalent to application rollback. + + +### Feature Flags + +Feature flags used for deployment safety are operational configuration, not automatically a business database table. + +Initial implementation may use: + +```text +environment/config-service flags +``` + +for global rollout and kill switches. + +If per-organization feature rollout is later required, introduce an explicit tenant-owned model such as: + +```text +organization_feature_flags +``` + +through an ADR/migration. + +Do not overload `organization_professions` with unrelated product experiments. + + +### Configuration and Secrets + +Non-secret configuration may use environment variables. + +Secrets should use a managed secret store where possible: + +```text +database credentials +Redis credentials +JWT/private signing keys +object storage credentials +SMTP/API provider credentials +monitoring credentials +``` + +Do not publish real secrets in sample configuration. + +Organization profession enablement remains primarily data-driven through `organization_professions`. + +Global feature flags may be used for staged rollout, kill switches, or incomplete features. + +--- + +## 97C. Review-Driven Deferred Decisions + +The following ideas are valid possibilities but are explicitly **not frozen into v1**: + +```text +read replicas +materialized views +Elasticsearch/OpenSearch +universal 100 MB file limit +fixed 100 req/min user limit +fixed 1000 req/hour organization limit +specific cache-hit-ratio target +specific p95 latency promise +database-per-tenant +microservices +GraphQL +``` + +These require evidence from: + +```text +load tests +security analysis +customer requirements +compliance requirements +real production workloads +``` + +This prevents benchmark-shaped guesses from becoming architecture law. + +--- + +## 97D. Provisional Performance Objectives + +Performance numbers in architecture are starting hypotheses, not guarantees. + +Initial engineering objectives may begin with: + +```text +Interactive read: + target p95 <= 500 ms + +Interactive mutation: + target p95 <= 750 ms + +Simple list/search: + target p95 <= 800 ms + +Upload authorization: + target p95 <= 300 ms + +Background outbox pickup: + target <= 5 seconds under normal operating conditions +``` + +These are revised after realistic testing. + +Track: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +outbox lag +``` + +Different endpoint classes receive different SLOs. + +Do not use file-transfer completion time as an API SLO when bytes travel directly between client and object storage. + +--- + +## 97E. Risk Register + +Maintain a living risk register. + +Suggested structure: + +| Risk | Impact | Mitigation | Owner | Phase | Status | +|---|---|---|---|---|---| +| Cross-tenant data exposure | Critical | Tenant-aware FKs, scoped queries, security tests | Backend/Security | P0 | Open | +| Non-idempotent outbox side effect | Critical | Consumer dedupe, provider idempotency, chaos tests | Backend | P0 | Open | +| Migration failure | High | Expand/contract, dry runs, backups | Backend/Platform | P0 | Open | +| Engineering workflow mismatch | High | Domain expert validation | Product/Engineering SME | MVP | Open | +| Portal authorization leak | Critical | Separate external access model, publication grants | Backend/Security | Portal | Open | +| Webhook delivery instability | Medium | Retry, dead-letter, replay, metrics | Backend | Integrations | Open | +| Large upload abandonment | Medium | Multipart expiry and cleanup | Backend/Platform | Documents | Open | +| Documentation drift | Medium | OpenAPI validation, ADRs, CI | Engineering | Continuous | Open | + +Do not pretend likelihood labels are quantitative unless the team defines and uses a scoring method. + +--- + +## 97F. Architecture Change Governance + +v4 is the last broad platform-architecture revision before Engineering MVP implementation. + +New discoveries should normally become: + +```text +ADR +OpenAPI change +database migration +domain-state-machine update +security decision +backlog item +runbook +``` + +rather than a new full architecture rewrite. + +Reopen the broad architecture only when a discovery invalidates one of these foundational assumptions: + +```text +tenant model +profession separation +shared-core boundary +REST API model +data ownership +security trust boundary +deployment topology +database architecture +``` + +This prevents design review from becoming an infinite recursion problem. + +--- + +## 97G. Production Readiness Gates + +Architecture being coherent does not mean production is safe. + +Before production, require evidence in these categories. + +### Security + +```text +TLS configured +password hashing configured +refresh rotation/reuse detection tested +session revocation tested +tenant isolation tests passing +authorization/credential policies tested +rate limiting active +secrets managed outside source control +file security scanning active +security review completed +``` + +### Reliability + +```text +database backups automated +restore tested +object storage recovery strategy tested +outbox monitoring active +job queue monitoring active +webhook retry/dead-letter behavior tested +health checks configured +dependency failures tested +``` + +### Data Integrity + +```text +tenant-aware foreign keys present where required +financial invariants tested +migration tested on production-like data +idempotency tested for high-risk commands +optimistic concurrency tested +audit integrity tested +``` + +### Contract / API + +```text +OpenAPI validates +contract tests pass +error schema consistent +versioning rules documented +client SDK generation validated if used +``` + +### Performance + +```text +load test executed +realistic SLOs defined +database pool configured +key queries analyzed +outbox/job backlogs remain within SLO +``` + +### Critical Domain Coverage + +Rather than a magic overall coverage number, require explicit test coverage for: + +```text +tenant boundaries +design approval +inspection completion +invoice issue +payment/refund +membership privilege changes +clinical record signing/amendment when healthcare exists +prescribing authorization when healthcare exists +``` + +### Release Gate Principle + +No single metric such as: + +```text +90% test coverage +``` + +is sufficient evidence of production readiness. + +Quality gates are based on critical behavior, not vanity percentages. + +--- + +## 97. Final Design Position + +The platform is: + +```text +One Shared Platform + │ + ├── Shared Identity / Sessions + ├── Shared Security / Authorization + ├── Shared Documents / Multipart Uploads + ├── Shared Financial Core + ├── Shared Audit / Outbox + ├── Shared Jobs / Webhooks / Notifications + │ + ├── Engineering Internal Product + │ ├── Engineering Frontend + │ ├── Engineering REST APIs + │ ├── Engineering State Machines + │ └── Engineering Tables + │ + ├── Engineering Client Portal + │ ├── External Portal Frontend + │ ├── Portal Accounts + │ ├── Project Grants + │ ├── Published Documents + │ └── Client Review / Acceptance + │ + ├── Legal Product + │ ├── Legal Frontend + │ ├── Legal REST APIs + │ └── Legal Tables + │ + └── Healthcare Product + ├── Healthcare Frontend + ├── Healthcare REST APIs + ├── Healthcare Security Policies + └── Healthcare Tables +``` + +The system shares infrastructure where reuse is valuable while preserving profession-specific domain semantics and trust boundaries. + +v4 is the final broad architecture baseline for Engineering MVP implementation. + +From this point forward, architecture detail should primarily move into: + +```text +ADRs +OpenAPI +database schema/migrations +state-machine specifications +security policies +implementation backlog +runbooks +``` + +rather than repeatedly rewriting the entire architecture plan. + +This document does not itself prove: + +```text +regulatory compliance +production certification +security certification +performance at a specific scale +``` + +Those require implementation evidence, security review, domain validation, operational testing, restore testing, and measured production-like workloads. + + + + +--- + +# v4.1 Changelog + +v4.1 resolves implementation-contract issues without changing the core architecture. + +```text +✓ raw UUIDv7 API ID contract +✓ camelCase API / snake_case database naming convention +✓ direct organization_id on tenant subresources +✓ invitation role assignments +✓ service accounts and hashed API keys +✓ multiple professional credentials per profile +✓ global archive/revoke/unlink/hard-delete policy +✓ project stage duplication removed +✓ project budget_minor removed +✓ project phase reorder command +✓ global engineering site listing +✓ task status and priority vocabularies +✓ design revise endpoint +✓ design-version many-document cardinality +✓ design review/version tenant keys +✓ inspection finding tenant keys +✓ explicit inspection follow-up table +✓ change requests deferred until fully specified +✓ time-entry work-item attribution +✓ time-entry project/work-item consistency constraints +✓ specification many-document cardinality and status values +✓ legal_documents duplication removed +✓ legal matter/case document-link schemas +✓ healthcare placeholder schemas clarified +✓ invoice-item schema defined +✓ document retention-policy schema +✓ explicit retention-period null semantics +✓ version-independent document-category uniqueness fallback +✓ standard upload DTO +✓ multipart-init/parts/complete DTOs +✓ webhook subscription schema +✓ webhook delivery references outbox events +✓ typed job input/result references +✓ logout endpoint +✓ assigned-scope resolution rules +✓ audit privacy transformation strategy +✓ outbox correlation and causation IDs +✓ CORS/browser-security ADR +✓ feature-flag strategy clarified +✓ jobs confirmed tenant-scoped via X-Organization-Id +``` + +The next artifacts should be implementation-specific: + +```text +ADRs +Engineering OpenAPI +Engineering database migrations +Engineering state-machine spec +Engineering MVP backlog +``` diff --git a/professional_management_platform_rest_plan_v4_5.md b/professional_management_platform_rest_plan_v4_5.md new file mode 100644 index 0000000..104dfb0 --- /dev/null +++ b/professional_management_platform_rest_plan_v4_5.md @@ -0,0 +1,7236 @@ +# Professional Management Platform +## Full REST-First System Design Plan + +> **Revision:** v4.1 — Consistency and Implementation-Contract Cleanup +> **Status:** Locked broad architecture baseline with implementation-blocking contradictions resolved. Subsequent detail belongs in ADRs, OpenAPI, migrations, domain specifications, and backlog items. +> **Primary vertical:** Engineering +> **API style:** REST + JSON + OpenAPI 3.1 +> **Backend style:** Modular monolith +> **Data:** PostgreSQL + profession-specific tables +> **Tenant model:** Organization-scoped, explicit tenant context + +### v4.1 Cleanup Notes + +v4.1 does not redesign the platform. It resolves contradictions and fills implementation contracts discovered during detailed review. + +Resolved: + +- raw UUIDv7 is now the serialized/API identifier format +- database columns remain UUID; prefixed strings are not public IDs +- all tenant-owned subresources carry direct `organization_id` +- design-version document cardinality is explicit and relational +- engineering time entries can be attributed to a specific work item +- time-entry work-item links are constrained to the time entry's project +- engineering specifications use explicit many-document link records +- project-level `budget_minor` is removed in favor of the dedicated budget model +- duplicate `legal_documents` ownership is removed +- legal matter/case document-link schemas are defined +- batch custom-action paths use one documented convention +- membership invitations can pre-assign multiple roles +- missing design `revise` command is added +- inspection follow-ups now have a table and lifecycle +- change requests are moved out of the initial Engineering schema until specified +- service accounts and API keys are defined for machine access +- service-account role assignments have an explicit relational schema +- design assignments, portal review requests, inspection documents, payments, and refunds have explicit schemas +- shared billing accounts replace vague or polymorphic invoice references +- resource status and type vocabularies required by migrations/OpenAPI are defined +- API JSON, query parameters, and path parameter names use camelCase; database columns use snake_case +- professional profiles support multiple professional credentials +- deletion/archival/revocation/unlink behavior is globally defined +- invoice-item fields are specified +- deferred healthcare placeholder tables receive minimum schemas or explicit deferral notes +- document upload and multipart DTOs are defined +- webhook subscription fields are defined +- `POST /auth/logout` is restored +- portal review-request and portal capability schemas are defined +- project-role, task-status, priority, and inspection-outcome values are defined +- document-category uniqueness has a version-independent fallback +- retention policy fields are defined +- retention-period null semantics are explicit +- pagination defaults are explicit +- `assigned` authorization scope has resource-specific resolution rules +- feature-flag behavior is clarified +- audit privacy minimization/anonymization strategy is documented +- outbox correlation and causation IDs are added +- webhook delivery event references and job reference envelopes are defined +- CORS and web-security configuration is moved into a required ADR +- project `stage` duplication is removed; project phases remain authoritative +- phase reordering, site listing, appointment locations, and encounter reason fields are clarified +- jobs remain tenant-scoped through the standard organization header rather than path nesting +- job types use a registered handler-and-schema vocabulary + +--- +--- +--- +--- + +## 2. Core Architecture Decision + +The platform will use: + +- REST +- JSON +- OpenAPI +- Versioned endpoints +- PostgreSQL +- Modular monolith backend +- Profession-specific frontends +- Profession-specific database tables +- Shared identity, security, billing, documents, audit, and infrastructure + +Base API path: + +```text +/api/v1 +``` + +GraphQL is not part of v1. + +--- + +## 3. High-Level Architecture + +```text + FRONTENDS + + ┌──────────────────┼──────────────────┐ + │ │ │ + Engineering Web Legal Web Healthcare Web + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ + REST API + /api/v1 + │ + ┌───────────┼───────────┐ + │ │ │ + Core Engineering Legal + │ │ │ + │ Healthcare │ + │ │ │ + └───────────┼───────────┘ + │ + PostgreSQL + │ + ┌───────────────┼────────────────┐ + │ │ │ + Shared Tables Profession Tables Audit/Event Tables +``` + +Shared infrastructure: + +```text +PostgreSQL +Redis +Object Storage +Queue / Workers +Audit +Notifications +Billing +Observability +``` + +--- + +## 4. System Architecture Strategy + +Start with a modular monolith. + +Do not start with microservices. + +Initial deployment: + +```text +Frontend Apps + │ + ▼ +Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Worker Queue +``` + +Benefits: + +- simpler transactions +- easier development +- easier deployment +- clearer domain boundaries +- lower operational burden +- easier refactoring +- future service extraction remains possible + +--- + +## 5. Repository Structure + +Recommended monorepo: + +```text +professional-platform/ +│ +├── apps/ +│ ├── engineering-web/ +│ ├── legal-web/ +│ ├── healthcare-web/ +│ ├── platform-admin/ +│ ├── api/ +│ └── workers/ +│ +├── packages/ +│ ├── ui/ +│ ├── api-client/ +│ ├── auth-client/ +│ ├── validation/ +│ ├── types/ +│ ├── config/ +│ └── testing/ +│ +├── database/ +│ ├── migrations/ +│ ├── seeds/ +│ └── scripts/ +│ +├── infrastructure/ +│ ├── docker/ +│ ├── deployment/ +│ └── monitoring/ +│ +└── docs/ + ├── architecture/ + ├── api/ + ├── security/ + └── domains/ +``` + +--- + +## 6. Frontend Strategy + +Every profession receives its own frontend application. + +Avoid one giant frontend filled with profession checks. + +### Engineering Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Projects +Project Phases +Project Team +Sites +Designs +Design Reviews +Inspections +Specifications +Tasks +Documents +Timesheets +Billing +Reports +Administration +``` + +### Legal Frontend + +Suggested navigation: + +```text +Dashboard +Clients +Matters +Cases +Hearings +Courts +Deadlines +Documents +Conflict Checks +Time Tracking +Retainers +Billing +Reports +Administration +``` + +### Healthcare Frontend + +Suggested navigation: + +```text +Dashboard +Patients +Appointments +Practitioners +Encounters +Clinical Records +Diagnoses +Prescriptions +Insurance +Documents +Billing +Reports +Administration +``` + +### Platform Admin Frontend + +Suggested functions: + +```text +Organizations +Users +Profession Modules +Subscriptions +System Health +Audit +Support +Global Configuration +``` + +Platform administrators and organization administrators are separate concepts. + +--- + +## 7. REST API Structure + +Shared endpoints: + +```text +/api/v1/auth +/api/v1/me +/api/v1/organizations +/api/v1/memberships +/api/v1/membership-invitations +/api/v1/roles +/api/v1/permissions +/api/v1/documents +/api/v1/invoices +/api/v1/payments +/api/v1/audit-events +``` + +Engineering: + +```text +/api/v1/engineering/clients +/api/v1/engineering/projects +/api/v1/engineering/project-members +/api/v1/engineering/phases +/api/v1/engineering/sites +/api/v1/engineering/tasks +/api/v1/engineering/designs +/api/v1/engineering/inspections +/api/v1/engineering/specifications +/api/v1/engineering/time-entries +``` + +Legal: + +```text +/api/v1/legal/clients +/api/v1/legal/matters +/api/v1/legal/cases +/api/v1/legal/hearings +/api/v1/legal/deadlines +/api/v1/legal/conflict-checks +/api/v1/legal/retainers +/api/v1/legal/time-entries +``` + +Healthcare: + +```text +/api/v1/healthcare/patients +/api/v1/healthcare/practitioners +/api/v1/healthcare/appointments +/api/v1/healthcare/encounters +/api/v1/healthcare/clinical-records +/api/v1/healthcare/diagnoses +/api/v1/healthcare/prescriptions +/api/v1/healthcare/insurance +``` + +--- + +## 8. REST Conventions + +All APIs use JSON over HTTPS. + +Typical tenant-scoped request: + +```http +Authorization: Bearer +X-Organization-Id: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d +X-Request-Id: 0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff +Content-Type: application/json +``` + +### API Naming Convention + +Public API representation: + +```text +JSON properties: camelCase +query parameters: camelCase +path parameter names in documentation: camelCase +HTTP headers: conventional HTTP header casing +``` + +Database representation: + +```text +table names: snake_case +column names: snake_case +constraint/index names: snake_case +``` + +Example: + +```http +GET /api/v1/engineering/tasks?assignedToUserId=&createdAfter=2026-08-01T00:00:00Z +``` + +```json +{ + "assignedToUserId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d", + "createdAt": "2026-08-26T12:00:00Z" +} +``` + +maps internally to columns such as: + +```text +assigned_to_user_id +created_at +``` + +### Organization Context + +`X-Organization-Id` is mandatory for every tenant-scoped endpoint. + +Global endpoints such as these do not require tenant context: + +```http +POST /api/v1/auth/login +POST /api/v1/auth/token/refresh +GET /api/v1/me +GET /api/v1/me/organizations +GET /api/v1/auth/sessions +``` + +Tenant-context resolution: + +```yaml +header_missing_on_tenant_endpoint: + status: 400 + code: ORGANIZATION_CONTEXT_REQUIRED + +organization_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + +membership_not_found: + status: 404 + code: RESOURCE_NOT_FOUND + +membership_inactive: + status: 403 + code: AUTHZ_MEMBERSHIP_INACTIVE + +organization_inactive: + status: 403 + code: AUTHZ_ORGANIZATION_INACTIVE + +resource_organization_mismatch: + status: 404 + code: RESOURCE_NOT_FOUND +``` + +### Idempotency + +Use: + +```http +Idempotency-Key: 8f7d6c5e-4b3a-4b1c-9d8e-7f6a5b4c3d2e +``` + +Required where duplicate execution can create material side effects. + +PostgreSQL is authoritative for critical idempotency records. + +Redis may accelerate lookup. + +### Batch Custom-Action Convention + +For collection-level custom commands use: + +```text +/{collection}/batch/{action} +``` + +Examples: + +```http +POST /api/v1/engineering/tasks/batch/assign +POST /api/v1/engineering/tasks/batch/complete +POST /api/v1/engineering/time-entries/batch/submit +``` + +Do not mix `batch-assign`, colon-style custom methods, and `/batch/assign` in the same API. + +### Rate-Limit Responses + +```http +429 Too Many Requests +Retry-After: +``` + +Additional rate-limit metadata may be exposed according to the selected gateway/standard. + +Do not freeze legacy `X-RateLimit-*` names here. + +### Error Standard Decision + +The current error envelope remains: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cff" + } +} +``` + +ADR-005 decides whether OpenAPI v1 aligns this with RFC 9457 Problem Details. + +Do not silently change the envelope during implementation. + +## 9. Standard Response Format + +Single resource: + +```json +{ + "data": { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c5d", + "name": "Central Tower" + } +} +``` + +Collection: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Standard error: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource not found.", + "details": {}, + "requestId": "req_123" + } +} +``` + +Clients depend on `error.code`, not message text. + +### Error Taxonomy + +Authentication: + +```text +AUTH_INVALID_CREDENTIALS +AUTH_TOKEN_EXPIRED +AUTH_TOKEN_INVALID +AUTH_MFA_REQUIRED +AUTH_SESSION_REVOKED +AUTH_REFRESH_TOKEN_REUSED +``` + +Authorization: + +```text +AUTHZ_PERMISSION_DENIED +AUTHZ_ORGANIZATION_INACTIVE +AUTHZ_MEMBERSHIP_INACTIVE +AUTHZ_CREDENTIAL_INVALID +AUTHZ_SCOPE_MISMATCH +``` + +Tenant context: + +```text +ORGANIZATION_CONTEXT_REQUIRED +``` + +Resource/state: + +```text +RESOURCE_NOT_FOUND +RESOURCE_ALREADY_EXISTS +RESOURCE_CONCURRENT_MODIFICATION +RESOURCE_INVALID_STATE +RESOURCE_ARCHIVED +``` + +Validation: + +```text +VALIDATION_ERROR +VALIDATION_REQUIRED_FIELD +VALIDATION_INVALID_FORMAT +VALIDATION_BUSINESS_RULE +``` + +Idempotency: + +```text +IDEMPOTENCY_KEY_REQUIRED +IDEMPOTENCY_KEY_CONFLICT +``` + +Rate limiting: + +```text +RATE_LIMIT_EXCEEDED +``` + +System/dependency: + +```text +INTERNAL_ERROR +SERVICE_UNAVAILABLE +DATABASE_UNAVAILABLE +DEPENDENCY_FAILED +``` + +Validation example: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed.", + "requestId": "req_123", + "details": { + "fields": [ + { + "field": "email", + "code": "INVALID_FORMAT", + "message": "Must be a valid email address" + } + ] + } + } +} +``` + +Business-state example: + +```json +{ + "error": { + "code": "RESOURCE_INVALID_STATE", + "message": "Cannot approve design in current state.", + "requestId": "req_123", + "details": { + "resourceType": "engineering_design", + "resourceId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c6d", + "currentState": "draft", + "requiredState": "under_review", + "allowedActions": [ + "submit_review" + ] + } + } +} +``` + +Do not expose internal stack traces, SQL, policy internals, secrets, or cross-tenant information. + +## 10. HTTP Status Rules + +```text +200 Success +201 Created +202 Accepted +204 No Content +400 Bad Request +401 Unauthorized +403 Forbidden +404 Not Found +409 Conflict +422 Validation Error +429 Too Many Requests +500 Internal Server Error +``` + +Cross-tenant resource access should return 404. + +--- + +## 11. API Versioning + +Current API: + +```text +/api/v1 +``` + +Breaking changes require: + +```text +/api/v2 +``` + +Additive fields generally do not require a new version. + +--- + +## 11A. Identifier Convention + +The serialized identifier standard is **raw UUIDv7**. + +Example: + +```text +0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d +``` + +Database: + +```sql +id UUID PRIMARY KEY +``` + +API: + +```json +{ + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c1d" +} +``` + +Do not serialize IDs as: + +```text +org_ +user_ +project_ +``` + +unless a future ADR explicitly changes the public identifier contract. + +Human-friendly resource references use separate fields such as: + +```text +projectNumber +matterNumber +patientNumber +invoiceNumber +``` + +This separates machine identity from business/display references. + +UUID generation is decided by ADR-004: + +```text +PostgreSQL-native UUIDv7 when supported and selected +or +application-generated UUIDv7 +``` + +The API format is identical either way. + +--- + +## 12. Authentication + +Initial human authentication: + +```text +Email ++ +Password ++ +Short-Lived Access Token ++ +Opaque Refresh Token ++ +Server-Side Session +``` + +REST: + +```http +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/logout + +POST /api/v1/auth/token/refresh +POST /api/v1/auth/token/revoke +POST /api/v1/auth/token/revoke-all + +GET /api/v1/auth/sessions +DELETE /api/v1/auth/sessions/{sessionId} + +GET /api/v1/me +``` + +`POST /auth/logout` revokes the current session. + +`DELETE /auth/sessions/{sessionId}` allows a user to revoke a specific session, such as another device. + +### Access Token + +```yaml +format: JWT +lifetime: short-lived +signed: true +encrypted: false +claims: + - sub + - sessionId + - issuer + - audience + - issuedAt + - expiresAt +``` + +Organization context is not trusted from the token as authorization authority. + +### Sessions + +```text +sessions +├── id +├── user_id +├── device metadata +├── created_at +├── last_active_at +├── expires_at +├── revoked_at +└── revocation_reason +``` + +### Refresh Tokens + +```text +refresh_tokens +├── id +├── session_id +├── family_id +├── token_hash +├── issued_at +├── expires_at +├── rotated_at +├── replaced_by_token_id +├── revoked_at +└── revocation_reason +``` + +Constraints/indexes: + +```text +UNIQUE(token_hash) +INDEX(family_id) +INDEX(session_id) +``` + +`family_id` is not unique. + +### Refresh Reuse Detection + +Use of a previously rotated token triggers: + +```text +revoke token family +revoke affected session +security audit event +reauthentication +``` + +Policy may escalate to all-session revocation. + +Future human authentication: + +- MFA +- WebAuthn/passkeys +- OIDC/SSO +- enterprise identity providers + + +## 12A. Service Accounts and API Keys + +Machine-to-machine access is separate from human sessions. + +Use: + +```text +service_accounts +api_keys +service_account_roles +``` + +### Service Account + +Suggested fields: + +```text +id +organization_id +name +description +status +created_by_user_id +created_at +updated_at +revoked_at +``` + +Service-account status values: + +```text +active +revoked +``` + +### API Key + +Suggested fields: + +```text +id +organization_id +service_account_id + +key_prefix +secret_hash + +created_at +expires_at +last_used_at +revoked_at +revocation_reason +``` + +Raw API-key secrets are shown only once. + +Store only a secure hash of the secret. + +`key_prefix` is safe display material for identifying a key in administration screens. + +### Service Account Role + +`service_account_roles` uses the same organization-scoped role registry as human RBAC assignments. + +Suggested fields: + +```text +id +organization_id +service_account_id +role_id +created_at +``` + +Unique: + +```text +(organization_id, service_account_id, role_id) +``` + +Tenant-safe foreign keys require the service account and role to belong to the same organization as the assignment. + +### Authorization + +Service accounts use explicit organization-scoped permissions, preferably through: + +```text +service_account_roles +``` + +with the same registered permission vocabulary used by RBAC. + +They do not become fake human memberships. + +### Audit + +Audit actors support: + +```text +actor_type = user +actor_type = service_account +actor_type = system +``` + +Machine authentication is required when public/integration API access is implemented; it does not block the earliest internal Engineering UI slice. + +--- + +## 13. Shared Core Backend + +Recommended modules: + +```text +core/ +├── auth/ +├── users/ +├── organizations/ +├── memberships/ +├── roles/ +├── permissions/ +├── authorization/ +├── documents/ +├── billing/ +├── notifications/ +├── audit/ +└── events/ +``` + +Dependency rule: + +```text +Profession module → Core +``` + +Never: + +```text +Core → Profession module +``` + +--- + +## 14. Organizations + +Organizations are tenants. + +Examples: + +```text +Atlas Structural Engineering +Smith & Associates Law +North Shore Medical Practice +``` + +Suggested fields: + +```text +id +name +slug +status +country_code +timezone +currency_code +created_at +updated_at +``` + +Organization status values: + +```text +active +suspended +pending_deletion +``` + +--- + +## 15. Profession Enablement + +Use: + +```text +organization_professions +``` + +Suggested fields: + +```text +organization_id +profession +enabled_at +configuration +``` + +Possible professions: + +```text +engineering +legal +healthcare +``` + +An organization may eventually enable more than one profession module. + +--- + +## 16. Users and Memberships + +Users are global identities. + +A user gains tenant access through membership. + +```text +User + │ + ▼ +Membership + │ + ▼ +Organization +``` + +Suggested `users` fields: + +```text +id +email +first_name +last_name +phone +avatar_url +status +created_at +updated_at +``` + +Suggested `memberships` fields: + +```text +id +organization_id +user_id +status +joined_at +created_at +updated_at +``` + +User status values: + +```text +active +inactive +pending_verification +``` + +Membership status values: + +```text +active +inactive +pending +``` + +--- + +## 17. Membership Invitations + +Keep invitations separate from memberships. + +Tables: + +```text +membership_invitations +membership_invitation_roles +``` + +`membership_invitations`: + +```text +id +organization_id +email +invited_by_user_id +expires_at +accepted_at +revoked_at +created_at +``` + +`membership_invitation_roles`: + +```text +organization_id +invitation_id +role_id +created_at +``` + +Use tenant-aware foreign keys so invitation roles cannot reference another organization's role. + +Flow: + +```text +Invitation + Intended Roles + ↓ + Accepted + ↓ + User + ↓ + Membership + ↓ + Membership Roles +``` + +At acceptance: + +1. validate invitation token and expiry +2. validate invited email/account policy +3. create membership +4. copy valid intended roles to membership-role assignments +5. mark invitation accepted +6. audit +7. emit outbox event + +If an intended role was revoked/deleted before acceptance, acceptance fails safely or drops that role according to explicit organization policy. + +## 18. Authorization + +Use: + +```text +RBAC ++ +Permission Scope ++ +Resource Policies ++ +Professional Qualification Policies ++ +Domain State Rules +``` + +Decision flow: + +```text +Authenticated User + ↓ +Explicit Organization Context + ↓ +Active Membership + ↓ +Enabled Profession Module + ↓ +Roles + ↓ +Permissions + ↓ +Permission Scope + ↓ +Tenant-scoped Resource Query + ↓ +Resource Policy + ↓ +Credential/Jurisdiction Policy + ↓ +Domain State Rule + ↓ +ALLOW / DENY +``` + +Default decision: + +```text +DENY +``` + +Authorization rules: + +1. Controllers never perform ad-hoc role comparisons. +2. Tenant resource queries always include `organization_id`. +3. Do not load an arbitrary resource first and then discover it belongs to another tenant. +4. High-risk professional actions perform credential checks at command execution time. +5. A permission grants the ability to attempt an action, not a guarantee the domain state allows it. +6. Cross-tenant resources appear nonexistent. +7. Profession module enablement is checked before profession-specific authorization. + +## 19. Roles and Permissions + +Roles are organization-scoped collections of permissions. + +Example roles: + +```text +Owner +Administrator +Project Manager +Engineer +Reviewer +Inspector +Lawyer +Paralegal +Doctor +Nurse +Billing Manager +Viewer +``` + +Roles are not professional credentials. + +### Engineering Permissions + +```text +engineering.clients.read +engineering.clients.create +engineering.clients.update +engineering.clients.archive + +engineering.projects.read +engineering.projects.create +engineering.projects.update +engineering.projects.activate +engineering.projects.close +engineering.projects.archive + +engineering.project_members.manage +engineering.phases.manage +engineering.tasks.manage +engineering.sites.manage + +engineering.documents.read +engineering.documents.upload +engineering.documents.delete + +engineering.designs.read +engineering.designs.create +engineering.designs.update +engineering.designs.review +engineering.designs.approve +engineering.designs.reject +engineering.designs.supersede + +engineering.inspections.read +engineering.inspections.manage +engineering.inspections.complete + +engineering.time_entries.manage +engineering.reports.read +``` + +### Legal Permissions + +```text +legal.clients.read +legal.clients.create +legal.clients.update + +legal.matters.read +legal.matters.create +legal.matters.update +legal.matters.close +legal.matters.reopen + +legal.cases.read +legal.cases.manage +legal.hearings.manage +legal.deadlines.manage + +legal.documents.read +legal.documents.upload + +legal.conflicts.manage +legal.conflicts.approve + +legal.retainers.manage +legal.time_entries.manage +``` + +### Healthcare Permissions + +```text +healthcare.patients.read +healthcare.patients.create +healthcare.patients.update + +healthcare.appointments.read +healthcare.appointments.manage + +healthcare.encounters.read +healthcare.encounters.manage + +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +healthcare.records.access_log.read + +healthcare.prescriptions.read +healthcare.prescriptions.write +healthcare.prescriptions.sign + +healthcare.insurance.read +healthcare.insurance.manage +``` + +### Shared Permissions + +```text +documents.read +documents.upload + +billing.read +invoices.create +invoices.issue +invoices.void +payments.record +payments.refund + +members.read +members.invite +members.update +members.remove + +roles.read +roles.manage + +audit.read +``` + +Avoid vague permissions such as `admin_everything` in normal tenant RBAC. + +## 20. Permission Scopes + +Initial scopes: + +```text +assigned +organization +``` + +Example: + +```text +Engineer: +engineering.projects.read = assigned + +Principal Engineer: +engineering.projects.read = organization +``` + +`assigned` is not magic. Each resource policy defines how assignment is resolved. + +### Engineering Project + +Assigned when: + +```text +engineering_project_members.user_id = ctx.userId +AND engineering_project_members.left_at IS NULL +``` + +or when the user is the active project manager, if project-manager assignment is modeled separately. + +### Engineering Task + +Assigned when: + +```text +engineering_tasks.assigned_to_user_id = ctx.userId +``` + +For tasks linked to a project, parent-project access may also be required. + +### Engineering Design + +Assigned when an active row exists in: + +```text +engineering_design_assignments +``` + +for the user and an allowed assignment role. + +### Engineering Inspection + +Assigned when: + +```text +engineering_inspections.inspector_user_id = ctx.userId +``` + +or an explicit inspection assignment exists if the model later supports multiple inspectors. + +### Derived Client Access + +An assigned professional may access a client only through a policy that derives access from authorized projects. + +Project assignment must not automatically grant access to every project belonging to that client. + +Future scopes may include: + +```text +owned +team +department +restricted +``` + +Do not add them before a real workflow requires them. + +## 21. Professional Credentials + +Professional identity and credentials are separate from RBAC. + +Use: + +```text +professional_profiles +professional_credentials +``` + +### Professional Profile + +One organization/user/profession relationship. + +Suggested fields: + +```text +id +organization_id +user_id +profession +title +status +created_at +updated_at +``` + +Professional-profile status values: + +```text +active +suspended +inactive +``` + +Credential expiry is represented by credential status and `expires_at`; a profile itself does not become `expired` merely because one credential expires. + +### Professional Credential + +One profile may hold many credentials. + +Suggested fields: + +```text +id +organization_id +professional_profile_id + +credential_type +credential_number +issuing_authority +jurisdiction +discipline + +status +valid_from +expires_at + +verified_at +verified_by_user_id + +created_at +updated_at +``` + +Professional-credential status values: + +```text +pending_verification +active +suspended +expired +revoked +``` + +Examples: + +```text +professional engineering license in jurisdiction A +professional engineering license in jurisdiction B +specialty certification +medical license +controlled-substance prescribing registration where applicable +``` + +Credential policy evaluates the set of active credentials rather than one `primary_license_number`. + +High-risk actions such as design approval, record signing, or prescribing use authoritative or revocation-aware credential state. + +Prescribing remains jurisdiction/scope-of-practice policy, not a hard-coded profession test. + +## 22. Database Architecture + +Use PostgreSQL. + +Start with: + +```text +One database ++ +Shared schema ++ +Profession-specific tables +``` + +Do not begin with database-per-profession or database-per-customer unless compliance or residency requirements force that choice. + +--- + +## 23. Shared Tables + +Recommended shared tables: + +```text +organizations +organization_professions + +users +user_credentials +sessions +service_accounts +api_keys +service_account_roles + +memberships +membership_invitations + +roles +permissions +role_permissions +membership_roles + +professional_profiles +professional_credentials + +documents +document_versions +document_categories +retention_policies + +billing_accounts +invoices +invoice_items +payments +payment_refunds + +notifications +notification_deliveries + +audit_events +outbox_events + +webhooks +webhook_event_subscriptions +webhook_deliveries +jobs +``` + +--- + +## 24. Multi-Tenancy Rule + +Every tenant-owned row must contain: + +```text +organization_id +``` + +Examples: + +```text +engineering_projects.organization_id +legal_matters.organization_id +healthcare_patients.organization_id +``` + +Enforce tenant boundaries at: + +- API layer +- authorization layer +- repository/query layer +- database constraints + +--- + +## 25. Tenant-Safe Foreign Keys + +Use composite tenant-aware foreign keys when possible. + +Example: + +```text +engineering_projects +organization_id +client_id +``` + +references: + +```text +engineering_clients +organization_id +id +``` + +This prevents linking a resource from one organization to another organization's data. + +--- + +## 21A. Deletion, Archival, Revocation, and Unlink Policy + +`DELETE` does not have one universal persistence meaning. + +Use four lifecycle behaviors. + +### Archive / Domain Inactivation + +For business records whose history matters: + +```text +engineering clients +engineering projects +legal matters +healthcare patients +documents where retention requires history +``` + +Typical fields: + +```text +status +archived_at +archived_by_user_id +``` + +Restore is permitted only when domain, retention, and organization policy allow it. + +### Revoke + +For access/security resources: + +```text +sessions +refresh tokens +API keys +membership invitations +portal grants +webhook credentials +``` + +Use: + +```text +revoked_at +revoked_by +revocation_reason +``` + +### Temporal Unlink + +For relationship records where the historical relationship matters: + +```text +project documents +project members +design assignments +portal document publications +``` + +Use: + +```text +unlinked_at +left_at +unassigned_at +revoked_at +``` + +rather than deleting historical evidence. + +### Hard Delete + +Reserved for genuinely disposable or never-committed data, such as: + +```text +expired pending upload artifacts +failed temporary staging objects +unreferenced draft configuration where audit/retention does not require history +``` + +Hard deletion of financial, professional, audit, signed clinical, or issued business records is forbidden unless an explicit retention/privacy policy defines the operation. + +Every resource specification must declare its lifecycle behavior. + +--- + +# Engineering Domain + +## 26. Engineering Tables + +Initial Engineering MVP tables: + +```text +engineering_clients +engineering_client_contacts + +engineering_projects +engineering_project_members +engineering_project_phases +engineering_sites +engineering_tasks + +engineering_designs +engineering_design_assignments +engineering_design_versions +engineering_design_version_documents +engineering_design_reviews + +engineering_inspections +engineering_inspection_findings +engineering_inspection_followups +engineering_inspection_documents + +engineering_specifications +engineering_specification_documents + +engineering_time_entries +engineering_client_billing_accounts +engineering_invoice_projects +engineering_invoice_item_time_entries +``` + +Later Engineering extensions: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries + +engineering_change_requests +``` + +`engineering_change_requests` is not part of the initial schema until its lifecycle, relationships, and REST contract are specified. + +## 27. Engineering Clients + +Suggested core client fields: + +```text +id +organization_id +client_type +display_name +legal_name +status +created_at +updated_at +version +``` + +Initial `client_type` values: + +```text +corporate +government +individual +``` + +Engineering-client status values: + +```text +active +archived +``` + +Do not permanently squeeze all contacts into one `email`, one `phone`, and one `contact_name`. + +Engineering customers commonly have multiple: + +```text +technical contacts +billing contacts +executive contacts +site contacts +contract contacts +``` + +Use: + +```text +engineering_client_contacts +``` + +Suggested contact fields: + +```text +id +organization_id +client_id + +name +title +department + +email +phone + +contact_type +is_primary + +created_at +updated_at +``` + +Client REST: + +```http +GET /api/v1/engineering/clients +POST /api/v1/engineering/clients +GET /api/v1/engineering/clients/{clientId} +PATCH /api/v1/engineering/clients/{clientId} + +POST /api/v1/engineering/clients/{clientId}/archive +POST /api/v1/engineering/clients/{clientId}/restore + +GET /api/v1/engineering/clients/{clientId}/projects +GET /api/v1/engineering/clients/{clientId}/invoices +``` + +Contact REST: + +```http +GET /api/v1/engineering/clients/{clientId}/contacts +POST /api/v1/engineering/clients/{clientId}/contacts +PATCH /api/v1/engineering/clients/{clientId}/contacts/{contactId} +DELETE /api/v1/engineering/clients/{clientId}/contacts/{contactId} +``` + +Delete may be implemented as archival when contact history matters. + +Client restore is allowed only when organization policy and retention rules permit it. + +## 27A. Engineering Client Portal + +External clients are not internal organization members. + +Use shared authentication identities where practical, but create a separate authorization boundary. + +```text +User + │ + ├── Internal Membership + │ ↓ + │ Organization Staff Access + │ + └── Client Portal Account + ↓ + Engineering Client Contact + ↓ + Project Access Grants +``` + +Suggested tables: + +```text +engineering_client_portal_accounts +engineering_client_portal_project_grants +engineering_project_document_publications +engineering_client_review_requests +``` + +### Portal Account + +Suggested fields: + +```text +id +organization_id +user_id +engineering_client_contact_id + +status + +invited_by_user_id +invited_at +accepted_at + +revoked_at +revoked_by_user_id +``` + +Portal-account status values: + +```text +invited +active +expired +revoked +``` + +Portal accounts are not placed in `memberships`. + +### Project Grant + +Suggested fields: + +```text +id +organization_id +portal_account_id +project_id + +access_profile + +granted_by_user_id +granted_at +expires_at +revoked_at +``` + +Initial `access_profile` values and capabilities: + +```text +viewer + project.status.read + project.documents.read_published + +contributor + all viewer capabilities + project.comments.create + project.files.submit + +reviewer + all contributor capabilities + client_review.respond +``` + +Capabilities are assigned through the three profiles above in v1; callers cannot submit an arbitrary capability list. + +The access model may later normalize capabilities into a grant table if simple profiles become insufficient. + +### Client Review Request + +`engineering_client_review_requests` fields: + +```text +id +organization_id +project_id +design_id nullable +portal_account_id + +requested_by_user_id +status +response nullable +response_notes nullable + +requested_at +responded_at nullable +created_at +updated_at +version +``` + +Status values: + +```text +pending +responded +withdrawn +expired +``` + +Response values: + +```text +accepted +changes_requested +declined +``` + +Only a portal account with an active grant for the same project and the `client_review.respond` capability may respond. A response sets `status = responded`, `response`, and `responded_at` atomically. Client responses never perform professional engineering approval. + +### Separate Frontend + +Recommended: + +```text +apps/ +├── engineering-web/ +└── engineering-client-portal/ +``` + +The internal engineering frontend and external portal do not share authorization assumptions. + +### Client Acceptance Is Not Engineering Approval + +Never represent client acceptance with: + +```text +engineering.designs.approve +``` + +Professional engineering approval is reserved for qualified internal/authorized professionals. + +Client-facing review should use separate concepts such as: + +```text +engineering.client_reviews.request +engineering.client_reviews.respond +engineering.client_reviews.accept +engineering.client_reviews.request_changes +``` + +Example: + +```http +POST /api/v1/engineering/client-review-requests/{reviewId}/accept +POST /api/v1/engineering/client-review-requests/{reviewId}/request-changes +``` + +A client acceptance may be commercially meaningful without being a professional engineering approval. + +### Portal Security Rules + +1. portal access is deny-by-default +2. every portal request remains organization-scoped +3. portal users only access explicitly granted projects +4. project membership does not apply to portal users +5. internal RBAC roles do not automatically apply to portal users +6. portal account revocation is immediate +7. portal grants may expire +8. sensitive document access requires explicit publication +9. portal activity is audited according to organization policy +10. professional approval endpoints are never exposed through portal grants + +--- + +## 27B. External Document Publication + +A document being linked to an engineering project does **not** make it externally visible. + +Use: + +```text +engineering_project_document_publications +``` + +Suggested fields: + +```text +id +organization_id + +project_document_link_id + +audience_type +portal_account_id nullable +client_id nullable + +published_by_user_id +published_at + +expires_at +revoked_at +revoked_by_user_id +``` + +Possible audiences: + +```text +all_active_client_portal_accounts_for_project +specific_portal_account +specific_client_contact +``` + +External download checks: + +```text +authenticated portal user ++ +active portal account ++ +active project grant ++ +active document publication ++ +publication not expired/revoked ++ +document classification allows publication ++ +download permission +``` + +This prevents an internal project document from appearing in the client portal merely because it is linked to the project. + +## 28. Engineering Projects + +Suggested fields: + +```text +id +organization_id +client_id + +project_number +name +description +discipline + +status + +project_manager_user_id + +start_date +expected_completion_date +completed_date + +created_at +updated_at +version +``` + +`stage` is removed from the project row because project phases are the authoritative workflow decomposition. + +Engineering-project status values: + +```text +draft +active +closed +archived +``` + +If the frontend needs a "current stage", derive it from the ordered phase records. `engineering_projects` does not store `current_phase_id` in v1; adding that denormalized pointer would require a later migration plus transactional reconciliation rules. + +Project `budget_minor` is also removed. + +Detailed project budgets belong to the dedicated budget model. + +REST: + +```http +GET /api/v1/engineering/projects +POST /api/v1/engineering/projects +GET /api/v1/engineering/projects/{projectId} +PATCH /api/v1/engineering/projects/{projectId} + +POST /api/v1/engineering/projects/{projectId}/activate +POST /api/v1/engineering/projects/{projectId}/close +POST /api/v1/engineering/projects/{projectId}/archive +``` + +Purpose-built reads: + +```http +GET /api/v1/engineering/projects/{projectId}/summary +GET /api/v1/engineering/projects/{projectId}/timeline +GET /api/v1/engineering/projects/{projectId}/budget +``` + +The budget endpoint reads from the budget module when that module exists. + +## 29. Engineering Project Members + +Suggested fields: + +```text +id +organization_id +project_id +user_id +project_role +joined_at +left_at +``` + +Initial project-role vocabulary: + +```text +project_manager +engineer +designer +reviewer +inspector +viewer +contractor +``` + +Project role describes participation in one project. + +It is not a substitute for RBAC permission. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/members +POST /api/v1/engineering/projects/{projectId}/members +PATCH /api/v1/engineering/projects/{projectId}/members/{memberId} +DELETE /api/v1/engineering/projects/{projectId}/members/{memberId} +``` + +`DELETE` means end participation by setting `left_at`, not erase historical participation. + +`PATCH` may change only `project_role` in v1. `joined_at` is server-assigned and immutable; `left_at` is set only by `DELETE`. Rejoining uses `POST` to create a new temporal membership record, and a partial unique index permits at most one active row per `(organization_id, project_id, user_id)` where `left_at IS NULL`. + +## 30. Engineering Project Phases + +Suggested fields: + +```text +id +organization_id +project_id +name +sequence +status +start_date +end_date +created_at +updated_at +version +``` + +Typical initial statuses: + +```text +planned +active +completed +cancelled +``` + +Example phases: + +```text +Concept +Preliminary Design +Detailed Design +Construction +Inspection +Closeout +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/phases +POST /api/v1/engineering/projects/{projectId}/phases +PATCH /api/v1/engineering/projects/{projectId}/phases/{phaseId} + +POST /api/v1/engineering/projects/{projectId}/phases/{phaseId}/complete +POST /api/v1/engineering/projects/{projectId}/phases/reorder +``` + +Reorder request: + +```json +{ + "projectVersion": 12, + "orderedPhaseIds": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b301", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b302" + ] +} +``` + +Reordering is transactional. + +Sequences remain unique within a project after commit. + +## 31. Engineering Sites + +Suggested fields: + +```text +id +organization_id +project_id +name +address +latitude +longitude +created_at +updated_at +``` + +REST: + +```http +GET /api/v1/engineering/sites +GET /api/v1/engineering/sites/{siteId} + +POST /api/v1/engineering/projects/{projectId}/sites +GET /api/v1/engineering/projects/{projectId}/sites + +PATCH /api/v1/engineering/sites/{siteId} +``` + +Global site listing is still tenant-scoped through `X-Organization-Id`. + +## 32. Engineering Tasks + +Suggested fields: + +```text +id +organization_id +project_id + +title +description + +status +priority + +created_by_user_id +assigned_to_user_id + +due_at +completed_at + +created_at +updated_at +version +``` + +Statuses: + +```text +todo +in_progress +completed +cancelled +``` + +Priorities: + +```text +low +medium +high +urgent +``` + +REST: + +```http +POST /api/v1/engineering/tasks +GET /api/v1/engineering/tasks +GET /api/v1/engineering/tasks/{taskId} +PATCH /api/v1/engineering/tasks/{taskId} + +POST /api/v1/engineering/tasks/{taskId}/complete +POST /api/v1/engineering/tasks/{taskId}/reopen +POST /api/v1/engineering/tasks/{taskId}/cancel +``` + +## 32A. Engineering Batch Operations + +Batch operations are useful for repetitive engineering workflows, but they must not bypass per-resource authorization or domain rules. + +Examples: + +```http +POST /api/v1/engineering/tasks/batch/assign +POST /api/v1/engineering/tasks/batch/complete + +POST /api/v1/engineering/time-entries/batch/submit +``` + +### Batch Execution Modes + +Every batch command explicitly defines one of: + +```text +atomic +partial +``` + +Atomic: + +```text +all resources succeed +or +entire operation fails +``` + +Partial: + +```text +each resource is evaluated independently +successful items commit +failed items return individual errors +``` + +Do not leave this behavior implicit. + +Example request: + +```json +{ + "taskIds": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c81", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c82", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c83" + ], + "assigneeUserId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d", + "mode": "partial" +} +``` + +Example response: + +```json +{ + "data": { + "succeeded": [ + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c81", + "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c82" + ], + "failed": [ + { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c83", + "code": "RESOURCE_INVALID_STATE" + } + ] + } +} +``` + +### Authorization + +Each resource is evaluated for: + +```text +tenant +permission +scope +resource access +state validity +credential policy where applicable +``` + +Never authorize the first item and assume the remaining batch is equivalent. + +### Synchronous vs Asynchronous + +Small batches may execute synchronously. + +Large batches become jobs: + +```http +202 Accepted +``` + +with: + +```text +jobId +``` + +The synchronous/asynchronous threshold is configuration based on: + +```text +batch size +operation cost +database load +side effects +product tier +``` + +Financial or regulated batch actions require stricter idempotency and audit rules than ordinary task updates. + +--- + +## 33. Engineering Designs + +Suggested fields: + +```text +id +organization_id +project_id + +design_number +title +description +discipline + +status + +owner_user_id +prepared_by_user_id + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +States: + +```text +draft +under_review +changes_requested +approved +rejected +cancelled +withdrawn +superseded +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/designs +POST /api/v1/engineering/projects/{projectId}/designs + +GET /api/v1/engineering/designs/{designId} +PATCH /api/v1/engineering/designs/{designId} + +POST /api/v1/engineering/designs/{designId}/submit-review +POST /api/v1/engineering/designs/{designId}/request-changes +POST /api/v1/engineering/designs/{designId}/approve +POST /api/v1/engineering/designs/{designId}/reject +POST /api/v1/engineering/designs/{designId}/revise +POST /api/v1/engineering/designs/{designId}/cancel +POST /api/v1/engineering/designs/{designId}/withdraw +POST /api/v1/engineering/designs/{designId}/supersede + +POST /api/v1/engineering/designs/{designId}/assign +POST /api/v1/engineering/designs/{designId}/unassign + +GET /api/v1/engineering/designs/{designId}/versions +POST /api/v1/engineering/designs/{designId}/versions + +GET /api/v1/engineering/designs/{designId}/reviews +POST /api/v1/engineering/designs/{designId}/reviews +``` + +### Design Assignment + +`engineering_design_assignments` fields: + +```text +id +organization_id +design_id +user_id +assignment_role +notes nullable +assigned_by_user_id +assigned_at +unassigned_at nullable +``` + +Initial assignment-role values: + +```text +owner +preparer +reviewer +contributor +``` + +`approver` is not an assignment role: approval authority is credential- and policy-driven, and the actual approver is recorded by the approval transition. + +Active assignments are unique by `(organization_id, design_id, user_id, assignment_role)` where `unassigned_at IS NULL`. `POST /assign` accepts `userId`, `assignmentRole`, and optional `notes`. `POST /unassign` accepts `assignmentId` and sets `unassigned_at`; it never hard-deletes the assignment. The design's primary `owner_user_id` and `prepared_by_user_id`, when set, must correspond to active `owner` and `preparer` assignments respectively. + +Assignment listing: + +```http +GET /api/v1/engineering/designs/{designId}/assignments +``` + +State machine: + +```text +draft + ├── submit-review ─────────────► under_review + └── cancel ────────────────────► cancelled + +under_review + ├── request-changes ───────────► changes_requested + ├── approve ───────────────────► approved + ├── reject ────────────────────► rejected + └── withdraw ──────────────────► withdrawn + +changes_requested + ├── submit-review ─────────────► under_review + └── withdraw ──────────────────► withdrawn + +rejected + └── revise ────────────────────► draft + +approved + └── supersede ─────────────────► superseded +``` + +Approval remains credential-aware, audited, and idempotent. + +Designs do not use generic archive/restore endpoints. Their professional lifecycle terminates through explicit state-machine outcomes such as `cancelled`, `withdrawn`, and `superseded`. Terminal designs remain queryable and auditable and are not hard-deleted through ordinary workflows. + +## 34. Design Versions and Reviews + +A design version is a logical professional revision. + +It may have multiple document files. + +Use: + +```text +engineering_design_versions +engineering_design_version_documents +engineering_design_reviews +``` + +### Design Version + +```text +id +organization_id +design_id +version_number +created_by_user_id +created_at +``` + +Unique: + +```text +(organization_id, design_id, version_number) +``` + +### Design Version Documents + +```text +id +organization_id +design_version_id +document_id +document_role +linked_by_user_id +linked_at +unlinked_at +``` + +Possible `document_role` values: + +```text +primary_drawing +calculation +supporting_document +specification +attachment +``` + +A design version therefore supports one or many documents without putting `document_id` directly on the version. + +### Design Review + +```text +id +organization_id +design_id +design_version_id +reviewer_user_id +status +comments +reviewed_at +created_at +``` + +Statuses: + +```text +pending +approved +changes_requested +rejected +``` + +All three tables are tenant-owned and carry direct `organization_id`. + +## 35. Engineering Inspections + +Inspection fields: + +```text +id +organization_id +project_id +site_id + +inspection_type +inspector_user_id + +status +outcome + +scheduled_at +started_at +performed_at +cancelled_at + +summary + +created_at +updated_at +version +``` + +Lifecycle: + +```text +draft +scheduled +in_progress +completed +cancelled +``` + +Outcome: + +```text +passed +passed_with_observations +followup_required +failed +``` + +`inspection_type` is an application/domain registry rather than a PostgreSQL enum. + +Initial common keys may include: + +```text +structural +mechanical +electrical +safety +final +``` + +Organizations/modules may add supported types through controlled configuration later. + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/inspections +POST /api/v1/engineering/projects/{projectId}/inspections + +GET /api/v1/engineering/inspections/{inspectionId} +PATCH /api/v1/engineering/inspections/{inspectionId} + +POST /api/v1/engineering/inspections/{inspectionId}/schedule +POST /api/v1/engineering/inspections/{inspectionId}/start +POST /api/v1/engineering/inspections/{inspectionId}/complete +POST /api/v1/engineering/inspections/{inspectionId}/cancel + +GET /api/v1/engineering/inspections/{inspectionId}/findings +POST /api/v1/engineering/inspections/{inspectionId}/findings + +GET /api/v1/engineering/inspections/{inspectionId}/followups +POST /api/v1/engineering/inspections/{inspectionId}/followups +``` + +Inspection completion may create follow-up records. + +Lifecycle and outcome remain separate. + +## 36. Inspection Findings + +`engineering_inspection_findings`: + +```text +id +organization_id +inspection_id + +severity +description +status + +resolved_at +resolved_by_user_id + +created_at +updated_at +version +``` + +Severity: + +```text +observation +minor +major +critical +``` + +Status: + +```text +open +in_progress +resolved +accepted_risk +``` + +REST: + +```http +POST /api/v1/engineering/inspections/{inspectionId}/findings +PATCH /api/v1/engineering/inspection-findings/{findingId} +POST /api/v1/engineering/inspection-findings/{findingId}/resolve +``` + +`organization_id` is direct even though tenant ownership is also derivable through the inspection. + +### Follow-Up Resource + +Use: + +```text +engineering_inspection_followups +``` + +Fields: + +```text +id +organization_id +inspection_id + +followup_type + +linked_task_id nullable +linked_inspection_id nullable + +status + +created_by_user_id +created_at +completed_at +cancelled_at +``` + +`followup_type`: + +```text +corrective_task +followup_inspection +both +``` + +`status`: + +```text +open +in_progress +completed +cancelled +``` + +Tenant-safe foreign keys apply to the original inspection and any linked task/inspection. + +## 37. Engineering Specifications + +Use: + +```text +engineering_specifications +engineering_specification_documents +``` + +Suggested specification fields: + +```text +id +organization_id +project_id +specification_number +title +version +status +superseded_by_specification_id nullable +archived_from_status nullable +archived_at nullable +archived_by_user_id nullable +created_at +updated_at +``` + +Status values: + +```text +draft +active +superseded +archived +``` + +Specification document links: + +```text +id +organization_id +specification_id +document_id +document_role +linked_by_user_id +linked_at +unlinked_at +``` + +A specification may therefore have one or many current or historical document links. `document_role` is an application registry with initial values such as `primary`, `attachment`, and `supporting_document`. Tenant-safe foreign keys apply to both the specification and shared document. + +The specification-document `document_role` registry is distinct from the design-version document-role registry in §34. Identical column names do not imply one shared vocabulary. + +Specification REST: + +```http +GET /api/v1/engineering/projects/{projectId}/specifications +POST /api/v1/engineering/projects/{projectId}/specifications + +GET /api/v1/engineering/specifications/{specificationId} +PATCH /api/v1/engineering/specifications/{specificationId} + +POST /api/v1/engineering/specifications/{specificationId}/activate +POST /api/v1/engineering/specifications/{specificationId}/supersede +POST /api/v1/engineering/specifications/{specificationId}/archive +POST /api/v1/engineering/specifications/{specificationId}/restore + +GET /api/v1/engineering/specifications/{specificationId}/documents +POST /api/v1/engineering/specifications/{specificationId}/documents +DELETE /api/v1/engineering/specification-documents/{documentLinkId} +``` + +Lifecycle rules: + +```text +draft → activate → active +draft → archive → archived +active → supersede → superseded +active → archive → archived +archived → restore → archived_from_status +superseded → terminal +``` + +Archive stores the prior `draft` or `active` value in `archived_from_status`; restore clears the archive fields and returns to that value. Supersede requires `supersededBySpecificationId`, linking a same-project replacement specification, and is audited and idempotent. + +--- + +## 38. Engineering Change Requests + +**Deferred from the initial Engineering schema.** + +Change requests are a valid future engineering capability, but v4.1 does not create the table until these are specified: + +```text +relationship to project +relationship to design/specification +request origin +impact analysis +cost/schedule effects +review workflow +approval authority +state machine +document links +REST commands +audit requirements +``` + +Future candidate: + +```text +engineering_change_requests +``` + +This belongs in the Engineering extension backlog rather than a half-defined initial migration. + +## 38A. Engineering Project Budgets + +A single `budget_minor` column is sufficient only for a very early project total. + +When budget management enters scope, introduce: + +```text +engineering_project_budgets +engineering_project_budget_items +engineering_project_commitments +engineering_project_cost_entries +``` + +### Budget + +Suggested fields: + +```text +id +organization_id +project_id + +name +currency_code +status + +approved_by_user_id +approved_at + +created_at +updated_at +version +``` + +### Budget Item + +Suggested fields: + +```text +id +organization_id +budget_id + +category +description + +allocated_amount_minor + +created_at +updated_at +``` + +Do not casually store mutable: + +```text +spent_amount_minor +committed_amount_minor +``` + +as independent sources of truth if those values are derived from time entries, expenses, purchase commitments, or invoices. + +Prefer: + +```text +authoritative cost/commitment records + ↓ +derived budget projections +``` + +If denormalized totals are needed for performance, update them transactionally and reconcile them. + +Potential REST: + +```http +GET /api/v1/engineering/projects/{projectId}/budgets +POST /api/v1/engineering/projects/{projectId}/budgets +GET /api/v1/engineering/budgets/{budgetId} +PATCH /api/v1/engineering/budgets/{budgetId} + +POST /api/v1/engineering/budgets/{budgetId}/approve +GET /api/v1/engineering/budgets/{budgetId}/items +POST /api/v1/engineering/budgets/{budgetId}/items +``` + +Budget approval is an explicit command. + +--- + +## 39. Engineering Time Entries + +Suggested fields: + +```text +id +organization_id +project_id +user_id + +work_date +duration_minutes +description + +billable +billing_rate_minor nullable +currency_code nullable + +phase_id nullable +task_id nullable +design_id nullable +inspection_id nullable + +created_at +updated_at +version +``` + +The project is always required. + +A time entry may also identify one primary work item. + +Database check: + +```text +at most one of: +phase_id +task_id +design_id +inspection_id +``` + +Each optional foreign key is tenant- and project-aware. For example: + +```text +(organization_id, project_id, task_id) +→ engineering_tasks(organization_id, project_id, id) +``` + +and similarly for phase, design, and inspection. Supporting unique constraints on `(organization_id, project_id, id)` are required on each target table. + +This is a database-enforced invariant, not only an application validation rule: whenever an optional work-item ID is present, that work item must belong to the same organization and `project_id` as the time entry. + +This preserves relational integrity instead of using an unconstrained polymorphic `reference_type/reference_id`. + +REST: + +```http +POST /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries +GET /api/v1/engineering/time-entries/{timeEntryId} +PATCH /api/v1/engineering/time-entries/{timeEntryId} + +POST /api/v1/engineering/time-entries/batch/submit +``` + +Duration is integer minutes. + +Billing snapshot constraint: + +```text +billable = true + → billing_rate_minor MUST be greater than zero + → currency_code MUST be non-null + +billable = false + → billing_rate_minor MUST be null + → currency_code MUST be null +``` + +The rate and currency are captured on the time entry so later billing-rate changes do not rewrite historical work. + +## 40. Legal Tables + +Initial legal-domain tables: + +```text +legal_clients +legal_matters +legal_matter_members +legal_cases +legal_case_parties +legal_courts +legal_hearings +legal_deadlines +legal_time_entries +legal_retainers +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +legal_matter_documents +legal_case_documents +legal_client_billing_accounts +legal_invoice_matters +``` + +There is no separate `legal_documents` ownership table. + +Documents remain shared infrastructure: + +```text +documents +document_versions +``` + +Legal relationships use: + +```text +legal_matter_documents +legal_case_documents +``` + +`legal_matter_documents` fields: + +```text +id +organization_id +matter_id +document_id +linked_by_user_id +linked_at +unlinked_at +``` + +`legal_case_documents` fields: + +```text +id +organization_id +case_id +document_id +linked_by_user_id +linked_at +unlinked_at +``` + +Both tables use tenant-safe foreign keys to their Legal parent and the shared `documents` table. `unlinked_at` preserves link history without deleting the shared document. + +REST namespace: + +```text +/api/v1/legal +``` + +Legal remains a later vertical. + +## 41. Legal Matters + +Suggested fields: + +```text +id +organization_id +client_id +matter_number +title +practice_area +responsible_lawyer_user_id +status +opened_date +closed_date +created_at +updated_at +``` + +--- + +## 42. Legal Cases + +Suggested fields: + +```text +id +organization_id +matter_id +case_number +court_id +jurisdiction +case_type +status +filed_date +created_at +updated_at +``` + +--- + +## 43. Legal Hearings + +Suggested fields: + +```text +id +organization_id +case_id +hearing_type +scheduled_at +courtroom +judge +status +notes +``` + +--- + +## 44. Legal Conflict Checks + +Suggested tables: + +```text +legal_conflict_checks +legal_conflict_parties +legal_conflict_matches +``` + +Conflict-check fields: + +```text +id +organization_id +potential_client_name +matter_description +requested_by_user_id +reviewed_by_user_id +status +decision +decision_reason +created_at +reviewed_at +version +``` + +Request example: + +```json +{ + "potentialClientName": "Acme Corporation", + "relatedParties": [ + { + "name": "John Smith", + "relationship": "CEO" + }, + { + "name": "Acme Subsidiary LLC", + "relationship": "Subsidiary" + } + ], + "matterDescription": "Corporate acquisition" +} +``` + +Response may contain possible matches: + +```json +{ + "data": { + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2cbd", + "status": "pending_review", + "potentialConflicts": [ + { + "type": "possible_direct_adversity", + "partyName": "Acme Corporation", + "existingMatterId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2ccd", + "existingMatterNumber": "MAT-2026-089" + } + ] + } +} +``` + +The system should distinguish: + +```text +automated possible match +``` + +from: + +```text +lawyer-approved conflict determination +``` + +The software may assist discovery; it should not silently make the professional judgment. + +Approvals and declines are auditable commands. + +## 45. Healthcare Tables + +Healthcare remains a later vertical. + +Minimum planned tables: + +```text +healthcare_patients +healthcare_patient_contacts +healthcare_patient_addresses + +healthcare_practitioners +healthcare_locations +healthcare_rooms + +healthcare_appointments +healthcare_encounters + +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments + +healthcare_diagnoses +healthcare_prescriptions +healthcare_insurance_policies +healthcare_allergies +healthcare_medications +``` + +All tenant-owned tables carry direct `organization_id`. + +Detailed healthcare interoperability, terminology, and jurisdiction rules require healthcare-specific design before implementation. + +## 46. Healthcare Patients + +Core patient: + +```text +id +organization_id +patient_number + +first_name +middle_name +last_name +date_of_birth + +administrative_gender nullable +sex_at_birth nullable +gender_identity nullable + +status + +created_at +updated_at +version +``` + +Exact demographic terminology and allowed values are finalized in the healthcare-domain specification. + +Do not make every field mandatory merely because it exists. + +### Patient Contact + +`healthcare_patient_contacts`: + +```text +id +organization_id +patient_id + +contact_type +value +is_primary + +created_at +updated_at +``` + +### Patient Address + +`healthcare_patient_addresses`: + +```text +id +organization_id +patient_id + +address_type +line_1 +line_2 +city +region +postal_code +country_code + +is_primary + +created_at +updated_at +``` + +Sensitive subresources remain permission-controlled. + +## 47. Healthcare Practitioners + +Suggested fields: + +```text +id +organization_id +user_id +professional_profile_id + +specialty +status + +created_at +updated_at +``` + +Professional licenses are not duplicated here. + +Multiple licenses/credentials live in: + +```text +professional_credentials +``` + +## 48. Healthcare Appointments + +Suggested fields: + +```text +id +organization_id + +patient_id +practitioner_id + +location_id nullable +room_id nullable + +appointment_type + +starts_at +ends_at + +status +reason + +created_at +updated_at +version +``` + +Planned supporting tables: + +`healthcare_locations`: + +```text +id +organization_id +name +address fields +timezone +status +``` + +`healthcare_rooms`: + +```text +id +organization_id +location_id +name +status +``` + +Exact scheduling rules are deferred to the healthcare vertical. + +## 49. Healthcare Encounters + +Suggested fields: + +```text +id +organization_id + +patient_id +practitioner_id +appointment_id nullable + +encounter_type + +reason_for_visit nullable + +started_at +ended_at + +status + +created_at +updated_at +version +``` + +Do not add a generic free-form `notes` field as a substitute for clinical records. + +Clinical narrative belongs in governed clinical-record structures. + +## 50. Clinical Records + +Use: + +```text +healthcare_clinical_records +healthcare_clinical_record_versions +healthcare_clinical_record_amendments +``` + +### Clinical Record + +```text +id +organization_id +patient_id +encounter_id +author_practitioner_id + +record_type +sensitivity_level +status + +signed_by_practitioner_id +signed_at + +created_at +updated_at +version +``` + +### Clinical Record Version + +```text +id +organization_id +record_id +version_number + +content_reference or governed content payload +created_by_practitioner_id +created_at +``` + +### Clinical Record Amendment + +```text +id +organization_id +record_id +source_version_id +result_version_id + +amended_by_practitioner_id + +amendment_type +amendment_reason + +created_at +``` + +Possible amendment types: + +```text +correction +addendum +clarification +``` + +Signed/finalized history is preserved. + +REST: + +```http +POST /api/v1/healthcare/encounters/{encounterId}/clinical-records + +GET /api/v1/healthcare/clinical-records/{recordId} +PATCH /api/v1/healthcare/clinical-records/{recordId} +# Draft/editable only. + +POST /api/v1/healthcare/clinical-records/{recordId}/sign +POST /api/v1/healthcare/clinical-records/{recordId}/amend + +GET /api/v1/healthcare/clinical-records/{recordId}/history +GET /api/v1/healthcare/clinical-records/{recordId}/access-log +``` + +## 51. Documents + +Shared document infrastructure: + +```text +documents +document_versions +document_categories +retention_policies +``` + +Binary data lives in S3-compatible object storage. + +### Document + +```text +id +organization_id +name +category_id +classification +retention_policy_id +current_version_id +created_by_user_id +created_at +updated_at +``` + +Classification: + +```text +public +internal +confidential +restricted +regulated +``` + +### Document Version + +```text +id +organization_id +document_id +version_number +storage_key +mime_type +size_bytes +content_hash +hash_algorithm +uploaded_by_user_id +created_at +``` + +Checksum is version-level authoritative data. + +### Document Category + +```text +id +organization_id +profession nullable +name +parent_category_id +created_at +``` + +Uniqueness requirement: + +```text +shared category: + unique organization_id + name where profession IS NULL + +profession category: + unique organization_id + profession + name where profession IS NOT NULL +``` + +Implementation options: + +```text +PostgreSQL null-aware unique constraint when supported +or +two partial unique indexes +``` + +The partial-index fallback does not depend on selecting PostgreSQL 18. + +### Retention Policy + +```text +id +organization_id + +name +profession nullable +classification nullable + +retention_period_days nullable +action + +created_at +updated_at +``` + +Initial actions: + +```text +review +archive +delete_when_legally_permitted +retain_indefinitely +``` + +`retention_period_days` has one meaning only: + +```text +action = retain_indefinitely + → retention_period_days MUST be null + +all other actions + → retention_period_days MUST be a positive integer +``` + +Null does not mean inherit, unconfigured, or unknown. Policy inheritance or an unconfigured state must be represented outside a persisted retention-policy row and specified separately before implementation. + +A retention policy describes configured behavior. + +Actual deletion remains subject to domain, contractual, privacy, and jurisdiction requirements. + +### Metadata + +JSONB is allowed only for genuinely extensible, non-authoritative metadata. + +Do not put authorization, lifecycle, retention state, or ownership into arbitrary JSON. + +## 52. Document Upload Flow + +### Standard Upload + +Request: + +```http +POST /api/v1/documents/upload-url +``` + +```json +{ + "name": "structural-calculations.pdf", + "categoryId": null, + "classification": "confidential", + "mimeType": "application/pdf", + "sizeBytes": 2457600, + "contentHash": "sha256:..." +} +``` + +Response: + +```json +{ + "data": { + "documentId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d01", + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d02", + "uploadUrl": "https://object-storage.example/...", + "expiresAt": "2026-08-26T13:00:00Z" + } +} +``` + +The frontend uploads directly to object storage. + +Finalize: + +```http +POST /api/v1/documents/{documentId}/complete-upload +``` + +```json +{ + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d02", + "contentHash": "sha256:..." +} +``` + +### Multipart Initialization + +```http +POST /api/v1/documents/multipart-uploads +``` + +Request: + +```json +{ + "name": "building-model.bin", + "categoryId": null, + "classification": "confidential", + "mimeType": "application/octet-stream", + "sizeBytes": 2147483648, + "contentHash": null +} +``` + +Response: + +```json +{ + "data": { + "documentId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d10", + "documentVersionId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d11", + "uploadId": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2d12", + "recommendedPartSizeBytes": 67108864, + "expiresAt": "2026-08-27T12:00:00Z" + } +} +``` + +### Request Signed Part URLs + +```http +POST /api/v1/documents/{documentId}/multipart-uploads/{uploadId}/parts +``` + +```json +{ + "partNumbers": [1, 2, 3, 4] +} +``` + +Response: + +```json +{ + "data": [ + { + "partNumber": 1, + "uploadUrl": "https://object-storage.example/..." + } + ] +} +``` + +Binary parts go directly to object storage. + +### Complete Multipart Upload + +```http +POST /api/v1/documents/{documentId}/multipart-uploads/{uploadId}/complete +``` + +```json +{ + "parts": [ + { + "partNumber": 1, + "etag": "..." + } + ], + "contentHash": "sha256:..." +} +``` + +Abort: + +```http +DELETE /api/v1/documents/{documentId}/multipart-uploads/{uploadId} +``` + +Upload state: + +```text +initiated +uploading +completing +completed +aborted +expired +``` + +Workers clean up abandoned multipart uploads. + +Upload policy validates: + +```text +declared MIME +extension +content signature +size +checksum +quota +classification +malware status +``` + +## 53. Profession-Specific Document Links + +Use explicit relationship tables. + +Engineering: + +```text +engineering_project_documents +engineering_design_version_documents +engineering_inspection_documents +``` + +Legal: + +```text +legal_matter_documents +legal_case_documents +``` + +Healthcare: + +```text +healthcare_patient_documents +healthcare_encounter_documents +``` + +`engineering_design_version_documents` is authoritative for files belonging to a specific design revision. + +Do not also maintain an ambiguous `engineering_design_documents` relation to the unversioned design unless a later requirement introduces a separate clearly named supporting-document relationship. + +### Project Documents + +```text +engineering_project_documents +├── id +├── organization_id +├── project_id +├── document_id +├── category +├── linked_by_user_id +├── linked_at +└── unlinked_at +``` + +REST: + +```http +GET /api/v1/engineering/projects/{projectId}/documents +POST /api/v1/engineering/projects/{projectId}/documents +DELETE /api/v1/engineering/project-documents/{documentLinkId} +``` + +`DELETE` temporally unlinks the relation when history must be preserved. + +### Inspection Documents + +`engineering_inspection_documents` fields: + +```text +id +organization_id +inspection_id +document_id +category +linked_by_user_id +linked_at +unlinked_at nullable +``` + +Both the inspection and document references use tenant-safe foreign keys. Active links are unique by `(organization_id, inspection_id, document_id)` where `unlinked_at IS NULL`. + +REST: + +```http +GET /api/v1/engineering/inspections/{inspectionId}/documents +POST /api/v1/engineering/inspections/{inspectionId}/documents +DELETE /api/v1/engineering/inspection-documents/{documentLinkId} +``` + +`DELETE` sets `unlinked_at`; it does not delete the shared document or erase inspection history. + +## 54. Billing + +Shared financial core: + +```text +billing_accounts +invoices +invoice_items +payments +payment_refunds +``` + +### Billing Account + +Invoices reference a shared bill-to identity rather than an unconstrained profession-owned resource. + +`billing_accounts` fields: + +```text +id +organization_id +display_name +legal_name nullable +billing_email nullable +billing_address nullable +status +created_at +updated_at +``` + +Billing-account status values: + +```text +active +inactive +``` + +Profession modules own explicit links from their parties to billing accounts. Initial link shapes are: + +```text +engineering_client_billing_accounts +├── id +├── organization_id +├── client_id +├── billing_account_id +├── linked_at +└── unlinked_at nullable + +legal_client_billing_accounts +├── id +├── organization_id +├── client_id +├── billing_account_id +├── linked_at +└── unlinked_at nullable +``` + +Each reference is tenant-safe. Engineering v1 permits one active billing-account link per client where `unlinked_at IS NULL`; later verticals may define stricter payer rules. Matter, project, encounter, and time-entry provenance remains in profession-owned invoice or invoice-item link tables. Shared billing therefore never depends on profession table internals. + +Billing-account REST: + +```http +GET /api/v1/billing-accounts +POST /api/v1/billing-accounts +GET /api/v1/billing-accounts/{billingAccountId} +PATCH /api/v1/billing-accounts/{billingAccountId} +``` + +### Invoice + +Core fields include: + +```text +id +organization_id +billing_account_id + +bill_to_name +bill_to_email nullable +bill_to_address_snapshot nullable + +invoice_number +status +currency_code +subtotal_minor +tax_total_minor +total_minor +issued_at +due_at +paid_at +created_at +updated_at +version +``` + +Invoice status values: + +```text +draft +issued +partially_paid +paid +overdue +void +``` + +The bill-to fields are immutable snapshots once the invoice is issued. Updating a billing account later does not rewrite an issued invoice. + +`(organization_id, billing_account_id)` uses a tenant-safe foreign key to `billing_accounts(organization_id, id)`. + +### Invoice Item + +```text +id +organization_id +invoice_id + +description + +quantity +unit_price_minor +total_amount_minor + +position + +created_at +updated_at +``` + +`quantity` uses fixed-precision numeric semantics, not floating point. + +Money uses integer minor units. + +The invoice determines currency; invoice items do not independently choose a different currency unless multi-currency invoicing is intentionally designed later. + +### Profession-Specific Source Links + +The shared billing module does not use unconstrained: + +```text +reference_type +reference_id +``` + +to profession-owned tables. + +Profession modules create explicit links, for example: + +```text +engineering_invoice_item_time_entries +├── organization_id +├── invoice_item_id +└── time_entry_id +``` + +This preserves the rule that shared core does not depend on profession-table internals. + +Invoice-level work context also uses explicit profession-owned links, for example: + +```text +engineering_invoice_projects +├── id +├── organization_id +├── invoice_id +├── project_id +└── linked_at + +legal_invoice_matters +├── id +├── organization_id +├── invoice_id +├── matter_id +└── linked_at +``` + +Healthcare invoice context is deferred to the healthcare billing design. The shared invoice table does not contain `reference_type` or `reference_id`. + +### Payment + +`payments` fields: + +```text +id +organization_id +invoice_id +amount_minor +currency_code +payment_method +status +transaction_reference nullable +paid_at nullable +created_at +updated_at +``` + +Payment status values: + +```text +pending +succeeded +failed +partially_refunded +refunded +voided +``` + +`payment_method` is an application registry with initial values `bank_transfer`, `card`, `check`, `cash`, and `other`. `amount_minor` must be positive, and `currency_code` must equal the invoice currency. + +### Payment Refund + +Refunds are separate records so partial or repeated refunds remain auditable. + +`payment_refunds` fields: + +```text +id +organization_id +payment_id +amount_minor +status +transaction_reference nullable +reason nullable +requested_by_user_id +created_at +refunded_at nullable +``` + +Refund status values: + +```text +pending +succeeded +failed +``` + +The sum of successful refunds may not exceed the successful payment amount. Payment and refund commands are transactional, idempotent, and append audit/outbox records. + +REST: + +```http +POST /api/v1/invoices +GET /api/v1/invoices +GET /api/v1/invoices/{invoiceId} +PATCH /api/v1/invoices/{invoiceId} + +POST /api/v1/invoices/{invoiceId}/issue +POST /api/v1/invoices/{invoiceId}/void +POST /api/v1/invoices/{invoiceId}/payments +POST /api/v1/payments/{paymentId}/refund +``` + +## 55. Money Representation + +Use integer minor units: + +```json +{ + "amountMinor": 12550, + "currency": "USD" +} +``` + +Meaning: + +```text +$125.50 +``` + +Never use floating point for money. + +--- + +## 56. Audit Logging + +Use: + +```text +audit_events +``` + +Suggested fields: + +```text +id +organization_id + +actor_type +actor_user_id nullable +actor_service_account_id nullable + +action + +resource_type +resource_id + +request_id +correlation_id + +ip_address +user_agent + +metadata + +occurred_at +``` + +Audit records are append-only from normal application workflows. + +### Mandatory Examples + +Engineering: + +```text +engineering.projects.create +engineering.projects.close +engineering.designs.approve +engineering.inspections.complete +``` + +Legal: + +```text +legal.matters.create +legal.matters.close +legal.conflicts.approve +``` + +Healthcare: + +```text +healthcare.records.read +healthcare.records.write +healthcare.records.sign +healthcare.records.amend +``` + +### Privacy / Erasure Handling + +Append-only audit does not mean "store unlimited personal data forever." + +Audit metadata must be minimized at write time. + +Where privacy, contractual, or retention obligations require removal of personally identifying material, use a governed privacy process such as: + +```text +pseudonymize actor references +null/remove nonessential PII fields +replace identifiers with irreversible privacy references where appropriate +retain the security/business event itself when permitted/required +``` + +The exact action depends on jurisdiction and retention policy and must be reviewed before healthcare/legal production. + +Do not place passwords, tokens, full clinical content, secret keys, or unnecessary payment data in audit metadata. + +REST: + +```http +GET /api/v1/audit-events +``` + +No public mutation endpoints. + +## 57. Domain Events and Transactional Outbox + +Use: + +```text +outbox_events +``` + +Fields: + +```text +id +organization_id nullable for truly global events + +event_type +aggregate_type +aggregate_id + +payload + +request_id nullable +correlation_id +causation_id nullable + +occurred_at +available_at +processed_at + +attempt_count +last_error +dead_lettered_at +``` + +`correlation_id` groups one logical workflow across requests/jobs/events. + +`causation_id` identifies the event/command that directly caused this event when applicable. + +Transaction: + +```text +BEGIN +business change +audit event +outbox event +COMMIT +``` + +Delivery semantics are at-least-once. + +Worker claim uses row locking such as: + +```sql +SELECT id +FROM outbox_events +WHERE processed_at IS NULL + AND dead_lettered_at IS NULL + AND available_at <= now() +ORDER BY occurred_at +FOR UPDATE SKIP LOCKED +LIMIT 100; +``` + +Every external side-effect consumer must be idempotent. + +`FOR UPDATE SKIP LOCKED` prevents simultaneous claiming; it does not prevent duplicate side effects after a worker crash. + +## 57A. Webhooks and External Integrations + +Shared tables: + +```text +webhooks +webhook_event_subscriptions +webhook_deliveries +``` + +### Webhook + +```text +id +organization_id +url +status +secret_ciphertext or signing_key_reference +created_by_user_id +created_at +updated_at +``` + +Webhook status values: + +```text +active +paused +disabled +``` + +### Subscription + +```text +id +organization_id +webhook_id +event_type +created_at +``` + +Unique: + +```text +(organization_id, webhook_id, event_type) +``` + +Only registered externally publishable event types may be subscribed. + +### Delivery + +```text +id +organization_id +webhook_id +event_id + +attempt_number +request_timestamp +response_status +response_summary + +delivered_at +failed_at +next_attempt_at +``` + +`event_id` references `outbox_events.id`. Because webhook deliveries are tenant-owned, only publishable outbox events with the same non-null `organization_id` may be delivered: + +```text +(organization_id, event_id) +→ outbox_events(organization_id, id) +``` + +The webhook publisher allowlists externally publishable `event_type` values before creating delivery records. The stable outbox event ID is also the consumer deduplication key. + +Configuration REST: + +```http +GET /api/v1/webhooks +POST /api/v1/webhooks +GET /api/v1/webhooks/{webhookId} +PATCH /api/v1/webhooks/{webhookId} +DELETE /api/v1/webhooks/{webhookId} + +POST /api/v1/webhooks/{webhookId}/test +POST /api/v1/webhooks/{webhookId}/rotate-secret +``` + +Delivery REST: + +```http +GET /api/v1/webhook-deliveries +GET /api/v1/webhook-deliveries/{deliveryId} +POST /api/v1/webhook-deliveries/{deliveryId}/retry +``` + +If HMAC signing is used, signing material is encrypted/recoverable with managed key protection. + +A one-way secret hash is insufficient for outbound HMAC signing. + +Webhook consumers deduplicate using stable event IDs. + +## 58. Background Jobs + +Workers handle: + +```text +notifications +reports/PDFs +file scanning +document processing +imports +exports +bulk operations +webhooks +search indexing +large data operations +``` + +Use shared tenant-owned: + +```text +jobs +``` + +Fields: + +```text +id +organization_id +requested_by_user_id + +job_type +status + +input_reference +result_reference +progress_percent + +created_at +started_at +completed_at +failed_at + +error_code +error_summary +``` + +Initial `job_type` values: + +```text +project_export +time_entry_import +report_generation +bulk_operation +``` + +`job_type` is an application registry, not a client-defined free-form value or a PostgreSQL enum. Each registered type defines its handler, input-reference schema, result-reference schema, authorization policy, retry policy, and idempotency behavior. Unknown job types are rejected before a job row is created. + +`input_reference` and `result_reference` are nullable typed JSONB reference envelopes, not arbitrary blobs or public URLs. Their schema is registered per `job_type`. + +Allowed reference kinds initially include: + +```text +document_version +object_storage_key +query_snapshot +job +``` + +Object-storage references contain internal storage keys; APIs generate time-limited signed URLs when access is authorized. Resource IDs inside an envelope are validated for tenant ownership when the job is created. Large inputs and outputs live in documents or object storage rather than inside the job row. + +States: + +```text +queued +running +completed +failed +cancelled +``` + +REST: + +```http +GET /api/v1/jobs/{jobId} +GET /api/v1/jobs/{jobId}/result +POST /api/v1/jobs/{jobId}/cancel +``` + +These endpoints are tenant-scoped through the standard: + +```http +X-Organization-Id +``` + +They do not need `/organizations/{id}/jobs` because the platform already chose header-based tenant context. + +Large import/export operations return: + +```http +202 Accepted +``` + +with a job ID. + +## 59. Redis + +Use Redis as an acceleration and coordination layer, not the authoritative system of record. + +Appropriate uses: + +```text +job queue +rate-limit counters +short-lived authorization caches +organization configuration cache +session lookup acceleration +idempotency lookup acceleration +distributed locks when justified +``` + +### Cache Layers + +L1 optional application-memory cache: + +```text +static permission definitions +non-sensitive configuration +``` + +L2 Redis shared cache: + +```text +organization settings +membership snapshots +role permission snapshots +rate-limit counters +session lookup cache +recent idempotency lookups +``` + +CDN: + +```text +frontend static assets +explicitly public assets only +``` + +Do not cache private professional API responses at a CDN by default. + +### Cache Invalidation + +Invalidate or version caches when: + +```text +membership changes +role permissions change +organization settings change +professional credentials change +session is revoked +profession module enablement changes +``` + +High-risk authorization decisions must not depend solely on stale cached credential state. + +### Idempotency Durability + +Redis may improve idempotency lookup latency, but PostgreSQL remains authoritative for high-risk commands. + +## 60. Pagination + +Use cursor pagination. + +Defaults: + +```text +default limit = 25 +maximum limit = 100 +offset pagination = not supported +``` + +Example: + +```http +GET /api/v1/engineering/projects?limit=25 +``` + +Response: + +```json +{ + "data": [], + "meta": { + "pagination": { + "nextCursor": null, + "hasMore": false + } + } +} +``` + +Rules: + +```text +cursor is opaque +sort order must be deterministic +cursor encodes/represents the selected sort position +unsupported limits return validation errors rather than silent huge responses +``` + +## 61. Filtering + +Use explicit resource-specific filters. + +Examples: + +```http +GET /api/v1/engineering/projects?status=active&discipline=structural +GET /api/v1/engineering/tasks?status=todo&assignedToUserId=0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c3d +``` + +Do not build a generic query DSL in v1. + +--- + +## 62. Sorting + +Examples: + +```http +GET /api/v1/engineering/projects?sort=createdAt +GET /api/v1/engineering/projects?sort=-createdAt +``` + +Only explicitly supported fields may be sorted. + +--- + +## 63. Search + +Start with PostgreSQL search. + +Engineering search may cover: + +```text +project number +project name +client name +``` + +Legal: + +```text +matter number +client +case number +``` + +Healthcare: + +```text +patient number +patient identity +``` + +Healthcare search requires stricter privacy and authorization controls. + +Potential PostgreSQL capabilities: + +- B-tree indexes for exact/filter queries +- PostgreSQL full-text search where appropriate +- `pg_trgm` only when fuzzy search requirements justify it + +Do not introduce Elasticsearch/OpenSearch until real query volume, relevance requirements, or indexing features justify another distributed system. + +Do not create every conceivable search index on day one. Indexes cost memory, storage, and write performance. + +## 64. Optimistic Concurrency + +Important mutable resources should use a version field. + +Example: + +```json +{ + "id": "0193c0a0-7c1e-7b3a-8c4d-6e5f4a3b2c5d", + "version": 6 +} +``` + +Update: + +```json +{ + "version": 6, + "name": "Central Tower Phase II" +} +``` + +If the current database version differs: + +```text +409 CONCURRENT_MODIFICATION +``` + +--- + +## 65. Domain-Oriented REST + +Important state transitions use explicit command endpoints. + +Good: + +```http +POST /engineering/projects/{id}/close +POST /engineering/designs/{id}/approve +POST /engineering/tasks/{id}/complete +POST /engineering/inspections/{id}/complete +POST /invoices/{id}/issue +``` + +Avoid: + +```http +PATCH /resource/{id} +{ + "status": "approved" +} +``` + +when the change has significant rules or side effects. + +--- + +## 66. Transaction Boundaries + +Create project: + +```text +BEGIN + +create project +assign project manager +write audit event +write outbox event + +COMMIT +``` + +Approve design: + +```text +BEGIN + +validate permission +validate project access +validate credentials +validate design state +create review result +mark approved +write audit event +write outbox event + +COMMIT +``` + +--- + +## 67. Request Context + +Every authenticated request should resolve: + +```text +RequestContext +{ + requestId + userId + sessionId + organizationId + membershipId + permissions +} +``` + +Profession modules consume this context. + +--- + +## 68. Request IDs + +Every request has: + +```http +X-Request-Id +``` + +If missing, the server generates one. + +Use it in: + +- logs +- audit context +- error diagnostics +- asynchronous correlation + +--- + +## 69. OpenAPI + +Maintain: + +```text +openapi.yaml +``` + +Use OpenAPI 3.1. + +Production server example: + +```yaml +servers: + - url: https://api.example.com/api/v1 +``` + +The server URL and path definitions must remain consistent with the platform base path. + +OpenAPI defines: + +- routes +- request DTOs +- response DTOs +- security schemes +- organization header +- request IDs +- idempotency header +- pagination +- filters +- error schemas +- examples +- profession tags + +Security scheme: + +```yaml +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +``` + +Reusable headers/parameters: + +```text +X-Organization-Id +X-Request-Id +Idempotency-Key +limit +cursor +``` + +CI must validate the OpenAPI document. + +Contract tests should detect drift between implementation and specification. + +Generated clients may be used by the separate frontends, but generated transport code should not dictate frontend domain architecture. + +## 70. DTO Rule + +Database models are not public API contracts. + +Use: + +```text +Request DTO +Response DTO +``` + +A database migration should not accidentally change the public API. + +--- + +## 71. Backend Module Structure + +Recommended: + +```text +src/ +├── core/ +│ ├── auth/ +│ ├── organizations/ +│ ├── memberships/ +│ ├── authorization/ +│ ├── documents/ +│ ├── billing/ +│ ├── audit/ +│ └── events/ +│ +├── engineering/ +│ ├── clients/ +│ ├── projects/ +│ ├── project-members/ +│ ├── phases/ +│ ├── sites/ +│ ├── tasks/ +│ ├── designs/ +│ ├── inspections/ +│ └── specifications/ +│ +├── legal/ +│ ├── clients/ +│ ├── matters/ +│ ├── cases/ +│ ├── hearings/ +│ ├── conflicts/ +│ └── retainers/ +│ +└── healthcare/ + ├── patients/ + ├── practitioners/ + ├── appointments/ + ├── encounters/ + ├── records/ + └── prescriptions/ +``` + +--- + +## 72. Internal Module Structure + +Example: + +```text +projects/ +├── domain/ +│ ├── project.entity.ts +│ ├── project-status.ts +│ └── project.errors.ts +│ +├── application/ +│ ├── commands/ +│ │ ├── create-project.ts +│ │ ├── update-project.ts +│ │ └── close-project.ts +│ │ +│ └── queries/ +│ ├── get-project.ts +│ └── list-projects.ts +│ +├── infrastructure/ +│ └── project.repository.ts +│ +└── api/ + ├── project.controller.ts + ├── project.request.ts + └── project.response.ts +``` + +--- + +## 73. Controllers + +Controllers should handle: + +```text +HTTP +authentication context +input DTO parsing +application command/query invocation +response mapping +``` + +Controllers should not contain: + +```text +business rules +raw SQL +role logic +transaction orchestration +email sending +audit implementation +``` + +--- + +## 74. Commands and Queries + +Mutations use commands. + +Examples: + +```text +CreateEngineeringProjectCommand +ApproveEngineeringDesignCommand +CloseLegalMatterCommand +CompleteHealthcareEncounterCommand +``` + +Reads use queries. + +Examples: + +```text +GetEngineeringProjectQuery +ListLegalMattersQuery +GetHealthcarePatientQuery +``` + +--- + +## 75. Repositories + +Use domain-specific repositories. + +Examples: + +```text +EngineeringProjectRepository +LegalMatterRepository +HealthcarePatientRepository +``` + +Avoid one massive generic repository abstraction that eventually needs dozens of flags. + +--- + +## 76. Security Baseline + +Minimum controls: + +```text +TLS everywhere +strong password hashing +short-lived access tokens +refresh-token rotation/reuse detection +server-side session revocation + +rate limiting +anti-automation controls + +RBAC +resource policies +credential-aware authorization +tenant isolation + +input validation +SQL injection protection + +signed object-storage URLs +file-content validation +malware scanning + +audit trails +secret management +encryption at rest + +dependency/image scanning + +request/correlation IDs +backup and restore testing +``` + +### Web Security / CORS + +ADR-009 defines environment-specific web security. + +Baseline requirements: + +```text +explicit CORS allowlist +no wildcard credentialed CORS +allowed methods/headers documented +preflight behavior tested +HSTS at the edge for production HTTPS +X-Content-Type-Options: nosniff +secure cookie attributes when cookies are used +CSP on browser frontends +frame-ancestor/clickjacking policy on frontends +referrer policy appropriate to the frontend +``` + +Security headers belong at the appropriate application/CDN/gateway layer. + +### Rate Limiting + +Policies are endpoint-specific and configurable. + +Return: + +```http +429 Too Many Requests +Retry-After: ... +``` + +### Secrets + +Production secrets live outside source control, preferably in managed secret/key systems. + +JWT signing keys support rotation. + +## 77. Data Classification + +Suggested classes: + +### Public + +```text +marketing configuration +``` + +### Internal + +```text +organization settings +tasks +``` + +### Confidential + +```text +engineering documents +legal matters +billing +``` + +### Highly Sensitive + +```text +clinical records +professional credentials +authentication secrets +``` + +--- + +## 78. Healthcare Security + +Before healthcare production use, define: + +```text +privacy model +minimum-necessary access model +clinical access policies +break-glass/emergency access policy if required +audit policy +record-signing policy +amendment policy +retention policy +credential policy +scope-of-practice policy +jurisdiction requirements +encryption strategy +consent requirements +data residency requirements +backup/restore handling +export/portability requirements +breach-response requirements +``` + +Healthcare is a stricter security tier. + +Key rules: + +1. default patient responses do not contain all available PHI +2. clinical record reads may be auditable events +3. signed records are immutable except through explicit amendment/version workflows +4. prescribing authorization is jurisdiction-specific +5. privileged clinical commands revalidate professional authority +6. caches must not allow revoked credentials to remain effective for high-risk writes +7. healthcare search results themselves are protected data +8. access logs may require dedicated permissions +9. do not claim regulatory compliance from architecture alone + +## 79. Observability + +Use: + +```text +structured logs +metrics +distributed tracing +request IDs +correlation IDs +``` + +Recommended: + +```text +OpenTelemetry +``` + +### Core Metrics + +API: + +```text +api_requests_total +api_errors_total +api_request_duration_seconds +``` + +Authentication: + +```text +auth_login_attempts_total +auth_token_refresh_total +auth_refresh_reuse_detections_total +auth_sessions_revoked_total +``` + +Authorization/security: + +```text +cross_tenant_access_attempts_total +tenant_isolation_invariant_failures_total +authorization_denials_total +credential_policy_denials_total +rate_limit_events_total +``` + +Important distinction: + +```text +cross_tenant_access_attempt += +request attempted another tenant's resource +``` + +This may be a stale link, mistake, or attack. + +```text +tenant_isolation_invariant_failure += +our system nearly or actually created/returned cross-tenant data +``` + +That is a high-severity internal correctness/security incident. + +Outbox/jobs/webhooks: + +```text +outbox_events_pending +outbox_events_failed_total +outbox_processing_duration_seconds + +jobs_queued +jobs_failed_total +job_duration_seconds + +webhook_delivery_attempts_total +webhook_delivery_failures_total +webhook_delivery_latency_seconds +``` + +Database: + +```text +db_pool_active +db_pool_waiting +db_query_duration_seconds +db_transaction_duration_seconds +``` + +Business metrics may include: + +```text +engineering_projects_created_total +engineering_designs_approved_total +engineering_inspections_completed_total +invoices_issued_total +``` + +Avoid patient-specific or sensitive identifiers in metric labels. + +### Alerts + +Examples: + +```text +refresh token reuse detected +tenant isolation invariant failure +outbox backlog exceeds SLO +webhook failure spike +database pool saturation +error-rate spike +latency regression +backup failure +malware scanner unavailable +``` + +Thresholds are calibrated from real environments rather than copied from a review document. + +### SLOs + +Define by endpoint class. + +Interactive CRUD, reports, file orchestration, and background jobs should not share one arbitrary latency target. + +## 80. Logging + +Useful fields: + +```text +request_id +route +method +status +duration +user_id when appropriate +organization_id when appropriate +``` + +Never log: + +```text +passwords +tokens +clinical record text +full sensitive documents +payment secrets +``` + +--- + +## 81. Testing Strategy + +### Unit Tests + +Test: + +```text +domain rules +state transitions +authorization policies +credential policies +money calculations +idempotency request hashing +``` + +### Property-Based Tests + +Use property-based testing for high-value domain state machines. + +Candidates: + +```text +engineering design lifecycle +engineering inspection lifecycle +invoice lifecycle +payment state transitions +membership/role invariants +``` + +Correct properties: + +```text +every successful transition ends in a valid state + +every forbidden transition is rejected + +terminal states reject prohibited actions + +required invariants survive every valid transition + +transition sequences never bypass required approval/credential rules +``` + +Do not assert that every random state/action pair succeeds. Many are supposed to fail. + +### Integration Tests + +Test: + +```text +repositories +tenant-aware foreign keys +PostgreSQL constraints +transactions +outbox persistence +idempotency persistence +cache invalidation +job persistence +webhook delivery persistence +``` + +### API Tests + +Every important endpoint covers: + +```text +happy path +request validation +authentication +organization context +permission denial +scope denial +credential denial where relevant +cross-tenant access +concurrent modification +invalid state transition +idempotent replay +idempotency conflict +audit creation +outbox creation +``` + +### Outbox Reliability / Chaos Tests + +Test: + +```text +worker crash before side effect +worker crash after side effect but before marking processed +two workers competing for same row +temporary dependency outage +retry/backoff behavior +dead-letter behavior +consumer idempotency +lost worker wake-up +replay +``` + +The dangerous scenario is: + +```text +external side effect succeeds +worker dies +event retries +``` + +Tests must prove the consumer does not create an unacceptable duplicate. + +### Tenant Security Tests + +Test both: + +```text +external cross-tenant access attempts +``` + +and: + +```text +internal cross-tenant data invariant failures +``` + +These are different classes of failure. + +### Performance Tests + +Create realistic profiles: + +```text +interactive reads +interactive writes +search +dashboard read models +reporting +file upload orchestration +outbox processing +webhook bursts +notification bursts +``` + +Measure: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +``` + +Set production SLO gates only after a realistic baseline exists. + +### Coverage + +Track code coverage. + +Do not treat a single percentage such as `90%` as proof of quality. + +Critical-path expectations are stronger: + +```text +all tenant-isolation paths tested +all financial commands tested +all regulated commands tested +all state transitions tested +all critical authorization policies tested +``` + +## 82. Tenant Security Tests + +For every major resource, attempt: + +```text +Organization A resource +using Organization B context +``` + +Test: + +```text +read +update +delete/action +list filtering +search +documents +``` + +Expected result: + +```text +404 / denied +``` + +--- + +## 83. Engineering MVP + +Engineering is the first vertical. + +Initial features: + +```text +Authentication +Organization management +Users / memberships / roles +Engineering clients +Projects +Project members +Project phases +Tasks +Sites +Documents +Basic design records +Inspections +Time entries +Basic billing +Audit history +``` + +Do not initially build: + +```text +advanced CAD integration +BIM integration +full document markup +advanced resource planning +procurement +complex accounting +AI design analysis +IoT integrations +``` + +--- + +## 84. Engineering MVP Workflow + +```text +User registers + ↓ +Creates engineering organization + ↓ +Invites engineer + ↓ +Assigns role + ↓ +Creates client + ↓ +Creates project + ↓ +Assigns project team + ↓ +Creates project phases + ↓ +Creates tasks + ↓ +Uploads documents + ↓ +Creates design + ↓ +Reviews / approves design + ↓ +Schedules inspection + ↓ +Records inspection findings + ↓ +Records engineering time + ↓ +Creates invoice + ↓ +Records payment + ↓ +Closes project + ↓ +Audit history contains lifecycle +``` + +--- + +## 85. Development Phases + +### Phase 0: Architecture Foundation + +Deliver: + +```text +domain boundaries +database conventions +REST conventions +authorization model +session/token model +idempotency strategy +error taxonomy +OpenAPI skeleton +engineering state machines +migration conventions +threat model +initial ADRs +risk register +``` + +### Phase 1: Shared Platform Core + +Build: + +```text +auth +sessions +refresh-token families +token rotation/revocation + +users +organizations +organization professions + +membership invitations +memberships +roles +permissions +authorization + +audit +outbox + +request context +idempotency +rate limiting +observability +``` + +### Phase 2: Engineering CRM + +Build: + +```text +engineering clients +engineering client contacts +client archive/restore +``` + +### Phase 3: Engineering Projects + +Build: + +```text +projects +project members +project phases +activation/close/archive +``` + +### Phase 4: Work and Site Management + +Build: + +```text +tasks +task batch operations +sites +``` + +### Phase 5: Documents + +Build: + +```text +documents +versions +categories +classification +retention references +signed uploads +multipart uploads +content verification +malware scanning +engineering document links +``` + +### Phase 6: Engineering Designs + +Build: + +```text +designs +assignments +versions +reviews +cancel/withdraw semantics +credential-aware approval +audit +outbox +idempotency +``` + +### Phase 7: Engineering Inspections + +Build: + +```text +inspection lifecycle +inspection outcome +findings +corrective work +follow-up inspections +attachments +audit +outbox +idempotency +``` + +### Phase 8: Time, Budgets, and Billing + +Build: + +```text +time entries +batch timesheet submission +project budgets when required +invoices +payments +financial idempotency +reconciliation +``` + +### Phase 9: Notifications, Jobs, and Webhooks + +Build: + +```text +notifications +email +async jobs +imports/exports +webhooks +delivery/retry +dead-letter handling +``` + +### Phase 10: Reporting and Search + +Build: + +```text +project status +overdue work +inspection status +billable time +revenue +outstanding invoices +dashboard read models +``` + +### Phase 11: Engineering Client Portal + +Build: + +```text +portal account invitations +external project grants +published project documents +client review/acceptance workflow +portal audit +portal-specific frontend +``` + +Do not expose professional approval actions to client portal accounts. + +### Phase 12: Legal Vertical + +Validate shared core against: + +```text +matters +cases +conflicts +deadlines +retainers +restricted access / ethical walls +``` + +### Phase 13: Healthcare Readiness and Vertical + +Before implementation: + +```text +healthcare threat model +privacy review +jurisdiction analysis +scope-of-practice policy +record signing/amendment model +retention model +audit requirements +``` + +### Estimation Rule + +These are dependency-ordered milestones. + +They are not calendar promises. + +Calendar estimates require: + +```text +team size +frontend/UX scope +cloud decisions +third-party providers +security requirements +QA capacity +domain-expert availability +``` + +## 86. Legal Expansion + +Only after engineering proves the shared platform assumptions. + +Build: + +```text +Legal Client + ↓ +Matter + ↓ +Case + ↓ +Hearings / Deadlines / Documents +``` + +Do not redesign engineering around legal terminology. + +Extract only genuinely reusable infrastructure. + +--- + +## 87. Healthcare Expansion + +Healthcare comes after: + +- core platform is stable +- audit model is proven +- permission model is proven +- tenant isolation is tested +- retention and encryption strategies are defined + +Healthcare should be treated as its own security and compliance workstream. + +--- + +## 88. Deployment Environments + +Use: + +```text +development +testing +staging +production +``` + +Each environment has independent: + +```text +database +object storage +secrets +queues +API keys +``` + +--- + +## 89. Initial Deployment Architecture + +```text +CDN + │ + ├── Engineering Web + ├── Legal Web + └── Healthcare Web + +Load Balancer + │ + Backend API + │ + ├── PostgreSQL + ├── Redis + ├── Object Storage + └── Queue + │ + Workers +``` + +Prefer managed infrastructure where practical. + +--- + +## 90. Backup Strategy + +Database: + +```text +automated backups +point-in-time recovery +tested restores +``` + +Object storage: + +```text +versioning +retention policies +backup or replication where required +``` + +A backup strategy is incomplete until restoration is tested. + +--- + +## 91. Migration Strategy + +Use explicit immutable migration files. + +Recommended naming: + +```text +YYYYMMDDHHMMSS_description.sql +``` + +Example: + +```text +20260826010000_create_organizations.sql +20260826011000_create_users.sql +20260826012000_create_memberships.sql +20260826013000_create_rbac.sql +20260826014000_create_audit_outbox.sql +20260826015000_create_engineering_clients.sql +``` + +### UUID Standard + +The platform uses UUIDv7. + +Supported implementation choices: + +```text +PostgreSQL 18+: + use native uuidv7() if database-generated identifiers are desired + +Earlier PostgreSQL: + generate UUIDv7 in the application or use a controlled extension +``` + +Database columns remain PostgreSQL `UUID`. + +The rule is consistency, not ideological loyalty to one generation layer. + +Do not silently fall back to UUIDv4 while documenting UUIDv7. + +### Production Migration Rules + +Use expand/contract: + +```text +1. add backward-compatible schema +2. deploy code supporting old + new schema +3. backfill/migrate +4. switch reads/writes +5. observe +6. remove obsolete schema later +``` + +For destructive changes: + +```text +backup/restore plan +compatibility window +production-like dry run +explicit approval +post-migration verification +``` + +Do not assume a destructive database migration can always be reversed by a simple down migration. + +Never use automatic ORM schema synchronization in production. + +## 91A. Architecture Decision Records + +v4 stops treating technology suggestions as automatically settled architecture. + +Create ADRs before implementation locks in: + +```text +ADR-001 Backend Framework +ADR-002 SQL / ORM / Query Layer +ADR-003 Queue Implementation +ADR-004 PostgreSQL Minimum Version +ADR-005 Error Format / RFC 9457 Compatibility +ADR-006 Rate-Limit Header Convention +ADR-007 Webhook Signing Strategy +ADR-008 Object Storage Provider / Multipart Strategy +ADR-009 Web Security / CORS / Browser Headers +ADR-010 Machine Authentication / API Key Policy +``` + +Each ADR should include: + +```text +context +decision +alternatives considered +tradeoffs +security impact +operational impact +migration/exit path +date +status +``` + +The architecture currently fixes capabilities and boundaries. + +It does not require a framework merely because a review document described it positively. + +--- + +## 92. Technology Recommendation + +The following are preferred candidates, not all final decisions. + +### Fixed Platform Choices + +```text +API style: REST +Contract: OpenAPI 3.1 +Primary language: TypeScript +Primary database: PostgreSQL +Architecture: Modular Monolith +Observability standard: OpenTelemetry +Object storage model: S3-compatible +Container model: Docker/OCI +``` + +### ADR-Gated Choices + +Backend framework candidates: + +```text +NestJS +Fastify-centered custom application structure +``` + +SQL / persistence candidates: + +```text +Drizzle +Kysely +Prisma +direct SQL for specialized queries +``` + +Queue candidates: + +```text +BullMQ / Redis +managed cloud queue +``` + +PostgreSQL baseline: + +```text +PostgreSQL 18+ +``` + +is attractive because of native UUIDv7 and current capabilities, but the minimum supported version must be confirmed against: + +```text +hosting provider availability +operations policy +extension requirements +upgrade policy +support lifecycle +``` + +Do not claim one ORM is categorically "faster" or "better" without workload-specific evidence. + +The selected stack should preserve: + +```text +transaction control +explicit SQL visibility +tenant-safe query design +migration control +observability +testability +``` + +## 93. REST API Milestones + +### Milestone 1: Platform Access and Security + +```http +POST /auth/register +POST /auth/login + +POST /auth/token/refresh +POST /auth/token/revoke +POST /auth/token/revoke-all + +GET /auth/sessions +DELETE /auth/sessions/{sessionId} + +GET /me + +POST /organizations +GET /me/organizations + +POST /membership-invitations +GET /memberships + +GET /roles +POST /roles +GET /permissions +``` + +Includes: + +```text +explicit organization context +session revocation +refresh-token reuse detection +audit foundation +outbox foundation +idempotency foundation +rate limiting +``` + +### Milestone 2: Engineering Clients + +```http +GET /engineering/clients +POST /engineering/clients +GET /engineering/clients/{id} +PATCH /engineering/clients/{id} +POST /engineering/clients/{id}/archive +POST /engineering/clients/{id}/restore +GET /engineering/clients/{id}/projects +``` + +### Milestone 3: Engineering Projects + +```http +GET /engineering/projects +POST /engineering/projects +GET /engineering/projects/{id} +PATCH /engineering/projects/{id} + +POST /engineering/projects/{id}/activate +POST /engineering/projects/{id}/close +POST /engineering/projects/{id}/archive + +GET /engineering/projects/{id}/summary +``` + +Timeline and budget read models follow when the frontend requires them. + +### Milestone 4: Collaboration + +```http +POST /engineering/projects/{id}/members +GET /engineering/projects/{id}/members + +POST /engineering/tasks +GET /engineering/tasks +POST /engineering/tasks/{id}/complete +``` + +### Milestone 5: Sites and Documents + +Build: + +```text +engineering sites +signed file uploads +document versions +malware scanning +project document links +``` + +### Milestone 6: Designs + +Build: + +```text +design lifecycle +versions +reviews +submit-review +request-changes +approve +reject +supersede +credential validation +audit + outbox + idempotency +``` + +### Milestone 7: Inspections + +Build: + +```text +schedule +start +complete +cancel +findings +finding resolution +audit + outbox + idempotency +``` + +### Milestone 8: Commercial Workflows + +Build: + +```text +time entries +invoices +payments +refunds +financial idempotency +reports +``` + +## 94. Architecture Rules to Freeze + +1. REST is the primary frontend and integration API. +2. Base path is `/api/v1`. +3. OpenAPI 3.1 is the public API contract. +4. GraphQL is not part of v1. +5. Start as one modular monolith backend. +6. Each profession has its own frontend. +7. Each profession owns its domain tables and state machines. +8. Shared modules provide infrastructure, not forced domain abstractions. +9. Public serialized IDs are raw UUIDv7. +10. Database ID columns use PostgreSQL UUID. +11. Human-readable business references are separate from resource IDs. +12. Every tenant-owned row carries direct `organization_id`. +13. Tenant-scoped requests require explicit `X-Organization-Id`. +14. Tenant boundaries are enforced in queries and database constraints. +15. Cross-tenant resources appear nonexistent. +16. API JSON/query parameter names use camelCase; DB identifiers use snake_case. +17. Authorization is server-side and deny-by-default. +18. `assigned` scope is defined per resource policy, never inferred generically. +19. Roles and professional credentials are separate. +20. A professional profile may own multiple credentials. +21. Sessions and refresh tokens are separate resources. +22. Refresh tokens rotate within families and support reuse detection. +23. Machine identities use service accounts/API keys, not fake human memberships. +24. Important domain transitions use explicit REST command endpoints. +25. High-risk commands use durable idempotency. +26. Batch custom actions use `/{collection}/batch/{action}`. +27. Every batch defines atomic or partial semantics. +28. Every batch item receives independent authorization/domain validation. +29. Large batches become asynchronous jobs. +30. Project phases are authoritative; duplicated project `stage` and `current_phase_id` are not stored in v1. +31. Project budgets use the dedicated budget model and project `budget_minor` is not authoritative; shared invoices reference `billing_accounts` and never unconstrained polymorphic profession resources. +32. Engineering time entries may attribute time to one explicit primary work item using tenant- and project-consistent composite foreign keys. +33. Design versions and engineering specifications use explicit document-link tables with one-to-many cardinality and separate role registries. +34. All design/assignment/review/version/inspection-document/finding/follow-up subresources carry `organization_id`. +35. Inspection lifecycle and inspection outcome are separate. +36. Inspection follow-ups are explicit resources. +37. Engineering change requests remain deferred until fully specified. +38. Shared documents own document records; profession modules own link tables. +39. Legal does not duplicate shared document ownership. +40. Large files use object-storage multipart uploads. +41. Application servers do not proxy multi-gigabyte chunks. +42. Document checksums belong to document versions. +43. Document classification is multi-level. +44. Document retention is explicit policy. +45. Document category uniqueness must work for nullable profession values on the selected PostgreSQL version. +46. Project document linkage does not imply client-portal publication. +47. External publication requires explicit publication records. +48. Client portal accounts are not internal memberships. +49. Client acceptance is not professional engineering approval. +50. Domain events use a transactional outbox. +51. Outbox delivery is at-least-once. +52. Outbox events carry correlation/causation identifiers. +53. External side-effect consumers are idempotent. +54. Webhook subscriptions and deliveries are tenant-owned. +55. HMAC signing secrets are securely recoverable/encrypted, not only hashed. +56. Jobs are tenant-scoped by the standard organization header, and every `job_type` is registered with schemas, authorization, retry, and idempotency behavior. +57. PostgreSQL is the authoritative transactional datastore. +58. Redis is acceleration/coordination, not critical source of truth. +59. Search starts with PostgreSQL. +60. Collections use cursor pagination, default 25 and max 100. +61. Important mutable resources use optimistic concurrency. +62. Database entities are not serialized directly. +63. Errors use stable codes. +64. `429` responses use `Retry-After`; exact quota headers are an API decision. +65. Business records use explicit archive/revoke/unlink/hard-delete lifecycle policies. +66. Financial/professional/audit records are not casually hard-deleted. +67. Audit metadata is minimized and supports governed privacy transformation when required. +68. Important/regulated actions are audited. +69. Signed clinical records use sign/amend/version workflows. +70. Prescribing authority remains jurisdiction/scope-of-practice policy. +71. Production migrations use expand/contract. +72. Destructive changes are not assumed trivially reversible. +73. Secrets remain outside source control. +74. CORS and browser security policy are explicit ADR/configuration. +75. Rate limits are calibrated by evidence. +76. CI validates types, tests, OpenAPI, migrations, and security checks. +77. Property-based tests cover high-value state machines. +78. Outbox/job/webhook reliability is tested under failure/concurrency. +79. Critical-path tests matter more than vanity coverage percentages. +80. Framework/ORM/queue/PostgreSQL-minimum choices require ADRs. +81. Engineering is the first vertical. +82. Client portal follows internal Engineering MVP foundations. +83. Legal follows after Engineering validates shared assumptions. +84. Healthcare requires dedicated privacy/security/domain design before implementation. +85. Architecture documentation never equates "designed for" with "certified/compliant". + +## 95. Required Design Artifacts + +Maintain: + +```text +01_PROJECT_ARCHITECTURE.md +02_DATABASE_CONVENTIONS.md +03_AUTHORIZATION_MODEL.md +04_AUTH_SESSION_MODEL.md + +05_ENGINEERING_DOMAIN.md +06_ENGINEERING_DATABASE_SCHEMA.md +07_ENGINEERING_STATE_MACHINES.md + +08_API_CONVENTIONS.md +09_ENGINEERING_API_SPEC.md +10_OPENAPI.yaml + +11_FRONTEND_ARCHITECTURE.md +12_CLIENT_PORTAL_SECURITY_MODEL.md + +13_DOCUMENT_SECURITY_MODEL.md +14_LARGE_FILE_UPLOAD_MODEL.md + +15_WEBHOOK_INTEGRATION_MODEL.md +16_ASYNC_JOB_MODEL.md + +17_SECURITY_MODEL.md +18_DEPLOYMENT_ARCHITECTURE.md +19_OBSERVABILITY_MODEL.md +20_TESTING_STRATEGY.md + +21_ARCHITECTURE_DECISION_RECORDS/ +22_RISK_REGISTER.md +23_MVP_BACKLOG.md +``` + +Important ADRs: + +```text +backend framework +persistence/query layer +queue implementation +PostgreSQL minimum version +error format +rate-limit headers +webhook signing +object-storage provider +``` + +## 96. Recommended Implementation Order + +```text +Foundation + ↓ +Authentication + ↓ +Organizations + ↓ +Memberships + ↓ +RBAC + ↓ +Engineering Clients + ↓ +Engineering Projects + ↓ +Project Team + ↓ +Tasks + ↓ +Sites + ↓ +Documents + ↓ +Designs + ↓ +Inspections + ↓ +Time Tracking + ↓ +Billing + ↓ +Notifications + ↓ +Reports + ↓ +Legal Vertical + ↓ +Healthcare Vertical +``` + +--- + +## 97A. Database Indexing Strategy + +All tenant-owned tables need efficient tenant scoping. + +Baseline: + +```text +(organization_id, id) +``` + +Common list access often benefits from: + +```text +(organization_id, created_at) +``` + +Query-specific examples: + +```text +(organization_id, status) +(organization_id, client_id) +(organization_id, project_id) +(organization_id, assigned_to_user_id) +``` + +### Rules + +1. every index corresponds to a known query, ordering, or constraint +2. column order follows real predicates +3. validate with `EXPLAIN (ANALYZE, BUFFERS)` +4. include production-like cardinality in testing +5. measure write amplification +6. do not index every field +7. introduce trigram/full-text indexes only for actual search requirements + +Potential later tools: + +```text +covering indexes +materialized views +read replicas +table partitioning +external search +``` + +These are evidence-driven scaling mechanisms, not baseline dependencies. + +### Document Category Uniqueness + +If a nullable field such as profession participates in uniqueness: + +```text +organization_id +profession nullable +name +``` + +do not assume plain uniqueness treats NULL as one shared value. + +Use PostgreSQL-supported null-aware uniqueness or partial unique indexes according to the selected PostgreSQL version. + +--- + +## 97B. CI/CD and Deployment Gates + +Pipeline stages: + +```text +lint/typecheck + ↓ +unit tests + ↓ +integration tests + ↓ +OpenAPI validation + contract tests + ↓ +security/dependency scan + ↓ +container build + image scan + ↓ +migration compatibility check + ↓ +deploy development + ↓ +smoke tests + ↓ +deploy staging + ↓ +E2E + performance/security baseline + ↓ +manual production approval + ↓ +production deployment + ↓ +post-deploy verification +``` + +Production deployment should support: + +```text +rolling or blue/green application deployment +backward-compatible database migrations +health checks +fast application rollback +feature flags for incomplete features +observability gates +``` + +Database schema rollback is not treated as equivalent to application rollback. + + +### Feature Flags + +Feature flags used for deployment safety are operational configuration, not automatically a business database table. + +Initial implementation may use: + +```text +environment/config-service flags +``` + +for global rollout and kill switches. + +If per-organization feature rollout is later required, introduce an explicit tenant-owned model such as: + +```text +organization_feature_flags +``` + +through an ADR/migration. + +Do not overload `organization_professions` with unrelated product experiments. + + +### Configuration and Secrets + +Non-secret configuration may use environment variables. + +Secrets should use a managed secret store where possible: + +```text +database credentials +Redis credentials +JWT/private signing keys +object storage credentials +SMTP/API provider credentials +monitoring credentials +``` + +Do not publish real secrets in sample configuration. + +Organization profession enablement remains primarily data-driven through `organization_professions`. + +Global feature flags may be used for staged rollout, kill switches, or incomplete features. + +--- + +## 97C. Review-Driven Deferred Decisions + +The following ideas are valid possibilities but are explicitly **not frozen into v1**: + +```text +read replicas +materialized views +Elasticsearch/OpenSearch +universal 100 MB file limit +fixed 100 req/min user limit +fixed 1000 req/hour organization limit +specific cache-hit-ratio target +specific p95 latency promise +database-per-tenant +microservices +GraphQL +``` + +These require evidence from: + +```text +load tests +security analysis +customer requirements +compliance requirements +real production workloads +``` + +This prevents benchmark-shaped guesses from becoming architecture law. + +--- + +## 97D. Provisional Performance Objectives + +Performance numbers in architecture are starting hypotheses, not guarantees. + +Initial engineering objectives may begin with: + +```text +Interactive read: + target p95 <= 500 ms + +Interactive mutation: + target p95 <= 750 ms + +Simple list/search: + target p95 <= 800 ms + +Upload authorization: + target p95 <= 300 ms + +Background outbox pickup: + target <= 5 seconds under normal operating conditions +``` + +These are revised after realistic testing. + +Track: + +```text +p50 +p95 +p99 +throughput +error rate +database saturation +queue backlog +outbox lag +``` + +Different endpoint classes receive different SLOs. + +Do not use file-transfer completion time as an API SLO when bytes travel directly between client and object storage. + +--- + +## 97E. Risk Register + +Maintain a living risk register. + +Suggested structure: + +| Risk | Impact | Mitigation | Owner | Phase | Status | +|---|---|---|---|---|---| +| Cross-tenant data exposure | Critical | Tenant-aware FKs, scoped queries, security tests | Backend/Security | P0 | Open | +| Non-idempotent outbox side effect | Critical | Consumer dedupe, provider idempotency, chaos tests | Backend | P0 | Open | +| Migration failure | High | Expand/contract, dry runs, backups | Backend/Platform | P0 | Open | +| Engineering workflow mismatch | High | Domain expert validation | Product/Engineering SME | MVP | Open | +| Portal authorization leak | Critical | Separate external access model, publication grants | Backend/Security | Portal | Open | +| Webhook delivery instability | Medium | Retry, dead-letter, replay, metrics | Backend | Integrations | Open | +| Large upload abandonment | Medium | Multipart expiry and cleanup | Backend/Platform | Documents | Open | +| Documentation drift | Medium | OpenAPI validation, ADRs, CI | Engineering | Continuous | Open | + +Do not pretend likelihood labels are quantitative unless the team defines and uses a scoring method. + +--- + +## 97F. Architecture Change Governance + +v4 is the last broad platform-architecture revision before Engineering MVP implementation. + +New discoveries should normally become: + +```text +ADR +OpenAPI change +database migration +domain-state-machine update +security decision +backlog item +runbook +``` + +rather than a new full architecture rewrite. + +Reopen the broad architecture only when a discovery invalidates one of these foundational assumptions: + +```text +tenant model +profession separation +shared-core boundary +REST API model +data ownership +security trust boundary +deployment topology +database architecture +``` + +This prevents design review from becoming an infinite recursion problem. + +--- + +## 97G. Production Readiness Gates + +Architecture being coherent does not mean production is safe. + +Before production, require evidence in these categories. + +### Security + +```text +TLS configured +password hashing configured +refresh rotation/reuse detection tested +session revocation tested +tenant isolation tests passing +authorization/credential policies tested +rate limiting active +secrets managed outside source control +file security scanning active +security review completed +``` + +### Reliability + +```text +database backups automated +restore tested +object storage recovery strategy tested +outbox monitoring active +job queue monitoring active +webhook retry/dead-letter behavior tested +health checks configured +dependency failures tested +``` + +### Data Integrity + +```text +tenant-aware foreign keys present where required +financial invariants tested +migration tested on production-like data +idempotency tested for high-risk commands +optimistic concurrency tested +audit integrity tested +``` + +### Contract / API + +```text +OpenAPI validates +contract tests pass +error schema consistent +versioning rules documented +client SDK generation validated if used +``` + +### Performance + +```text +load test executed +realistic SLOs defined +database pool configured +key queries analyzed +outbox/job backlogs remain within SLO +``` + +### Critical Domain Coverage + +Rather than a magic overall coverage number, require explicit test coverage for: + +```text +tenant boundaries +design approval +inspection completion +invoice issue +payment/refund +membership privilege changes +clinical record signing/amendment when healthcare exists +prescribing authorization when healthcare exists +``` + +### Release Gate Principle + +No single metric such as: + +```text +90% test coverage +``` + +is sufficient evidence of production readiness. + +Quality gates are based on critical behavior, not vanity percentages. + +--- + +## 97. Final Design Position + +The platform is: + +```text +One Shared Platform + │ + ├── Shared Identity / Sessions + ├── Shared Security / Authorization + ├── Shared Documents / Multipart Uploads + ├── Shared Financial Core + ├── Shared Audit / Outbox + ├── Shared Jobs / Webhooks / Notifications + │ + ├── Engineering Internal Product + │ ├── Engineering Frontend + │ ├── Engineering REST APIs + │ ├── Engineering State Machines + │ └── Engineering Tables + │ + ├── Engineering Client Portal + │ ├── External Portal Frontend + │ ├── Portal Accounts + │ ├── Project Grants + │ ├── Published Documents + │ └── Client Review / Acceptance + │ + ├── Legal Product + │ ├── Legal Frontend + │ ├── Legal REST APIs + │ └── Legal Tables + │ + └── Healthcare Product + ├── Healthcare Frontend + ├── Healthcare REST APIs + ├── Healthcare Security Policies + └── Healthcare Tables +``` + +The system shares infrastructure where reuse is valuable while preserving profession-specific domain semantics and trust boundaries. + +v4 is the final broad architecture baseline for Engineering MVP implementation. + +From this point forward, architecture detail should primarily move into: + +```text +ADRs +OpenAPI +database schema/migrations +state-machine specifications +security policies +implementation backlog +runbooks +``` + +rather than repeatedly rewriting the entire architecture plan. + +This document does not itself prove: + +```text +regulatory compliance +production certification +security certification +performance at a specific scale +``` + +Those require implementation evidence, security review, domain validation, operational testing, restore testing, and measured production-like workloads. + + + + +--- + +# v4.1 Changelog + +v4.1 resolves implementation-contract issues without changing the core architecture. + +```text +✓ raw UUIDv7 API ID contract +✓ camelCase API / snake_case database naming convention +✓ direct organization_id on tenant subresources +✓ invitation role assignments +✓ service accounts and hashed API keys +✓ multiple professional credentials per profile +✓ global archive/revoke/unlink/hard-delete policy +✓ project stage duplication removed +✓ project budget_minor removed +✓ project phase reorder command +✓ global engineering site listing +✓ task status and priority vocabularies +✓ design revise endpoint +✓ design-assignment schema and temporal unassignment +✓ design-version many-document cardinality +✓ design review/version tenant keys +✓ inspection finding tenant keys +✓ explicit inspection follow-up table +✓ inspection-document link schema and REST +✓ change requests deferred until fully specified +✓ time-entry work-item attribution +✓ time-entry project/work-item consistency constraints +✓ specification many-document cardinality and status values +✓ specification lifecycle commands and document REST +✓ legal_documents duplication removed +✓ legal matter/case document-link schemas +✓ healthcare placeholder schemas clarified +✓ invoice-item schema defined +✓ shared billing-account invoice reference +✓ payment and refund schemas +✓ document retention-policy schema +✓ explicit retention-period null semantics +✓ version-independent document-category uniqueness fallback +✓ standard upload DTO +✓ multipart-init/parts/complete DTOs +✓ webhook subscription schema +✓ webhook status vocabulary +✓ webhook delivery references outbox events +✓ typed job input/result references +✓ registered job-type vocabulary +✓ organization, user, membership, profile, service-account, invoice, and client vocabularies +✓ portal access profiles and client-review-request schema +✓ project-member PATCH contract +✓ derived project phase; no current_phase_id in v1 +✓ time-entry billing snapshot constraint +✓ logout endpoint +✓ assigned-scope resolution rules +✓ audit privacy transformation strategy +✓ outbox correlation and causation IDs +✓ CORS/browser-security ADR +✓ feature-flag strategy clarified +✓ jobs confirmed tenant-scoped via X-Organization-Id +``` + +The next artifacts should be implementation-specific: + +```text +ADRs +Engineering OpenAPI +Engineering database migrations +Engineering state-machine spec +Engineering MVP backlog +```