Skip to content

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
CREATE INDEX idx_incident_photo_incident
    ON incident_photos(incident_id) WHERE deleted_at IS NULL;

Business Rules

  1. reference is 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 using SELECT MAX(sequence_number) + 1 FROM incidents WHERE EXTRACT(YEAR FROM created_at) = {year}.

  2. When riddor_reportable is set to true:

  3. riddor_category becomes required
  4. riddor_deadline is computed as occurred_at + INTERVAL '10 days'
  5. The incident.riddor_deadline notification is scheduled via the SQS queue for the scanner to pick up at 5 days and 2 days before deadline

  6. riddor_reportable can be set at creation time or updated later (Site Managers may not initially recognise RIDDOR reportability). When updated to true on an existing record, riddor_deadline is computed from the original occurred_at.

  7. Setting riddor_reported_to_hse = true requires riddor_reported_at and riddor_reference. This terminates the RIDDOR deadline notification.

  8. involved_personnel is stored as a UUID array (PostgreSQL UUID[]). 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.

  9. involved_plant is similarly stored as a UUID array.

  10. Closing an incident (status → closed) requires root_cause and corrective_actions to be non-empty. Enforced in service layer.

  11. Closed incidents can be reopened (set back to under_investigation) by Project Manager and above.

  12. Incident records are never hard deleted. The soft delete on the deleted_at column means records remain accessible in audit exports even after deletion.

  13. 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_deadline set to occurred_at + 10 days
  • Report RIDDOR without riddor_category → 422
  • Reference sequence increments per tenant per year
  • riddor_days_remaining computed correctly (positive, zero, negative)

Status Transitions

  • Transition open → under_investigation → 200
  • Transition under_investigation → closed with root_cause and corrective_actions → 200
  • Transition to closed without root_cause → 422
  • Transition closed → under_investigation (reopen) by Project Manager → 200
  • Transition closed → under_investigation by 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 = true on existing record → riddor_deadline computed from original occurred_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_id from another tenant → 404
  • POST with involved_personnel UUIDs 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