Safety data privacy: what HQ sees and what it never sees
The Safety page inside HQ's Compliance band names memberships that are short on safety certifications, lists memberships carrying open incidents, and shows a network median for how long it takes to…
On this page
- The line this article holds
- Three different things are being protected here
- How the schema-level wall works
- The one legitimate bridge: the nightly aggregate-refresh cron
- Why the franchisee's name gets encrypted at all
- What a "group shared DEK" actually is
- Where the name gets anonymized on top of that
- Network Follow-Through: how the timing data gets aggregated
- What Safety never shows, on either side of the boundary
- Empty states you'll see
- Why this design, not a lighter one
- Related articles
- Data sources
The line this article holds
The Safety page inside HQ's Compliance band names memberships that are short on safety certifications, lists memberships carrying open incidents, and shows a network median for how long it takes to close a corrective action. It is reasonable to read that and wonder whether HQ is somehow reading a franchisee's incident report, their OSHA 300 log, or the timeline of a specific claim. It is not, and the reason is structural rather than a policy promise. Every number on Safety comes from two network-layer rollup tables and one static reference catalog, read by code (the product and the product) that has no path to the the private layer schema where a franchisee's actual job files, incident narratives, and correspondence live.
If you want the full tour of the page itself, the hero, Compliance Frameworks, Network Follow-Through, Safety Cert Coverage Gaps, Safety Incidents, and the two OSHA reference rows, read Safety: network safety posture at a glance first. This article is the trust layer underneath it, the same relationship What HQ sees: the network privacy boundary has to the rest of HQ's product. It answers three separate questions that are easy to conflate on a page like this one: what happens to the content of an incident, what happens to a franchisee's own name, and what happens to the timing data behind Network Follow-Through.
Three different things are being protected here
1. The content of an incident: never read, never stored on the HQ side. Safety Incidents shows an open-incident count and an OSHA-recordable count per membership. It does not, and cannot, show what happened, who was involved, what corrective action was taken, or any narrative, photo, or document tied to the incident. That detail lives entirely in the franchisee's own private-layer records, on their own Verinode IQ account. the network data, the table Safety's incident counts are read from, stores open_incidents_count and osha_recordable_36mo as integers, one row per operator. There is no column in that table for an incident's description, date, injured party, or resolution, because the nightly rollup that populates it never writes one.
2. The franchisee's own name: shown, but encrypted at rest. Unlike the content of an incident, a franchisee's business name is meant to be visible on Safety, it is the label on every cert-gap and incident tile ("Riverside Restoration, Missing," for example). That is not a privacy leak; a franchise network or multi-location enterprise looking at its own roster is the same visibility a company has into its own branch offices. But because that name is still an identifying piece of business data sitting in a network-layer rollup table, Verinode encrypts it at rest as an added layer of defense, on top of, not instead of, the schema-level wall described below.
3. Timeline and case data behind Network Follow-Through: aggregated into a median, never disclosed as individual dates. The Network Follow-Through tile reads a network median number of days from a safety incident occurring to its corrective action closing. That median is mined from lifecycle dates each office already logs in its own IQ account, pooled into a single network number by a separate process-mining aggregator. HQ never sees which incident took how long, which office logged which date, or any case identifier behind the median, only the resulting number.
Note
If you are looking for the actual incident report, the injured party's details, the corrective-action narrative, or a specific safety case's status, that record lives on the franchisee's own Verinode IQ account and belongs to them. Safety's job is to show where safety risk is concentrated across the network, never to be a substitute incident-management system for a business you don't employ.
How the schema-level wall works
hqAdmin (exported from the product) is not a Supabase client with a row-level-security policy that could theoretically be misconfigured. It is a direct Postgres connection authenticated as a database role, hq_app, that has explicit grants on the network-layer rollup tables Safety and the rest of HQ read, and a hard, schema-level REVOKE on the private layer. A query against the private layer from this connection returns "permission denied for schema pii" at the database layer itself, the same error any misconfigured client would get, because the database refuses the connection regardless of what the application code asks for.
That is layer one of four:
- The database role.
hq_app's schema-level revoke on the private layer, enforced by Postgres, not by application logic. - The client binding. the product and the product import
hqAdminand nothing else, and read exclusively from the network data, and the network data. - A build-time lint. An automated check (the product) fails the build if any HQ-side file imports a private-layer client directly.
- An audit trail. Cross-schema access, the one legitimate bridge described next, is logged.
The one legitimate bridge: the nightly aggregate-refresh cron
None of the network rollup tables Safety reads populate themselves, and the Safety page's own code never writes to any of them. A separate process, the nightly refresh cron, is the sanctioned, documented crossing point between a franchisee's private safety records and HQ's network view. Once a night, for each franchisee on a network's roster, it reads that franchisee's own private certification, incident, and process lifecycle-date records, computes the counts, medians, and completeness percentages described in this article and in the page overview, and upserts one row per table into the network layer. That cron runs with legitimate cross-schema access because producing network-layer summaries from private-layer truth is its entire job; it is the one function explicitly excluded from the build-time lint, not a loophole in it. If a single franchisee's rollup fails to compute or write on a given night, the cron logs the failure and moves to the next franchisee rather than blocking the rest of the network's refresh, and that franchisee's row simply keeps whatever it last had.
Why the franchisee's name gets encrypted at all
Everything else in the network data and the network data, cert counts, cert status, open-incident counts, OSHA-recordable counts, is a number or a short status code. Numbers alone do not identify a business if a table were ever exposed outside its normal read path (a database export, a misdirected backup, a compromised credential that only reaches as far as the network layer). The franchisee's name is the one column in both tables that does. Vault Slice B Half 2 closes that gap: the aggregator cron writes the franchisee's name in two forms on every row it upserts, a plaintext franchisee_name column, and a franchisee_name_ciphertext column holding the same value encrypted under the network's own shared group DEK (data encryption key). Safety's read code decrypts the ciphertext column back to a name before it ever reaches a tile.
Note
This is defense-in-depth for a business-level identifier that Safety is meant to display, not a zero-knowledge scheme that hides franchisee names from HQ. The whole point of the page is that you see which franchisee a cert-gap or incident tile is about. What the encryption protects against is a table falling into the wrong hands through some path other than the product itself, not against a signed-in HQ admin, or against Verinode's own server infrastructure, seeing the name.
What a "group shared DEK" actually is
Every network has its own DEK, a randomly generated encryption key scoped to that one group. It is stored, wrapped, in the network data, keyed by (group_id, dek_label, wrap_method, principal_id). There are two variants in the platform's Vault design, and Safety uses the weaker of the two on purpose:
admin_only, wrapped once per HQ admin under that admin's own personal Vault Key. There is no KMS-held copy of this variant anywhere; Verinode's infrastructure structurally cannot decrypt it without an authorized admin's active, unlocked session.shared, the variant Safety's franchisee names use, wrapped once under a KMS master key (VAULT_ROOT_KEYin the deployment environment) instead of under any one admin's personal key. That is a necessary tradeoff, not an oversight: the aggregator cron writes these rows every night with no admin logged in to unwrap anadmin_onlykey for it, so the column it writes to has to use a key the server itself can unwrap on demand. The honest way to describe the guarantee: it protects the name from anyone who can read the the network layer schema's raw bytes but not the deployment's KMS key, but Verinode's own infrastructure, holding that key, can decrypt it, and so can any signed-in HQ admin's session, which is exactly what lets the page render the name at all.
Reading a name back out, concretely, in getSafetyRollupForGroup: the query first pulls every row's franchisee_name and franchisee_name_ciphertext for the group, from both the network rollup and the network rollup independently (each unwraps the group's shared DEK once via KMS the moment it finds at least one ciphertext value present). It then decrypts each row's ciphertext with that DEK. If a row has no ciphertext yet, or decryption throws for any reason, the code logs a warning ("franchisee_name decrypt failed, falling back to plaintext") and falls back to that row's plaintext franchisee_name column rather than showing a broken tile. That fallback is a deliberate resilience choice, the same "dual-write, fail open toward availability" posture the platform uses elsewhere for encrypted columns: a name always renders, and a rollup that predates the ciphertext column, or a transient KMS hiccup, degrades to plaintext for that one field instead of breaking the page.
Where the name gets anonymized on top of that
Encryption at rest and name anonymization are two separate, stacked controls, and it is worth keeping them apart. Whether the decrypted name is shown as a real business name or as a stable anonymized label like "Franchisee #A1B2" depends on your network's entity model, the same setting that governs naming everywhere else in HQ:
- Same entity (one legal business operating multiple locations): the decrypted name passes through unchanged. There is nothing to anonymize, since it is one business looking at its own branches.
- Independent operators (a franchise network of separately owned locations, or an association, the default posture):
anonymizeFranchiseeNamereplaces the decrypted name with a short, stable label derived from the operator's internal ID, before it ever reaches a tile. The same franchisee always gets the same label, so you can track a pattern over time without their identity being disclosed on the interface.
This applies uniformly across every named surface on the page: the Safety Cert Coverage Gaps tiles, the Safety Incidents tiles, and the per-office breakdown behind the Network Follow-Through drill-in.
Network Follow-Through: how the timing data gets aggregated
The Network Follow-Through tile is built differently from the cert and incident rows, and it is worth walking through separately because it draws on a different pipeline, the same process-mining aggregator behind Network Flow. Each office's own IQ account logs lifecycle dates for its safety process (an incident occurring, its corrective action closing). The nightly aggregator reads those dates from the private layer, the same cross-schema bridge described above, and writes two kinds of rows into the network data:
- Network-level rows (
operator_idnull): a pooled median, 25th percentile, and 75th percentile across every in-order case from every office, plus a count of how many cases the figure is built on. This is the number the Network Follow-Through tile shows. - Per-office rows: one office's own median for the same stage, only written once that office has at least five in-order cases behind it. Below that floor, the office's row carries no median at all by design, "it stays off the comparison rather than rendering a fake number," rather than a figure built on too few cases to trust.
HQ never sees the individual lifecycle dates or which incident they belong to, only the resulting medians and the case counts backing them. The per-office breakdown behind the tile is subject to the same name anonymization described above; each office's name is decrypted and, in independent-operator networks, anonymized before rendering.
Where an anonymous cross-industry cohort exists for this same stage transition, the tile can also show a comparison against that outside benchmark ("On Pace With Industry," or "Xd vs Industry"). That industry figure is a single median contributed by operators entirely outside your network, published only once the contributing cohort clears its own anonymity floor, described qualitatively (never as an exact contributor count) in What HQ sees: the network privacy boundary. Below that floor, the comparison line simply does not render, no tick, no delta, rather than showing a figure built on too few outside contributors.
What Safety never shows, on either side of the boundary
- No incident report content. No narrative, injured party, photo, or document behind an open-incident or OSHA-recordable count.
- No job, case, or claim ID. Nothing on this page links a cert or incident count back to a specific job file or claim.
- No individual lifecycle date. The Network Follow-Through median and the per-office medians behind it are always aggregates, never a list of dates or cases.
- A franchisee's real identity, in an independent-operators network below the per-office reporting floor for that stage. An office's stage median simply is not written to the network layer until it clears five in-order cases, so there is no thin, potentially re-identifying single-case figure to protect in the first place.
- How many outside contributors sit behind a published industry figure. The Network Follow-Through comparison line states the direction and the day-count difference, never the size of the anonymous cohort behind it.
Empty states you'll see
- Hero, before any franchisee has registered safety certification data: "Safety certification data will appear as franchisees register their OSHA / first-aid / confined-space certifications."
- Compliance Frameworks, before any framework rollup exists: "Framework completeness (HazCom, OSHA recordkeeping) appears across the network as franchisees build and forward their programs."
- Network Follow-Through, before the pooled median clears its reporting floor: the row is simply empty, no placeholder tile appears, rather than showing a figure built on too little data.
- Safety Cert Coverage Gaps, when every franchisee is at baseline: "Every franchisee has at least 3 active safety certifications. Network is at baseline coverage."
- Safety Incidents, when nothing is open: "No open incidents or OSHA-recordable events in the last 36 months across the network."
None of these states are the encryption or aggregation layer failing. A franchisee row with no ciphertext yet (a rollup written before the ciphertext column existed, or one write cycle behind) still renders its name from the plaintext column; you would not see a broken tile or a missing name because of it.
Why this design, not a lighter one
A franchisee agreeing to run Verinode inside their business is trusting that the specifics of a safety incident, and the timeline behind it, stay theirs to manage, even while the fact that risk exists is worth a network leader knowing about. Safety exists so a franchisor, multi-location enterprise, or PE-backed leadership team can see where certification gaps and open incident risk are concentrated across a whole network, without that visibility ever becoming a window into what actually happened on a specific job or how long one specific case took to close. The franchisee-level name is the one identifying detail on the page, and it is both schema-isolated from the private records that produced it and encrypted at rest as a second, independent layer, so that a table dump or a leaked credential that only reaches the network layer still does not hand over a readable franchisee roster. That is the same discipline the rest of HQ runs on: franchisees own their data, and what flows up is a summary, never the record.
Related articles
- Safety: network safety posture at a glance
- Network Flow: how work moves through the network
- What HQ sees: the network privacy boundary
- Workforce privacy: aggregates only, encrypted franchisee names
- Network Health: your HQ command home
- HQ overview
- HQ Compliance
- HQ Standards
- HQ Programs
- Report library
- Broadcasting to your network
- Discovery Day
- Item 19
Data sources
Data sources
- 1.Per-franchisee safety-certification and incident rollup (the network data). Verinode network rollup, computed nightly from each franchisee's own certification and incident records.
- 2.Compliance-framework completeness rollup (the network data). Verinode network rollup, computed from each franchisee's own framework records.
- 3.Network process-mining aggregates (the network data). Verinode network intelligence layer, computed nightly from each office's own lifecycle dates.
- 4.HQ Vault Slice B specification (group-shared DEK design). Verinode.
- 5.Verinode Data Use Policy. Verinode.