Data classification, retention and disposal (draft)
Lucent Axis, SCOPE. Draft 28 Oct 2026. Owner: Dan. Approved by: [Kyle, on return]. Review:
yearly, and whenever a table holding personal data is added. SOC 2 C1.1, C1.2, CC6.5; items
DB-5, DB-6, TS-6. Read from the schema (docs/dba/schema.sql) on 28 Oct.
Classes
| Class | Meaning | Handling |
|---|---|---|
| Confidential | Would harm a person or the client if it got out: health, identity documents, pay, investigation findings, location history | Only the roles that need it (RLS, and column limits where a role sees the row but not the field, DB-2). Never in logs, error reports, screenshots, chat or email. Never copied out of the client's database except by the client's own export |
| Internal | The client's operational records: names, sites, routes, assets, inspections, actions, training rows | Signed-in users within their scope (RLS). Not published |
| Public | Meant to be seen: the app's code is not public, but its static pages, brand marks and the course catalog titles are harmless | No restriction |
The sample data set is synthetic and holds no real person's data. These classes apply to it anyway, so that the controls are already working when a client's real data arrives.
Confidential fields
| Table | Fields | Why |
|---|---|---|
incident |
injury_classification, injury_illness_type, body_part, treatment_sought, lost_days, restricted_days, is_lti, description when it is an injury |
Health information about a named person (OSHA 300/301) |
incident |
root_cause, root_cause_category, corrective_action_summary |
Investigation findings; a driver must not read them (DB-2) |
incident |
estimated_cost_usd, actual_cost_usd |
Reserves and claims; already limited to can_see_cost |
incident_attachment and its bucket incident-attachments |
the files | Photos of injuries, scenes and people |
qualification |
identifier, issuing_authority, dates |
CDL and medical card numbers identify a person |
person_document and its bucket person-documents |
the files and claimed_identifier |
Copies of licences, DOT medical cards, driving records: identity and health |
training_session_attendee, external_training and bucket training-evidence |
outcomes and certificates | Performance records about a named person |
person |
pay_rate_usd, pay_group, phone, termination_date |
Pay and contact details |
telematics_event |
latitude, longitude, location, speed_mph |
Where a named driver was, and how they drove |
ask_log |
question_text |
What a VP typed; may name a person |
Everything else in public is Internal. A new column holding health, identity, pay, location or
investigation data is Confidential and is added to this table in the same change.
Column limits: the plan for DB-2
Row-level security decides which ROWS a login sees, not which columns, and every app login is
the same database role (authenticated). So a column grant cannot hide root_cause from a
driver while showing it to a supervisor: both are authenticated. Today a driver's token can
read the investigation columns of any incident they were involved in or reported, through the
API, although no screen asks for them.
The fix (DB-2). The code on main also serves Paul's demo, whose frozen database depends on today's reads, so it waits for Paul's switch, for the change to be made backward-compatible, or for Novvia's clone, which has no frozen demo behind it:
- A table
incident_findingkeyed onincident_id, holdingroot_cause,root_cause_categoryandcorrective_action_summary, moved out ofincidentby the same migration. RLS on it: readable by supervisors and above within their scope (not app.is_driver()and the incident visible to them); written only by the investigation's save function, as today. - The readers switch to a join: the incident record (
apps/web/app/incident/[number]/page.tsxand its export panel), the investigation form, the action detail (its root-cause line), the incidents table's reason code, and/cofi(category only). - A check in the verify scripts, as a driver's login: selecting any of the three columns from
incidentfails, andincident_findingreturns nothing. - The same review for the other Confidential fields a driver's row exposes: injury details are the driver's own and stay readable to them; cost columns are already limited.
Retention
| Data | Kept for | Why |
|---|---|---|
Injury and illness records (incident rows that are recordable, their attachments) |
5 years after the end of the calendar year they cover | OSHA 29 CFR 1904.33 |
A driver's qualification file (person_document, qualification for drivers) |
While employed, then 3 years after leaving | FMCSA 49 CFR 391.51 |
DVIRs (inspection, inspection_defect) |
At least 3 months from the inspection date | FMCSA 49 CFR 396.11 |
| Everything else the client puts in SCOPE | For the life of the contract | The client's records, kept on their behalf |
Audit trails (incident_event, training_event, investigation_request_event, audit columns) |
As long as the record they describe | They are its history |
| Our own logs (Vercel, Supabase) | 12 months (target, LM-1; today 1 day Vercel, 7 days Supabase) | LM-1 |
| Backups | Daily, kept 7 days (Pro) | AV-2 |
The client may keep its records longer than these minimums; SCOPE does not delete a record because its minimum has passed. Retention is the client's decision, and the contract says so.
Legal hold
When the client, counsel or a regulator asks for records to be preserved, deletion of the named
records stops until the hold is lifted in writing. The hold and its lifting are recorded in the
evidence log, and in the database (20261029130000_legal_hold.sql, 29 Oct):
- A VP places the hold with
place_legal_hold(scope, key, requested_by, reason): scope is an incident (IN-...), a person (PER-...), a site (SITE-...) or everything. While it is live the database refuses to delete the incidents and people it covers, from anybody, the owner included; their actions, tasks, attachments and DQ files go only by cascade from those two, so they are held with them. Edits continue, and each is on the change history (app.row_change). - Another VP lifts it with
lift_legal_hold(id, reason)once it is released in writing. A hold is never edited or deleted;report_legal_holds()lists every one, live and lifted. report_retention()shows, per class with a legal minimum (the table above), how many records are past it and how many of those are held. It deletes nothing: disposal before contract end happens only on the client's written request.
A soft-delete column (deleted_at, DB-6) is still the code side when deletion is built.
Disposal
- At contract end, on the client's written request: their data is exported to them, then their database project and its storage buckets are deleted, and the backups expire on the provider's schedule. The deletion date is confirmed to the client in writing. Each client has its own project (ORG-11), so this is a project deletion, not row-by-row.
- A person's laptop at leaving: every SCOPE file, export and
.env.localdeleted, and the person confirms it in the leaving checklist (onboarding-offboarding.md). - Exports and screenshots made for support or a demo are deleted when the task is done.
- Paper: none is produced.