Incidents & Near Misses¶
Status: Specified — ready to build Build order: 7 of 10 — depends on Projects & Sites, Personnel & Attendance, Plant & Equipment
Purpose¶
Incidents & Near Misses is the reporting and investigation log for health, safety, and environmental events on site. It captures the initial report, classifies RIDDOR-reportable events, guides the reporter through mandatory details, and tracks the 10-day HSE reporting deadline. All incident records form the immutable audit trail required for legal and insurance purposes.
Entities¶
Incident¶
A single incident or near miss event.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| reference | VARCHAR(20) | System | Auto-generated: INC-{YYYY}-{seq:04d}. Unique within tenant |
| site_id | UUID | ✓ | FK → sites.id |
| project_id | UUID | ✓ | Denormalised |
| incident_type | VARCHAR(30) | ✓ | incident | near_miss | dangerous_occurrence | environmental |
| severity | VARCHAR(20) | ✓ | See severity levels below |
| title | VARCHAR(255) | ✓ | Brief description |
| description | TEXT | ✓ | Full narrative |
| occurred_at | TIMESTAMPTZ | ✓ | When the incident happened |
| reported_at | TIMESTAMPTZ | System | When this record was created |
| location_description | TEXT | Specific location on site | |
| involved_personnel | UUID[] | Array of personnel.id — workers involved | |
| involved_plant | UUID[] | Array of plant_items.id — plant involved | |
| immediate_action_taken | TEXT | What was done immediately after the incident | |
| riddor_reportable | BOOLEAN | ✓ | Default: false. Set true to trigger RIDDOR workflow |
| riddor_category | VARCHAR(50) | Required if riddor_reportable = true. See categories below |
|
| riddor_reported_to_hse | BOOLEAN | ✓ | Default: false |
| riddor_reported_at | TIMESTAMPTZ | When reported to HSE | |
| riddor_reference | VARCHAR(100) | HSE reference number after reporting | |
| riddor_deadline | TIMESTAMPTZ | Computed: occurred_at + 10 days. Set when riddor_reportable = true |
|
| status | VARCHAR(20) | ✓ | Default: open |
| closed_at | TIMESTAMPTZ | Set when status → closed |
|
| closed_by | UUID | FK → users.id | |
| root_cause | TEXT | Added during investigation | |
| corrective_actions | TEXT | Actions taken to prevent recurrence | |
| custom_fields | JSONB | ✓ | Default {} |
| created_at | TIMESTAMPTZ | System | |
| updated_at | TIMESTAMPTZ | System | Trigger-maintained |
| created_by | UUID | System | FK → users.id |
| updated_by | UUID | System | |
| deleted_at | TIMESTAMPTZ | Soft delete — investigation records are never truly removed |
CREATE UNIQUE INDEX idx_incident_reference
ON incidents(reference) WHERE deleted_at IS NULL;
CREATE INDEX idx_incident_site_id
ON incidents(site_id, occurred_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_incident_project_id
ON incidents(project_id, occurred_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_incident_status
ON incidents(project_id, status) WHERE deleted_at IS NULL;
-- RIDDOR deadline scanner
CREATE INDEX idx_incident_riddor_deadline
ON incidents(riddor_deadline)
WHERE riddor_reportable = true
AND riddor_reported_to_hse = false
AND deleted_at IS NULL;
Severity levels:
| Level | Definition | RIDDOR likely |
|---|---|---|
near_miss |
No injury — an event that could have caused harm | No |
first_aid |
Injury treated by first aider on site, no medical referral | No |
medical_treatment |
Required hospital or GP treatment | Possibly |
lost_time |
Worker absent from work the following day or longer | Yes |
specified_injury |
HSE-defined serious injury (fracture, amputation, burn, etc.) | Yes |
dangerous_occurrence |
Collapse, explosion, release of substance — no injury | Yes |
fatality |
Death of any person | Yes |
RIDDOR categories:
| Category | Description |
|---|---|
specified_injury_worker |
Worker suffered a specified injury |
over_7_day_injury_worker |
Worker absent for more than 7 consecutive days |
death_worker |
Death of a worker |
death_non_worker |
Death of a non-worker (member of public, visitor) |
non_fatal_non_worker |
Non-fatal injury to non-worker requiring hospital treatment |
dangerous_occurrence |
Dangerous occurrence (Schedule 2 RIDDOR) |
occupational_disease |
Reportable occupational disease (Schedule 3 RIDDOR) |
Incident status:
| Status | Meaning |
|---|---|
open |
Newly reported, under review |
under_investigation |
Formal investigation in progress |
closed |
Investigation complete, corrective actions recorded |
IncidentPhoto¶
Photos attached to an incident record.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| incident_id | UUID | ✓ | FK → incidents.id |
| site_id | UUID | ✓ | Denormalised |
| project_id | UUID | ✓ | Denormalised |
| photo_key | VARCHAR(500) | ✓ | S3 key |
| caption | VARCHAR(255) | ||
| taken_at | TIMESTAMPTZ | ||
| sort_order | INTEGER | ✓ | Default: 0 |
| created_at | TIMESTAMPTZ | System | |
| created_by | UUID | System | |
| deleted_at | TIMESTAMPTZ |
Business Rules¶
-
referenceis system-generated at creation time using a per-tenant sequence:INC-{current_year}-{sequence:04d}. Example:INC-2026-0047. The sequence resets each calendar year. Implemented in the service layer usingSELECT MAX(sequence_number) + 1 FROM incidents WHERE EXTRACT(YEAR FROM created_at) = {year}. -
When
riddor_reportableis set totrue: riddor_categorybecomes requiredriddor_deadlineis computed asoccurred_at + INTERVAL '10 days'-
The
incident.riddor_deadlinenotification is scheduled via the SQS queue for the scanner to pick up at 5 days and 2 days before deadline -
riddor_reportablecan be set at creation time or updated later (Site Managers may not initially recognise RIDDOR reportability). When updated totrueon an existing record,riddor_deadlineis computed from the originaloccurred_at. -
Setting
riddor_reported_to_hse = truerequiresriddor_reported_atandriddor_reference. This terminates the RIDDOR deadline notification. -
involved_personnelis stored as a UUID array (PostgreSQLUUID[]). The API returns the full personnel summary (name, employment type) by joining on query. This denormalised array avoids a join table for a V1 feature. -
involved_plantis similarly stored as a UUID array. -
Closing an incident (
status → closed) requiresroot_causeandcorrective_actionsto be non-empty. Enforced in service layer. -
Closed incidents can be reopened (set back to
under_investigation) by Project Manager and above. -
Incident records are never hard deleted. The soft delete on the
deleted_atcolumn means records remain accessible in audit exports even after deletion. -
The RIDDOR guidance text shown in the UI is static informational content — it does not replace professional legal advice. It guides the site manager on whether the incident is likely reportable.
RIDDOR Guidance (UI Display Text)¶
When a user reports an incident and selects severity lost_time, specified_injury,
dangerous_occurrence, or fatality, the UI displays this guidance block
before the riddor_reportable question:
RIDDOR — is this reportable?
Under the Reporting of Injuries, Diseases and Dangerous Occurrences Regulations 2013, certain workplace incidents must be reported to the HSE within 10 days of the incident.
This may be reportable if: - A worker has been absent for more than 7 consecutive days (not counting the day of the incident) — report within 15 days - A worker suffered a specified injury (fracture except fingers/thumbs/toes, amputation, crush, loss of sight, chemical/hot metal burn to eye, electric shock, hospitalised for over 24 hours, or any injury leading to unconsciousness) - A dangerous occurrence occurred (crane collapse, explosion, scaffold collapse, etc.)
Contact your H&S manager or check hse.gov.uk/riddor for the full list. This platform reminds you of the deadline but does not submit to HSE on your behalf — you must report via HSE's online portal or by phone.
This text is stored as a tenant_label with key guidance.riddor so tenants
can customise it.
API Endpoints¶
Incidents¶
| Method | Path | Description |
|---|---|---|
| GET | /sites/{siteId}/incidents |
List incidents for site (paginated, filterable) |
| POST | /sites/{siteId}/incidents |
Report incident |
| GET | /sites/{siteId}/incidents/{id} |
Full detail |
| PATCH | /sites/{siteId}/incidents/{id} |
Update |
| DELETE | /sites/{siteId}/incidents/{id} |
Soft delete (Project Manager+) |
| POST | /sites/{siteId}/incidents/{id}/transition |
Status transition |
| POST | /sites/{siteId}/incidents/{id}/mark-riddor-reported |
Mark as reported to HSE |
Photos¶
| Method | Path | Description |
|---|---|---|
| GET | /sites/{siteId}/incidents/{id}/photos |
List photos |
| POST | /sites/{siteId}/incidents/{id}/photos |
Add photo |
| DELETE | /sites/{siteId}/incidents/{id}/photos/{photoId} |
Soft delete |
Project-level view¶
| Method | Path | Description |
|---|---|---|
| GET | /projects/{projectId}/incidents |
All incidents across sites (paginated) |
| GET | /projects/{projectId}/incidents/riddor-pending |
RIDDOR-reportable not yet reported to HSE |
Request / Response Shapes¶
POST /sites/{siteId}/incidents¶
// Request
{
"incident_type": "incident",
"severity": "lost_time",
"title": "Worker fell from scaffold — left ankle fracture",
"description": "At approximately 14:20, Marcus Obi (direct worker) fell approx. 1.5m from a working platform on scaffold adjacent to Grid C. He was wearing a harness but the lanyard was not clipped. Immediate first aid applied on site; ambulance called, worker taken to Royal London Hospital.",
"occurred_at": "2026-05-28T14:20:00Z",
"location_description": "Scaffold — Grid C, Level 2, north elevation",
"involved_personnel": ["uuid-marcus-obi"],
"involved_plant": [],
"immediate_action_taken": "First aid applied. Area cordoned off. Ambulance called. Next of kin notified. Scaffold platform inspected and declared safe.",
"riddor_reportable": true,
"riddor_category": "specified_injury_worker",
"custom_fields": {}
}
// Response 201
{
"id": "uuid-incident",
"reference": "INC-2026-0047",
"site_id": "uuid-site",
"project_id": "uuid-project",
"incident_type": "incident",
"severity": "lost_time",
"title": "Worker fell from scaffold — left ankle fracture",
"description": "...",
"occurred_at": "2026-05-28T14:20:00Z",
"reported_at": "2026-05-28T16:55:00Z",
"location_description": "Scaffold — Grid C, Level 2, north elevation",
"involved_personnel": [
{
"personnel_id": "uuid-marcus-obi",
"full_name": "Marcus Obi",
"employment_type": "direct"
}
],
"involved_plant": [],
"immediate_action_taken": "...",
"riddor_reportable": true,
"riddor_category": "specified_injury_worker",
"riddor_reported_to_hse": false,
"riddor_deadline": "2026-06-07T14:20:00Z",
"riddor_days_remaining": 10,
"status": "open",
"photo_count": 0,
"custom_fields": {},
"created_at": "2026-05-28T16:55:00Z",
"created_by_name": "James Fletcher"
}
POST /sites/{siteId}/incidents/{id}/mark-riddor-reported¶
// Request
{
"riddor_reported_at": "2026-05-30T10:00:00Z",
"riddor_reference": "OCC-2026-12345"
}
// Response 200
{
"id": "uuid-incident",
"riddor_reported_to_hse": true,
"riddor_reported_at": "2026-05-30T10:00:00Z",
"riddor_reference": "OCC-2026-12345",
"riddor_days_remaining": null
}
POST /sites/{siteId}/incidents/{id}/transition¶
// Request (closing)
{
"status": "closed",
"root_cause": "Worker had not been briefed on the mandatory harness clipping procedure for this scaffold tier. The site induction did not cover this tier specifically.",
"corrective_actions": "All workers on site re-briefed. Scaffold checklist updated to include mandatory lanyard clip points. SSIP audit scheduled."
}
// Response 200 — full incident object
// Response 422 — missing root cause
{
"error": "investigation_incomplete",
"message": "root_cause and corrective_actions are required before closing an incident."
}
Permission Matrix¶
| Action | Platform Admin | Tenant Admin | Project Manager | Site Manager | Viewer |
|---|---|---|---|---|---|
| List incidents | ✓ | ✓ | ✓ assigned | ✓ own site | ✓ assigned |
| Report incident | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ |
| View full detail | ✓ | ✓ | ✓ assigned | ✓ own site | ✓ assigned |
| Edit incident | ✓ | ✓ | ✓ assigned | ✓ open/investigating | ✗ |
| Close incident | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Reopen incident | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Mark RIDDOR reported | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Delete incident | ✓ | ✓ | ✓ assigned | ✗ | ✗ |
| Add photos | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ |
| View photos | ✓ | ✓ | ✓ assigned | ✓ own site | ✓ |
Site Managers can edit incidents in open or under_investigation status but
cannot close them, reopen them, or mark RIDDOR as reported — these are Project
Manager actions.
Notifications Triggered¶
| Event | Trigger point | Notification type |
|---|---|---|
| RIDDOR deadline approaching | Daily scanner (EventBridge 06:00 UTC) | incident.riddor_deadline |
The daily scanner query:
SELECT *
FROM incidents
WHERE riddor_reportable = true
AND riddor_reported_to_hse = false
AND riddor_deadline IS NOT NULL
AND riddor_deadline BETWEEN NOW() AND NOW() + INTERVAL ':advance_days days'
AND deleted_at IS NULL
Default advance_days for RIDDOR: 5 days and 2 days (from notifications spec).
Once riddor_reported_to_hse = true is set, the scanner stops finding this record.
Offline Sync Scope¶
| Data | Syncs to | Notes |
|---|---|---|
| Incidents | Site Manager | Last 90 days for assigned sites |
| IncidentPhotos metadata | Site Manager | Last 90 days |
Offline incident creation:
- Site Manager creates incident offline — queued in local SQLite
- Photos stored in device storage
- On reconnect: incident synced, photos uploaded
- reference (INC-YYYY-NNNN) is assigned server-side on first sync — the
record shows "Pending" until synced
Cross-Module Dependencies¶
Upstream¶
| Module | Dependency |
|---|---|
| Projects & Sites | site_id, project_id |
| Personnel & Attendance | involved_personnel — array of personnel UUIDs |
| Plant & Equipment | involved_plant — array of plant_item UUIDs |
Downstream¶
None. Incidents is a terminal module.
Tests Required¶
Incident Reporting¶
- Report incident — valid → 201, reference auto-generated
INC-{year}-XXXX - Report RIDDOR incident —
riddor_deadlineset tooccurred_at + 10 days - Report RIDDOR without
riddor_category→ 422 - Reference sequence increments per tenant per year
riddor_days_remainingcomputed correctly (positive, zero, negative)
Status Transitions¶
- Transition
open → under_investigation→ 200 - Transition
under_investigation → closedwithroot_causeandcorrective_actions→ 200 - Transition to
closedwithoutroot_cause→ 422 - Transition
closed → under_investigation(reopen) by Project Manager → 200 - Transition
closed → under_investigationby Site Manager → 403
RIDDOR¶
- Mark RIDDOR reported — valid → 200,
riddor_reported_to_hse = true - Mark RIDDOR reported without
riddor_reference→ 422 - After marking reported,
riddor_days_remaining = null - Set
riddor_reportable = trueon existing record →riddor_deadlinecomputed from originaloccurred_at
Photos¶
- Add photo → presigned PUT URL returned
- List photos — ordered by
sort_order - Soft delete photo
Cross-tenant isolation (mandatory)¶
- GET incident from another tenant's site → 404
- POST incident with
site_idfrom another tenant → 404 - POST with
involved_personnelUUIDs from another tenant → 422
Permission boundaries¶
- Site Manager cannot close incident → 403
- Site Manager cannot mark RIDDOR reported → 403
- Site Manager can edit open/investigating incident → 200
- Site Manager cannot edit closed incident → 422
- Viewer can view but not edit → 403
Default Tenant Labels¶
| Key | Default |
|---|---|
module.incidents |
Incidents & Near Misses |
field.incident.incident_type |
Incident Type |
field.incident.severity |
Severity |
field.incident.riddor_reportable |
RIDDOR Reportable |
field.incident.root_cause |
Root Cause |
field.incident.corrective_actions |
Corrective Actions |
status.incident.open |
Open |
status.incident.under_investigation |
Under Investigation |
status.incident.closed |
Closed |
type.incident.incident |
Incident |
type.incident.near_miss |
Near Miss |
type.incident.dangerous_occurrence |
Dangerous Occurrence |
type.incident.environmental |
Environmental |
guidance.riddor |
RIDDOR guidance text — see spec |
Foundation Dependencies¶
| Component | Location | Used for |
|---|---|---|
TenantMiddleware |
core/middleware.py |
Sets request.state.tenant |
get_db |
core/db.py |
Schema-scoped AsyncSession |
get_current_user |
core/security.py |
Auth |
require_permission |
core/security.py |
RBAC |
EntityMixin |
core/models.py |
Universal columns |
PaginatedResponse |
core/pagination.py |
List endpoint wrapper |
ConstruoError hierarchy |
core/exceptions.py |
Typed exceptions |
presigned_put_url |
core/storage.py |
Photo upload |
SQSClient |
core/events.py |
RIDDOR deadline notification scheduling |
audit_entity_change trigger |
DB foundation | Applied to incidents, incident_photos |
factories.py |
tests/factories.py |
create_incident, create_incident_photo |