π’ Mostly live, in two places you would not expect. Safety records (toolbox talks + incident reports) are real and shipping β but they live on a Job's Safety tab, not in a safety hub. Insurance/COI tracking with automated renewal chasing is live but rides inside the nightly digest job. Worker certifications reuse the credential system. The one thing the name "compliance" makes people expect β a Job Hazard Analysis (JHA) workflow β does not exist yet.
Audience: owner / PM / field. The Safety tab and field capture are for anyone on a Job. The compliance dashboards and COI chase are office-side (owner/PM). See also Team & HR CC and The Field App.
Badge legend: π’ live & load-bearing Β· π‘ built but partial / caveated Β· βͺ scaffolded or planned Β· β not built.
Safety in GoBuild is three separate systems that share the word but not a home screen:
| System | What it is | Where it lives | State |
|---|---|---|---|
| Toolbox talks + incidents | Jobsite safety records (meetings, injuries, near-misses) | A Job's Safety tab + the Field app | π’ |
| COI / insurance tracking | Sub/vendor certificates of insurance, expiry, auto-chase | Sub records + Team & HR compliance list | π’ |
| Certifications / tickets | A worker's OSHA/first-aid/trade credentials | The credential system (SubCredential) |
π‘ |
| JHA / hazard analysis | Pre-task hazard assessment | β | β planned |
House terms: a Job is a project; a Client is the homeowner/customer. Safety records attach to a Job, never to a Client.
This is the single most important navigation fact, and it trips people up.
There is no "Safety" rail button anywhere. You cannot open a safety module. Toolbox talks and incidents are only reachable from inside a specific Job:
templates/job_detail.html:30, the ('safety','Safety','βοΈ') tab)./portal/jobs/{job_id}?view=classic&tab=safety (app/routers/safety.py:38).The Team & HR hub (app/services/team_hub.py) has no rail button and no pane for incidents or toolbox talks. The only pointer to safety from Team & HR is a read-only report card β "Safety & TRIR" linking to /portal/reports/r/safety (templates/team_hub.html:437). That opens a dashboard, not the place you file anything.
Consequence: to log a toolbox talk or file an incident you must first pick the Job it happened on. There is no org-level "file an incident" entry point in the office Hub. (The Field app does offer an org-wide entry point β see below.)
The tab (templates/job_detail.html:1236) has two sub-panes, wired to app/routers/safety.py.
A logged safety meeting on a Job: topic, date, who led it, an attendance roster, and notes. Model: ToolboxTalk (app/models/safety.py:28).
app/services/safety.py:15-40, TOOLBOX_TOPICS). Picking a topic drops a ready-made blurb into the notes field; it is fully editable.[{"name", "sub_id"}] JSON (app/routers/safety.py:63-83).POST /portal/jobs/{job_id}/toolbox (create), POST /portal/toolbox/{talk_id}/delete.A structured injury / near-miss record. Model: IncidentReport (app/models/safety.py:42).
near_miss β first_aid β medical β lost_time β fatality (app/models/safety.py:24). The bottom three are reportable (REPORTABLE = {medical, lost_time, fatality}), and a reportable boolean is stamped at file time (app/routers/safety.py:131, app/services/safety.py:51).authority(country, region) returns OSHA in the US, and the correct provincial board + Ministry of Labour in Canada β WSIB (ON), WorkSafeBC, WCB-Alberta, CNESST (QC), etc. (app/services/safety.py:55-64). The Job's country/region is snapshotted onto the incident (app/models/safety.py:60-61) so the printed report stays correct even if the org later moves.open β reported β closed, set manually (app/routers/safety.py:147). "Reported" is a human-set flag β GoBuild does not file anything externally.GET /portal/incidents/{id}/print renders templates/incident_print.html (Print β Save as PDF), naming the right authority for the snapshotted region (app/routers/safety.py:170-186).POST /portal/jobs/{job_id}/incidents (create), .../status, .../delete.π‘ Caveat β no photos on the office form. The
IncidentReportmodel has aphoto_idslist, but the office (portal) incident form does not upload photos βcreate_incidentnever setsphoto_ids(app/routers/safety.py:105-136). Only the Field app captures incident photos (below).
The crew's phone surface has its own safety screen at /field/safety (app/routers/field.py:2195). See The Field App.
app/routers/field.py:2201-2215). This is the closest thing to a "safety inbox" that exists.POST /field/safety/incident accepts uploads and stores photo_ids (app/routers/field.py:2240-2270).app/routers/field.py:2270). This is the only push notification in the safety flow.Certificates of insurance are tracked on the credential record, not a bespoke COI table. Module: app/services/coi.py.
Insurance lives on SubCredential (app/models/permits.py:107) where kind is one of INSURANCE_KINDS = (liability_insurance, wcb_wsib, bond) (app/services/coi.py:23). A credential attaches to exactly one of a Sub, a Vendor, or a staff User. Each carries an expiry_date, coverage amount, issuer, policy number, and an optional uploaded document.
Status resolves to none / ok / expiring / expired β "expiring" means within 30 days (app/services/coi.py:48-57). This state powers the compliance hub badges and a warn-on-pay / warn-on-assign gate. Note the whole system is advisory only β it warns and allows override, it never blocks (app/services/coi.py:9).
Upload a COI or WCB/WSIB clearance PDF and extract_coi() reads it with Gemini, returning insured name, issuer, policy number, coverage limit, expiry date, and kind to prefill the credential form (app/services/coi.py:151-179).
run_chases() (app/services/coi.py:91) emails and texts the credential holder when insurance is expiring or lapsed:
_CHASE_WITHIN_DAYS).last_chased_at β no holder is chased more than once every 7 days (_CHASE_EVERY_DAYS).coi_reminder email template and/or an SMS, whichever contact channels exist (app/services/coi.py:124-137). The message asks for a renewed certificate "so we can keep you on the schedule and clear payments."π‘ Reliability caveat β it rides the nightly digest.
run_chasesis not an independent job. It is called once per org from inside the daily digest loop (app/services/scheduler.py:47-50), which itself only runs whendigest_hour_utcis set and the digest hasn't already gone out today (app/services/scheduler.py:32-44). If an org has the digest disabled, COI chasing never runs. A chase failure is caught and logged so it can't block the digest, but that also means silent failures are possible.
A credential can belong to a staff User (SubCredential.user_id) for things like OSHA cards, first-aid, trade tickets, and driver's licences (app/models/permits.py:117-120). The org's own org-level compliance (business license, trade cert, liability insurance, WCB/WSIB, bond) lives on the separate License model (app/models/permits.py:91).
The Team & HR compliance list (app/services/team_hub.py:97-121) folds every SubCredential β crew, vendor, and staff β into one table with a holder label and an ok / expiring / expired state, sorted by soonest expiry. See Team & HR CC.
π‘ Caveat: worker certifications share the insurance plumbing but there is no dedicated "certifications" screen β they surface only in the mixed compliance list and the expiry report. There is no cert-specific renewal workflow distinct from the COI chase.
Two read-only reports live under Reports (both linked as cards from Team & HR):
Insurance & WSIB Expiry (compliance category) β reporting.compliance_expiry (app/services/reporting.py:1853). Merges the org's own Licenses with every SubCredential, sorted by soonest expiry, with headline counts of expired / due-in-30 / due-in-90 / tracked (report_specs.py:1597).
π‘ Data caveat: this report labels holders from the Sub name map only, so a vendor- or staff-held credential shows the holder as "Sub" and its drill-down link points at a sub-vetting page (
app/services/reporting.py:1867-1874). Accurate for sub COIs; mislabeled for vendor/staff creds.
Safety & TRIR (safety category) β reporting.safety_trir (app/services/reporting.py:2100). An incident log for the year plus the industry TRIR = recordables Γ 200,000 Γ· hours worked, where recordables are derived from the severity ladder and hours come from time-clock shifts (report_specs.py:1646). Each row deep-links to the printable incident. TRIR is blank until there are clocked hours.
| Capability | State | Note |
|---|---|---|
| Toolbox talks (library, roster) | π’ | Per-Job tab + Field app |
| Incident reports (severity, authority, print) | π’ | Region-aware; manual "reported" status |
| Field incident capture with photos | π’ | Only place photos attach |
| COI/insurance tracking + AI prefill | π’ | On SubCredential, advisory gate |
| COI auto-chase (email + SMS) | π‘ | Rides the nightly digest; off if digest off |
| Worker certifications/tickets | π‘ | Reuses credential plumbing; no dedicated screen |
| TRIR + expiry dashboards | π’ | Expiry report mislabels vendor/staff holders |
| Org-level safety hub / rail button | β | Safety is per-Job only |
| JHA / job hazard analysis | β | No model, route, or template exists |
| Automated external filing (WSIB/OSHA) | β | "Reported" is a manual flag; print-to-PDF only |