Skip to main content

ADR 011: GRC risks

Status: Accepted

Context​

HIPAA asks a practice to find the risks to its patient data (ePHI) and to manage them. Two control templates depend on that, and today only uploaded evidence can move them:

Template keyHIPAAWhat the register proves
risk-analysis164.308(a)(1)(ii)(A)every risk is scored, every critical ePHI system is covered by a risk, and the register was looked at in the last year
risk-management-plan164.308(a)(1)(ii)(B)every risk that matters has an owner, a treatment and a review date that hasn't passed

The risk epic (#76) adds a risk register: what could go wrong, how likely and how bad it is before and after controls, who owns it, what the practice is doing about it, and when it is next looked at. A library of common healthcare risks gets a practice started. Every issue in the epic (library, API, signals, review flags, page) rests on the same decisions:

  • How is a risk scored, and what are the bands?
  • What is "before controls" and "after controls", and who sets each?
  • What can a practice do about a risk, and what does accepting one take?
  • How does a risk go out of date, and what is kept when it changes?
  • What happens when a control a risk depends on starts failing?
  • What does a team with no risks see?
  • Who can read and change the register?

Decision​

1. The model​

RiskTemplate (global library, seeded) ── RiskTemplateControlTemplate ──► ControlTemplate
│ add from library (#80) assetTypes: AssetType[]
▼
Risk (teamId, title, status OPEN | CLOSED) ← team-owned
├─ inherent likelihood 1–5 × impact 1–5 (before controls)
├─ residual likelihood 1–5 × impact 1–5 (after controls)
├─ treatment MITIGATE | ACCEPT | TRANSFER | AVOID, treatmentPlan
│ └─ ACCEPT → sign-off: acceptedBy, acceptedAt, note, expiry
├─ owner → User (must be on the team), reviewDueAt, lastReviewedAt
├─ review flag (reviewFlaggedAt, reviewFlagReason)
├─ RiskControl ──► Control (same team)
├─ RiskAsset ──► Asset (same team)
└─ RiskReview (who, when, note, snapshot of scores and treatment)

Risk and RiskReview carry teamId. Every query filters by the team taken from the principal, and a miss across teams is a 404 (ADR 004). RiskControl and RiskAsset have no teamId of their own: both ends already have one, and they are only ever reached through a risk. The API checks that every control and asset id in a write belongs to the risk's team (§10). Deleting a team deletes its risks, their links and their reviews.

RiskTemplate is global, like ControlTemplate (ADR 007): every team reads it, only seed scripts write it.

2. Scoring​

Likelihood and impact are each a whole number from 1 to 5. The score is likelihood × impact, so 1 to 25. The band comes from the score:

BandScore
Low1–4
Medium5–9
High10–15
Critical16–25

The maths lives in one pure helper in @riskflow/types (packages/types/src/risk-score.ts): riskScore, riskBand, judgedRisk, isResidualAboveInherent and defaultRiskReviewDueAt. The API and the page both import it, so they never disagree about a band. Neither the score nor the band is stored: both come from the four score columns every time, so changing a band boundary later is a code change, not a data migration.

Why 5×5: it is what small-practice risk tools and the HHS SRA tool use, people can tell five levels apart, and it gives a heatmap that fits on a phone. Money-based scoring (annual loss expectancy) and custom scales are out of scope.

3. Inherent and residual​

  • Inherent is the risk before controls: "if we did nothing, how likely and how bad?"
  • Residual is the risk after controls: "with what we have in place?"

Both are set by a person. Residual is not worked out from control status: a control can be green and still not reduce a given risk much, and the link between a control and a risk says "helps", not "by how much". When a linked control starts failing, the risk is flagged for a person to re-score instead (§9).

Residual is optional. Until it is set, the risk is judged on its inherent score (judgedRisk returns which one it used). Rules, filters and the "band" column on the page all use the judged score.

Residual can't be higher than inherent. Controls only lower a risk. The check compares scores, not each side, so likelihood 2 × impact 5 = 10 is allowed under an inherent 3 × 4 = 12. Residual without inherent is also rejected ("after controls" needs a "before"). The API answers 400 RISK_RESIDUAL_ABOVE_INHERENT; risks_residual_check is the safety net. Lowering inherent below an existing residual is rejected the same way.

4. Not scored yet​

All four score columns are nullable, so a quick add ("we should think about ransomware") or an add from the library with the scores cleared works. Likelihood and impact are set together or not at all, for inherent and for residual (risks_scores_check), so a risk is never half scored.

An unscored risk has no band. That is a problem on the register (it fails risk-analysis, §11), not an error.

5. Treatment and acceptance​

treatment is MITIGATE | ACCEPT | TRANSFER | AVOID, nullable. Null means untreated. Any treatment can carry a short treatmentPlan ("Turn on BitLocker on all laptops by March").

TreatmentMeaning
MITIGATEreduce it with controls
ACCEPTlive with it, with a recorded sign-off
TRANSFERhand it to someone else (insurance, a vendor under a BAA)
AVOIDstop doing the thing that causes it

Accepting a risk is a decision someone answers for, so it needs a sign-off recorded on the risk row: who (acceptedById), when (acceptedAt), why (acceptanceNote, required, not blank) and until when (acceptanceExpiresAt, default 1 year, must be after acceptedAt).

  • The sign-off is not a Signature row. Signature needs a DocumentVersion (unified signatures), and a risk is not a document. The formal signature over the whole register belongs to the signed SRA (module 7), which will freeze the register including each acceptance.
  • ACCEPT is set only through the accept action (POST /risks/:id/accept, #79), which takes the note and the expiry. A plain edit that sets ACCEPT is rejected with RISK_ACCEPTANCE_NOTE_REQUIRED. Accepting again renews the sign-off (new who, when, note and expiry).
  • Changing treatment away from ACCEPT clears all four sign-off fields.
  • risks_acceptance_check makes both rules hold in the database: no sign-off fields unless ACCEPT, and an ACCEPT always has when, a non-blank why and a later expiry. Who may be null only because that user was deleted (SetNull).
  • An expired acceptance keeps treatment = ACCEPT. Expiry is a problem (risk-management-plan fails, §11) until someone accepts again or picks another treatment. Nothing is cleared on its own.

6. Owner and review date​

ownerId points at a User, and the API only accepts a member of the team (404 RISK_OWNER_NOT_FOUND). Deleting the user sets it to null. If the user leaves the team, the column is kept (so the page can say who used to own it), but every rule treats the risk as having no owner, the same as assets (ADR 010 §4). Removing a member therefore recalculates risk-management-plan (#81).

reviewDueAt is set by a person. When a risk first gets a band and has no date, the API fills in a default from the judged band (defaultRiskReviewDueAt):

BandNext review
Critical, High90 days
Medium180 days
Low365 days

An unscored risk gets no default (it has no band); a person may still set one. lastReviewedAt is the date of the last review (§7).

7. Review history​

A review is "I looked at this risk again". It writes a RiskReview row: who, when, an optional note, and a snapshot of the four scores and the treatment after the review (the reviewer may change scores in the same call). Reviews are never edited. Deleting the risk deletes them; deleting the reviewer only clears reviewedById.

A review also sets lastReviewedAt, moves reviewDueAt forward by the band default (unless the reviewer picks a date) and clears the review flag (§9). Ordinary edits don't write a review: changing a title is not a review. The snapshot gives the SRA (module 7) and auditors "how did this risk change over the year" without reading the audit log.

teamId is copied onto RiskReview so "when was the register last reviewed" (#81) is one indexed query per team (@@index([teamId, reviewedAt])).

8. Close, don't delete​

A risk that no longer applies ("we got rid of the fax machine") is closed: status = CLOSED with closedAt and a closeReason. The row, its links and its reviews stay for history and for the SRA. Closed risks drive no control, are never flagged, and can't be edited, accepted or reviewed (400 RISK_CLOSED) until they are reopened, which clears closedAt and closeReason. Closing also clears the review flag.

risks_closed_check: an open risk has neither close field; a closed one has both, with a non-blank reason.

Hard delete (DELETE /risks/:id) is for mistakes only, such as a duplicate. It is audited like every other write.

9. Review flag​

When a linked control turns FAILING, every open risk linked to it that isn't already flagged gets reviewFlaggedAt and a plain reviewFlagReason ("MFA is enforced started failing"; the date is reviewFlaggedAt). The risk's owner should re-score it (#82).

  • How: ControlStatusService calls every CONTROL_STATUS_LISTENERS entry on a real status change, inside the same transaction as the status write, so the controls module never imports the risk module. RiskFlagListener is the only listener today; if it throws, the status write rolls back too.

  • Linking a risk to a control that is already failing (add or edit, newly linked controls only) flags it straight away.

  • The flag clears only when someone reviews the risk (§7). The control going green again does not clear it; the page shows that it is passing again.

  • An already flagged risk keeps its first reason and date, so a control that stays red doesn't rewrite it every night.

  • Flags never feed control status. No signal source reads them, and setting one never recalculates a control. That keeps the direction one-way: controls → risks, never back, so there is no loop. Turning flags into tasks with due dates is module 6.

risks_review_flag_check: a flag always has a reason.

A risk links to many of the team's controls (RiskControl: "this control helps keep the risk down") and many of the team's assets (RiskAsset: "this risk is about these systems"). Both are many-to-many, with a primary key on the pair and an index on the far side for "which risks use this control / asset".

  • Every control and asset id in a write must belong to the risk's team. Any other id, existing or not, is 404 RISK_CONTROL_NOT_FOUND / RISK_ASSET_NOT_FOUND, so one team can't link another team's rows.
  • Deleting a control or an asset deletes the link, not the risk (cascade on both sides).
  • Retired assets stay linked (history), but rules only look at active ones.

11. What the register drives​

All of this is one more signal source on the existing ControlStatusService (source: "risks", #81), not a new status engine. Only open risks count. The rules, fixed here so later issues build on them:

ControlFails whenOtherwise
risk-analysisany open risk not scored; or an active ePHI asset rated HIGH or CRITICAL that no open risk links toSTALE when the register was not reviewed in 365 days (newest of the last RiskReview and the oldest open risk's creation, so a new register isn't stale on day one); else PASS
risk-management-planfor open risks judged Medium or higher: no owner (or owner left the team), untreated, review date passed, or acceptance expiredPASS

Low risks can sit untreated without failing anything: a register full of "Low, no plan" is honest, and forcing a treatment on every Low risk only teaches people to click ACCEPT.

Each failing signal names the risks or assets behind it in items, the same shape evidence and assets use. A risk-analysis failure with both problems sends two signals (ref: "unscored-risks" and ref: "uncovered-assets"), so one signal's items are never a mix of risks and assets.

What recalculates the two controls (#81), each once per event:

  • every risk write (RiskRecalcService.recalculate);
  • every asset write and CSV import, which add risk-analysis to the asset controls they already recalculate (an asset turning critical ePHI can need a risk);
  • removing a member who owned open risks (risk-management-plan only);
  • time passing (review dates, acceptance expiry, the yearly clock): a step in the existing daily /internal/grc-evidence cron, not a new cron (RiskDueSweepService). RiskDueCheck keeps, per team, when the sweep last recalculated it; a date that passed after that triggers one recalculation, so a second run the same day does nothing and a missed day is caught on the next. Batched and on the shared 240 s budget, like the asset review sweep.

12. No risks means no signal​

A team that hasn't added any risks keeps risk-analysis and risk-management-plan exactly as the other sources (evidence) leave them. The risk source sends nothing for an empty register; it never sends MISSING for "you have no risks". Turning on HIPAA must not turn controls red on day one, the same choice as evidence (ADR 009 §5) and assets (ADR 010 §10).

13. Library​

RiskTemplate is a global, seeded list of common healthcare risks (#78): key (unique, stable), title, description, category, threat, vulnerability?, starting defaultLikelihood and defaultImpact (1–5, risk_templates_default_scores_check), the assetTypes it applies to, and the control templates that reduce it (RiskTemplateControlTemplate).

  • Seeding upserts by key and runs safely on prod. A template removed from the file is not deleted from the database, so risks made from it keep their link. Each template's control links are rebuilt from the file on every seed; teams' risks copied their links when added, so this never changes a register. The data lives in apps/backend/prisma/catalog/risk-library.ts.
  • Adding a template to a team (#80) copies title, description and category, puts the starting scores into inherent, and links the team's controls made from the suggested control templates (only those the team has). The risk keeps riskTemplateId, so the page can show the template's threat and vulnerability. Residual and treatment stay empty so a person decides them; the review date is set from the inherent band. Assets are not linked automatically (too noisy); the page offers them.
  • GET /risk-templates lists the library by category. Each template says whether the team already added it (riskId) and whether it is suggested: the team has an active ePHI asset (holdsPhi, not retired) of one of its assetTypes. A template with no asset types is never suggested.
  • POST /risks/from-library adds several templates in one transaction, with one audit event and one recalculation. Ones the team already added are skipped and listed in skipped; an unknown key adds nothing (RISK_TEMPLATE_NOT_FOUND). A team without HIPAA has no controls, so its risks arrive unlinked, not as an error.
  • A team adds a template at most once: @@unique([teamId, riskTemplateId]). NULLs are distinct in Postgres, so a team's own risks never clash. Two adds at once (a double click) retry once on the unique clash and then skip.
  • If a template is ever deleted, its risks keep everything and lose only the link (SetNull).

14. Permissions​

ActionPermission
List and read risks, the summary, reviews and the libraryACCESS_CONTROLS
Add, edit, score, link, treat, accept, review, close, reopen, delete, add from libraryMANAGE_RISKS

MANAGE_RISKS = 1 << 27 is a new bit. We keep it: in a small practice the person who runs the risk register is often the practice manager or a compliance lead who should be able to do that without marking controls not applicable or changing control owners. The same reasoning kept MANAGE_ASSETS (ADR 010 §12). MANAGE_RISKS implies ACCESS_CONTROLS (a spec checks every preset).

Accepting a risk needs no stronger bit. The acceptor, the reason and the expiry are recorded on the risk and in the audit log, and the signed SRA (module 7) is where the practice formally signs off on the whole register. A separate "accept risks" bit would be one more box on the roles page with no practice asking for it.

  • Presets: every preset that has MANAGE_CONTROLS gets it (MSP_SUPER_ADMIN, ORG_ADMIN).
  • Stored roles: a backfill migration ORs the bit into every UserTeam whose stored permissions include MANAGE_CONTROLS, custom roles included, so nobody who could change controls yesterday loses the register today. Presets are also merged in at read time.

Risks are ORG-team data. Vendors never see them (they have no permission bitfield; see AGENTS.md).

15. Audit​

Category COMPLIANCE, resource risk:

ActionWhen
compliance.risk_createdone risk added by hand
compliance.risk_updatedany field or link changed, including treatment away from ACCEPT
compliance.risk_acceptedaccepted (or accepted again) with a sign-off
compliance.risk_reviewedreviewed; a RiskReview snapshot was written
compliance.risk_flaggeda linked control turned FAILING; actor is SYSTEM (#82)
compliance.risk_closedclosed with a reason
compliance.risk_reopeneda closed risk made open again
compliance.risk_deletedhard delete of a mistake
compliance.risks_added_from_libraryone event per "add from library", with the template keys, not one per risk

Every write runs in one transaction with its audit event, then recalculates only the two risk controls of that team, once per request (#81).

16. Error codes​

CodeHTTPWhen
RISK_NOT_FOUND404no such risk in this team
RISK_TEMPLATE_NOT_FOUND404no such template in the library
RISK_OWNER_NOT_FOUND404the owner is not a member of this team
RISK_CONTROL_NOT_FOUND404a linked control is not a control of this team
RISK_ASSET_NOT_FOUND404a linked asset is not an asset of this team
RISK_ACCEPTANCE_NOTE_REQUIRED400accepting without a reason, or setting ACCEPT through a plain edit
RISK_RESIDUAL_ABOVE_INHERENT400residual score above inherent, or residual without inherent
RISK_CLOSED400editing, accepting or reviewing a closed risk

Consequences​

  • The tables exist before anything uses them. Nothing visible changes until the library (#78), the API (#79), the signals (#81), the flags (#82) and the page (#83).
  • Residual is only as good as the person's judgement. The review flag is how the register notices that a control it counted on stopped working; it asks for a re-score rather than guessing one.
  • Score and band are never stored, so lists filter by band in code or with the same arithmetic in SQL. At small-practice sizes (tens of risks) that is fine.
  • Keeping an expired acceptance as ACCEPT means the register can show "accepted, expired" honestly; the cost is that every reader must check acceptanceExpiresAt, which the shared rules do.
  • MANAGE_RISKS is one more box on the roles page. If nobody uses it apart from admins, it can be folded into MANAGE_CONTROLS later with a migration that drops the bit; nothing else depends on it being separate.