Skip to main content

ADR 009: GRC evidence

Status: Accepted

Context​

ADR 007 gave every team its own controls, and ADR 008 gave them their first signal source: Google Workspace tests. Most HIPAA controls cannot be checked by an API, though. "A written sanction policy exists" or "staff complete security training" are proven by things RiskFlow already holds: policies (PnP), training, signed BAAs and files in Documents. Today none of them count for anything on the Controls page.

The evidence epic (#52) makes them count. Every issue in it (signal source and expiry, policies and training as proof, the BAA check, the UI) builds on the same few decisions:

  • What can be attached to a control, and how is it linked?
  • What happens to the evidence when the linked item is deleted?
  • How does evidence go out of date?
  • What does a control with nothing attached show?
  • Who can attach and remove evidence?

Decision​

1. The model​

Control (the team's "Sanction policy")
└─ ControlEvidence (teamId, kind, expiresAt) ← attached by people
├─ DOCUMENT → Document (files uploaded on the control land here too)
├─ POLICY → PnpDocument
├─ TRAINING → Training
└─ NOTE (text written on the control, no link)

ControlTemplate.evidenceKinds / evidenceRefreshDays ← catalog hints

ControlEvidence is team-owned: it carries teamId, every query filters by the team taken from the principal, and a miss across teams is a 404 (ADR 004).

2. Four kinds of evidence​

KindLinks toExample
DOCUMENTa Document"Incident response plan.pdf"
POLICYa PnpDocumentthe team's sanction policy, assigned and signed
TRAININGa Training"Security awareness 2026"
NOTEnothing"Tabletop exercise held 2026-03-14, notes in the board minutes"

A file uploaded from a control's page is saved into Documents (in a "Control evidence" folder, created on first use) and attached as DOCUMENT evidence. There is one place for files: removing the evidence does not delete the file, and the file can be attached to other controls too.

A control can hold any mix of kinds. The same item can be attached to a control only once (409 EVIDENCE_ALREADY_ATTACHED); it can be attached to any number of controls.

ControlEvidence has three nullable foreign keys: documentId, pnpDocumentId, trainingId. A database check constraint ties them to the kind: DOCUMENT sets documentId and nothing else, POLICY only pnpDocumentId, TRAINING only trainingId, and NOTE sets none and must have note text. The API checks the same thing first and answers 400 EVIDENCE_LINK_INVALID, so the constraint is only a safety net.

Each foreign key is onDelete: Cascade. Deleting a policy, a training or a document removes its evidence rows, with no clean-up code.

This is on purpose different from the polymorphic Signature table (ADR 005), which stores subjectType + subjectId and needs manual cascade deletes. Signatures cover two subjects with identical rules; evidence links to three different tables with different meanings, and a real foreign key gives us integrity, cascades and joins for free. A fourth kind later is one more nullable column and one more branch in the constraint.

The linked item must belong to the same team as the control. The API looks it up with teamId in the where, and a miss is 404 EVIDENCE_TARGET_NOT_FOUND, the same answer whether the item does not exist or belongs to someone else.

Cascades are not audited as evidence_removed. The item's own module deleted it, and that module's audit event (or the item's absence) explains it. A cascade also does not recalculate the control by itself: the signal source (#54) and the policy and training sources (#55) recalculate the controls that used an item when they hear about its change or deletion.

4. Expiry​

Any evidence can have an expiresAt. Expired evidence sends a STALE signal (#54), and by the existing rules a control with a STALE signal and no FAIL shows Needs evidence. An incident response plan uploaded with a one-year expiry turns its control to Needs evidence the day it expires. A cron in #54 finds newly expired evidence, writes compliance.evidence_expired (actor SYSTEM) and recalculates those controls.

  • expiresAt is optional. No expiry means the evidence never goes stale by date (a policy can still fail for other reasons, #55).
  • When it is set on add or edit it must be in the future (400 EVIDENCE_INVALID_EXPIRY). An edit can clear it with null.
  • A template can suggest a refresh period (evidenceRefreshDays, e.g. 365 days for a policy review). It is a suggestion the UI pre-fills. The API never sets an expiry the caller did not send: what you send is what is stored.

How the sweep remembers (#54). The daily cron (/internal/grc-evidence) takes evidence with expiresAt <= now and expiryRecordedAt IS NULL, stamps expiryRecordedAt, writes the audit event and recalculates that team's affected controls, all in one transaction per team. A second run finds nothing, so each expiry is recorded exactly once. Editing expiresAt clears the stamp, so the new date is recorded when it passes. We chose a column over "no evidence_expired audit row yet" because audit rows move to the archive after 12 months and would then be recorded again.

The stored status can lag an expiry by up to a day (until the next sweep). The signal itself is computed live from expiresAt, so any recalculation before then (an evidence edit, a Google run) already sees it as STALE, and GET /controls/:id lists it as expired at once.

5. No evidence means no signal​

A control with nothing attached sends no evidence signal, so it stays Not started unless another source (a Google test, the BAA check) speaks for it. We do not send MISSING for "nothing attached yet". Turning on HIPAA must not turn 35 controls yellow on day one: Not started is the honest state of a control nobody has worked on.

What each kind signals once attached (decided in #54 and #55, listed here so the whole picture is in one place):

EvidenceSignal
DOCUMENT / NOTE, not expiredPASS
any kind, expiredSTALE
POLICYfrom assignments and signatures on the current version (§9)
TRAININGfrom the completion rate of its assignments (§9)

6. BAAs are automatic​

The business-associate-agreements control is not proven by attaching something. The BAA source (#56) checks every vendor linked to the team for a signed BAA and sends FAIL naming the vendors without one. Nothing has to be attached by hand, and attaching an agreement as evidence is not a kind.

The rule (grc-evidence/baa/baa-signal.rules.ts):

  • Vendors in scope are the team's VendorOrgLink rows that are not REVOKED. INVITED counts: a vendor that has not accepted the invite yet still needs a BAA before it gets patient data.
  • A vendor is covered when an agreement it is on (AgreementVendor) has a vendor Signature from it on that agreement's current version. A signature on an older version stops counting once a new version exists.
  • All covered → PASS. Any not covered → FAIL with label ("2 vendors without a signed BAA: Acme Labs, Fax.io") and items (the vendors' ids and names) so the control detail can list them. No vendors → no signal, so the control stays Not started.
  • Every query filters by the team. A vendor is global and can work for many teams; its signature on another team's agreement never counts.

The control is recalculated after a vendor is invited or removed, a vendor is added to or taken off an agreement, a vendor signs, and an agreement is deleted. There is no "replace BAA file" flow yet (a BAA gets version 1 when it is created); when one is added it must refresh the control too (BaaControlRefresher.refresh(teamId)). Accepting an invite changes nothing, since INVITED and ACTIVE both count.

7. Template hints live on ControlTemplate​

Two catalog columns, filled from prisma/catalog/hipaa-controls.ts:

  • evidenceKinds EvidenceKind[]: which kinds the template expects, e.g. [POLICY] for sanction-policy, [TRAINING] for security-awareness-training. The UI uses it to offer the right picker first. Empty means no suggestion.
  • evidenceRefreshDays Int?: the suggested expiry from the day evidence is added.

Columns, not a separate table: there are two small values per template, they are always read together with the template, and nothing else points at them. Custom controls have no template and so no hints.

8. Permissions and audit​

ActionPermission
List a control's evidenceACCESS_CONTROLS
Attach, upload, edit, removeMANAGE_CONTROLS

No new permission bit. Uploading a file puts it in Documents, which normally needs UPLOAD_DOCS. Every role preset that holds MANAGE_CONTROLS also holds UPLOAD_DOCS, and a spec checks that this stays true; a custom role that breaks it is the admin's explicit choice.

Listing shows the linked item's name to anyone with ACCESS_CONTROLS, even without VIEW_DOCS, ACCESS_PNP or VIEW_TRAINING (for example MSP_AUDITOR). Only names, kinds and dates are shown, not file contents or URLs. Reviewing proof is the auditor's job.

Audit events (category COMPLIANCE, resource control_evidence):

ActionActor
compliance.evidence_addeduser (attach or upload)
compliance.evidence_updateduser (title, note or expiry changed)
compliance.evidence_removeduser
compliance.evidence_expiredsystem (#54)

Every write runs in one transaction with its audit event, then calls ControlStatusService.recalculate(teamId, [controlId]) for that one control.

9. Signed policies and finished training (#55)​

A policy or training counts by what people did with it, not by being attached. The rules are pure functions (grc-evidence/policy/policy-evidence.rules.ts, grc-evidence/training/training-evidence.rules.ts); small loaders feed them, and every query filters by the team. Expiry (§4) comes first: expired evidence of any kind is STALE whatever the rule would say.

Policy. Only the policy's current version counts, and only people assigned to it (PnpAssignment) who are still on the team (UserTeam). First match wins:

  1. Nobody assigned → MISSING ("No one has been asked to sign this policy").
  2. Someone has not signed the current version more than 30 days after the later of their assignment and the version's upload → FAIL, with the count ("3 of 12 not signed") and their names in items.
  3. The current version is older than the template's refresh period (evidenceRefreshDays, 365 days when there is none) → STALE ("Policy not reviewed in over a year").
  4. Otherwise PASS. People still inside the 30 days don't count against it, so a new hire doesn't turn the control red on day one.

A signature on an older version stops counting as soon as a new version is uploaded, and the new version gives everyone another 30 days. FAIL beats STALE: an old policy nobody signed is failing, not just out of date.

Training. Worked out per person still on the team, from all their assignments of that training. A person is:

  • completed when they have a completion in the last 12 months (training is annual);
  • left out when they have an open assignment that is not due yet (they still have time);
  • not completed otherwise: overdue, or only completions older than 12 months.

Completion rate = completed ÷ (completed + not completed). Nothing assigned → MISSING. Rate at or above the target → PASS; below → FAIL with the rate ("85% completed (17 of 20), target 90%") and the people who haven't finished in items. When everyone assigned still has time, the rate has nothing to count and the signal is PASS. Overdue comes from the due date, not the stored OVERDUE status, which is only refreshed when someone opens a training page.

The target is 90% by default. A team can raise it per evidence row (completionThreshold, 90–100) when adding or editing training evidence; another value, or one on another kind, is 400 EVIDENCE_INVALID_THRESHOLD. A check constraint keeps it on TRAINING rows and in range. It can be raised, not lowered: 90% is the floor we stand behind.

Why the signal says so. Every signal now carries detail (the sentence above) next to label (the evidence's name), and items for the people behind a FAIL. GET /controls/:id returns both under signals.evidence.

Keeping status fresh. EvidenceRecalcService is the one door PnP, Training and Teams use; it looks up the team's controls by foreign key (pnpDocumentId, trainingId) and recalculates only those. A policy or training used by no control recalculates nothing. Like the BAA refresher it runs after the caller's write and logs a failure instead of throwing.

EventCall
Policy signed, new version uploaded, user assigned or unassignedforPolicy(teamId, id)
Policy deletedcontrolsForPolicy before the delete, recalculateControls after (the delete cascades the evidence rows)
Training completed for the first time, batch created, batch due date changed, batch deletedforTraining(teamId, id)
Training deletedcontrolsForTraining before, recalculateControls after
Member removed from the teamforMembers(teamId): every control with policy or training evidence

Some changes come from the clock alone: a grace period ends, a due date passes, a version turns a year old, a completion turns 12 months old. Nothing fires for those (there is no job that marks assignments overdue; the reminders cron only sends emails). So the daily evidence cron, after the expiry sweep, recalculates every control that has policy or training evidence, team by team, inside the same time budget. The stored status can lag one of these by up to a day; GET /controls/:id computes the signals live and shows the current answer at once.

Consequences​

  • Policies, training and documents can be attached today, and they will start to count as soon as #54 and #55 add their signal sources. Nothing visible changes until the UI (#57).
  • Deleting a policy, training or document silently removes its evidence. That is what people expect, but the control can lose its only proof. The recalculation in #54 / #55 shows that on the Controls page.
  • A fourth evidence kind is a migration (enum value, nullable column, check constraint branch), not just code. That is the price of real foreign keys.
  • Signature stays polymorphic. We don't convert it; the reasons differ (§3).