ADR 007: GRC control model — frameworks, requirements and controls
Status: Accepted
Context
RiskFlow is growing from separate compliance tools (SRA, BAA, PnP, training) into a Vanta-style program: a clinic turns on a law such as the HIPAA Security Rule, sees what it asks for, and tracks the concrete things it does to meet it. Before any tables or endpoints exist, everyone building this (people and AI agents) needs the same picture of how those pieces fit together.
Two things make this harder than a checklist:
- The law is vague on purpose. HIPAA 164.312(d) says "implement procedures to verify that a person or entity seeking access … is the one claimed". It does not say "turn on MFA". The same requirement is met in different ways by different clinics, and one requirement usually needs several concrete safeguards.
- One safeguard covers many requirements, and later many laws. Enforcing MFA helps meet several HIPAA requirements today and SOC 2 criteria later. If each requirement carried its own copy of "MFA enforced", a clinic would track the same thing in several places and the copies would drift apart.
The catalog of laws and our recommendations is the same for every clinic. Each clinic's own answers (who owns a safeguard, whether it applies, how it is doing) are private to that clinic.
Decision
1. The model
Framework (HIPAA Security Rule) ← the law. Global, seeded by us.
└─ Requirement (164.312(d) "Person or entity authentication")
▲ many-to-many
ControlTemplate ("MFA enforced") ← our recommended way to meet it. Global, seeded by us.
│ copied when a team turns on a framework
Control (team's own "MFA enforced") ← owned by one team: owner, status, N/A reason.
- A Framework is a law or standard (
hipaa-security). It has aversionso a future rule change can live next to the old one. - A Requirement is one thing the framework asks for, keyed by the citation
people recognize (
164.312(d)). Requirements form a tree: HIPAA has standards with implementation specifications underneath. Each one may be marked Required or Addressable. - A ControlTemplate is our recommended, concrete, checkable safeguard
(
mfa-enforced). Templates link to requirements many-to-many throughControlTemplateRequirement. - A Control is a team's own copy of a safeguard. It carries the team's
owner, status, and not-applicable reason.
ControlRequirementrecords which requirements that team's control covers.
2. Requirements and controls are separate
We do not attach controls to requirements as children, and we do not treat a requirement as something a team "does". Requirements say what, controls say how, and the link between them is many-to-many. This lets one control (MFA) count toward several requirements and, later, several frameworks, so a clinic fixes it once and every requirement it covers moves together.
3. Catalog rows and team rows are split
| Tables | teamId | Written by | |
|---|---|---|---|
| Catalog | Framework, Requirement, ControlTemplate, ControlTemplateRequirement | none | idempotent seed scripts only |
| Team | TeamFramework, Control, ControlRequirement | always | the team, through the API |
- Catalog rows are read by every team and never written by request handlers. There is no per-team edit of a requirement or template.
- Team rows follow the normal multi-tenancy rules: every query filters by
teamIdtaken from the principal, never the body, and a miss across teams returns 404 (ADR 004). ControlRequirementstoresteamIditself (copied fromControl) so tenant-scoped queries do not need a join.
4. Turning on a framework copies, it does not reference
When a team turns on a framework (TeamFramework), we create a Control for
each template linked to that framework's requirements and copy the template's
requirement links into ControlRequirement. After that the team's controls
are independent of the template:
- teams can rename a control, write custom controls with no template
(
controlTemplateId = null), and change which requirements a control covers; - a team adopts a given template at most once (
@@unique([teamId, controlTemplateId])), so a template shared by two frameworks becomes one control covering both; - later edits to the catalog do not silently change a team's controls. Pulling catalog updates into existing teams is a separate, explicit step.
5. Status is computed by pure rules from signals
A control's status (NOT_STARTED, PASSING, FAILING, NEEDS_EVIDENCE,
NOT_APPLICABLE) is the output of pure functions with no database or Nest
dependency. Their input is a list of signals: small facts such as "this
Google Workspace check failed" or "this document is missing". Requirement
status and the framework's readiness score are computed from control statuses
by the same kind of pure functions.
- Status is stored on
Control(withstatusChangedAt) for fast dashboards, and recalculated byControlStatusServicewhenever its inputs change. The stored value is a cache of the rules, not a field users edit. - Signals come from pluggable signal sources. Later epics add sources (Google tests, evidence, training, BAAs) without changing the rules.
- Marking a control not applicable requires a written justification and records who approved it and when, because auditors read these reasons.
- The readiness score counts only leaf requirements. A standard that has implementation specifications underneath is not scored itself, so nothing is counted twice.
6. Later
Tests and their results, evidence, the risk register, assets, issues and the
yearly SRA snapshot are later epics. They plug into this model as new signal
sources and as tables that link to Control; they do not change the catalog
and team split above.
Consequences
- Until a signal source exists, every control shows Not started and every framework scores 0%. That is expected at the end of the foundation epic.
- Two link tables (
ControlTemplateRequirementandControlRequirement) hold similar data. This is deliberate: one is our recommendation, the other is the team's actual mapping, and they are allowed to differ. - Catalog fixes reach new teams automatically but not existing ones. A sync step for existing teams is needed before we ship a second version of a framework or change template links in a way teams should see.
- Seeds must be idempotent and keyed on stable
keyvalues (hipaa-security,164.312(d),mfa-enforced), never on autoincrement ids. - Adding a framework that reuses existing templates (for example SOC 2) adds
requirements and template links only. Teams that already have those controls
get the new coverage through
ControlRequirementrows, not duplicate controls.
References
- Epic: GitHub issue #21 (GRC foundation)
apps/backend/prisma/schema/grc.prisma(issues #23, #24)apps/backend/src/modules/controls/status/control-status.rules.ts(issue #27)- ADR 004 — 404 over 403
- Glossary: Framework, Requirement, Control, Control template, Control status,
Readiness score in
CONTEXT.md