A payment gateway timed out for nineteen minutes. By the time we'd finished writing it up, the same incident had been claimed three times: infrastructure filed it as a Technology risk, operations logged it as a service-availability failure, and compliance wanted it flagged against a regulatory reporting obligation. One event, three owners, three categories. My data model could only let it pick one.
That single foreign key is where today's post lives.
The gap this closes
Post 1 called fragmentation the real enemy, not a shortage of data. This is that same problem, but hiding inside the schema instead of across five tools. If one incident can only point at one risk, you have quietly rebuilt the thing the register was meant to kill. The full shape of an event gets split, either across systems or across three people's private spreadsheets, and nobody sees all of it at once.
The model I started with was a tree
My first schema was a tree. Every incident carried a single categoryId, one foreign key into the risk taxonomy from the last post. It was clean, it charted in one line, and it was wrong within about a week of real use.
Risks and incidents are not trees. One incident touches many risks. One risk is evidenced by many incidents. That outage was Technology (the gateway), Operational (the service was down), and Legal/Regulatory (a reportable event) at the same time, and none of those was more true than the others. The relationship is many-to-many in both directions, which is the definition of a graph, not a hierarchy.
The build
The join table is the whole move. In Prisma it is a small explicit model sitting between Incident and Risk, and every row carries its own metadata: whether this is the primary association and how strong the link is. The graph became the source of truth, and the old single-category view became just one query over it.
The trade-off: normalization vs. the reporting query
The tree had one real advantage, and I gave it up on purpose. Count incidents by category used to be a one-line group-by on a foreign key. Once the truth lives in a join table, that same question has to fan out through the join and can double-count if you are not careful, because one incident legitimately appears under three categories.
So I kept a controlled piece of denormalization: the isPrimary flag. The 30-second dashboard reads the primary link and gets a clean headline number with no double counting. Anyone who drills in gets the full graph, every category the incident actually touched. Normalized truth underneath, one fast view on top. That split is the part nobody warns you about, and it is worth being deliberate about rather than discovering it under a board deadline.
What I got wrong
My first fix was worse than the problem. Instead of a join table, I duplicated the incident, one row per category. Two rows, same outage. It made the dashboard counts add up, and it quietly corrupted the audit trail. Close one copy, the other stays open, and now the register disagrees with itself about whether a nineteen-minute outage was ever resolved. For a tool whose entire job is being the single source of truth, that is close to the worst failure mode there is. I ripped it out the next day and never trusted a just duplicate the row shortcut again.
Where it stands
Today an incident links to as many risks as it genuinely touches, each link carries its own metadata, and the dashboard still gets one headline number because the primary flag hands it one. The graph is the truth. The tree is only a view.
Next up (Post 4): the first 48 hours of scaffolding this whole platform, and what I deliberately over-built and under-built on day one.
Following the build? Subscribe below and the next post lands in your inbox.