SCOPE: everything in progressdocs/compliance/data-classification-and-retention.md · build b00458d32828

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:

  1. A table incident_finding keyed on incident_id, holding root_cause, root_cause_category and corrective_action_summary, moved out of incident by 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.
  2. The readers switch to a join: the incident record (apps/web/app/incident/[number]/page.tsx and its export panel), the investigation form, the action detail (its root-cause line), the incidents table's reason code, and /cofi (category only).
  3. A check in the verify scripts, as a driver's login: selecting any of the three columns from incident fails, and incident_finding returns nothing.
  4. 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.

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.local deleted, 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.
Back to top