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
| Kind | Links to | Example |
|---|---|---|
DOCUMENT | a Document | "Incident response plan.pdf" |
POLICY | a PnpDocument | the team's sanction policy, assigned and signed |
TRAINING | a Training | "Security awareness 2026" |
NOTE | nothing | "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.
3. Links are real foreign keys, not polymorphic
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.
expiresAtis 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 withnull. - 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):
| Evidence | Signal |
|---|---|
DOCUMENT / NOTE, not expired | PASS |
| any kind, expired | STALE |
POLICY | from assignments and signatures on the current version (§9) |
TRAINING | from 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
VendorOrgLinkrows that are notREVOKED.INVITEDcounts: 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 vendorSignaturefrom 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 →FAILwithlabel("2 vendors without a signed BAA: Acme Labs, Fax.io") anditems(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]forsanction-policy,[TRAINING]forsecurity-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
| Action | Permission |
|---|---|
| List a control's evidence | ACCESS_CONTROLS |
| Attach, upload, edit, remove | MANAGE_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):
| Action | Actor |
|---|---|
compliance.evidence_added | user (attach or upload) |
compliance.evidence_updated | user (title, note or expiry changed) |
compliance.evidence_removed | user |
compliance.evidence_expired | system (#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:
- Nobody assigned →
MISSING("No one has been asked to sign this policy"). - 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 initems. - 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"). - 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.
| Event | Call |
|---|---|
| Policy signed, new version uploaded, user assigned or unassigned | forPolicy(teamId, id) |
| Policy deleted | controlsForPolicy before the delete, recalculateControls after (the delete cascades the evidence rows) |
| Training completed for the first time, batch created, batch due date changed, batch deleted | forTraining(teamId, id) |
| Training deleted | controlsForTraining before, recalculateControls after |
| Member removed from the team | forMembers(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.
Signaturestays polymorphic. We don't convert it; the reasons differ (§3).