How SCOPE controls what you see and what you can do
SCOPE has three kinds of relationship, and each one looks different on screen:
| Kind | What decides it | What it looks like |
|---|---|---|
| Structure | A foreign key: this record is part of that one | Nesting, cascades, an "Open ›" link to the parent or child |
| Visibility | Row-level security in Postgres: which rows your login can read | Rows you cannot read are simply absent, or marked "not visible to you" |
| Rules | Functions and triggers: what you may do | A button if you may, a sentence naming who can if you may not |
One principle sits under all three: the screen is a courtesy, the database is the lock. Almost every rule exists twice. The copy in packages/core makes sure the screen never offers a button that will fail. The copy in Postgres makes sure a direct API call cannot get around it.
1. Parent and child: nesting, embedding, cascades
Where a real foreign key exists, the child appears as part of the parent's record. It is not a separate thing you go and look for.
| Relationship | How it shows up |
|---|---|
| Inspection → defect lines | Drawn on the DVIR record, worst first, fetched in the same request as the inspection |
| Corrective action → tasks | Listed inside the action sheet |
| Observation → action → remedial training | All three levels on one screen: the closed loop, end to end |
| Incident → actions, contributing factors, attachments, audit trail | All on the incident record |
| Action → its source (incident, observation, inspection or telematics event) | An evidence card that changes shape with the source type, plus a link to the source record |
Four behaviors follow from a real foreign key:
- Closing a parent closes its children, and the screen says so. Completing an action completes its tasks, and the confirmation reads "1 task under it closed too". A cascade the reader is not told about is two things happening while one is shown.
- Drill-down follows the key. Every row opens its record because you can walk the key: action → inspection → vehicle → site.
- Some children refuse to cascade. The audit trails (the incident changelog and the request thread) are append-only. Deleting the record above them fails unless the guard is lifted deliberately.
- Composite keys stop children getting crossed. A photo filed against a request for input carries both the request and the incident, so it cannot cite a request raised on a different incident.
2. Visibility: the database filters, not the screen
Nothing in the app says "show Kyle only Rutland". Every query runs on the user's own login, and Postgres policies on site and person return only what that login may see. The dashboard's counting functions run with the caller's permissions too, so they total only the caller's rows. No site ID is ever sent from the browser to make this happen. The site filter on the dashboard can narrow what the database allowed, but never widen it.
That produces a consistent style on screen:
- "Not visible to you" is never shown as "none". A driver opening his own observation cannot see the action raised off it, because that action is assigned to his supervisor. The screen says an action was raised and is outside what he can see. It does not say "no action was raised", which would be false.
- A record you cannot see and a record that does not exist get the same answer: "No inspection to show". Telling the two apart would reveal that the hidden record exists.
- Names are written onto the record at the moment of signing. A driver cannot read his supervisor's person row, so the supervisor's name is stored on the document or release when it is signed. Otherwise the screen could only say "checked by a supervisor".
- Three records are open to every level (the DVIR, the observation and the qualification file), with the database doing the gating. The incident record is gated in the app instead; see the gaps below.
3. Rules that are not a table relationship
Much of what controls an action is not parent and child at all.
| Control | How it is decided | How it looks |
|---|---|---|
| Who may close an action | Your person compared with the action's assignee. Rank never decides it | The button for the assignee only. Anyone else reads "Daniel Sgalia has to close this" |
| Who signs it off | Stored when the action is raised: whoever raised it, else the assignee's supervisor, else the site manager | "then Paul Marker signs off" is printed before the button is pressed. Mark Done and Sign Off are two buttons for two people |
| Who may own an investigation | A match on job title (supervisor, manager, director, VP), enforced by a database trigger | The owner picker lists seven names at Rutland, and the database refuses anyone else |
| Which truck a driver may inspect | The certifications each vehicle type demands, judged as of the inspection date | Gated trucks are listed, not hidden, marked "needs CDL Class A". Tapping one says why, and no signature control is offered |
| Who sees cost | A per-person flag plus access level | The whole column is removed, not just its values. A dash in a money column would read as "nothing was spent" |
| Which screens you get | One access rule in packages/core, mirrored by database policies |
The launcher tile is missing, and the database refuses the write anyway |
| Shop work vs operations | Job title plus the type of action | The maintenance supervisor gets a shop queue and a vehicles-only watchlist: a different arrangement of the same screen, not a new access level |
| Notifications | A query, not a stored flag | "Waiting on you" is "requested and assigned to me", so the badge cannot disagree with the list |
Writes go through database functions, not direct edits. Filing a DVIR, closing an action and signing off are each one function, and the important ones take "who signed" from the login making the call, never from a value the device sends. A signature a device can name is a signature a device can forge.
4. Relationships the data deliberately does not have
Some things people expect to be parent and child are not, and the screen says so rather than inventing one:
- Trucks do not belong to drivers. Nothing assigns a truck to a person, so vehicles are grouped yard ▸ type ▸ truck, and the yard and type folders carry no rating: nothing reports to a truck.
- An expiring license in your crew has no owner. "5 people with a license expiring" is a reading of the data, so it sits under the watchlist with no Done button. The renewal, once assigned, is a real training record and does get one.
- Reporting lines do not cascade anything. They drive the watchlist, where each level sees the level below it and can drill down.
Known gaps: where only the app is holding the line
- The incident record returns its full row to a driver, root cause included. No screen asks for those fields, but the API would answer. The fix is a driver-facing view that leaves out the investigation columns.
- Settled cost can be read directly by a GM's login. Hiding it is currently the app's job.
- 27 tables carry wider default permissions than their migrations state, a project-level Supabase default. Nothing is reachable, because no write policy exists on them, and the tidy-up is on the to-do list.