Any product that reports on people eventually grows a privacy threshold. Do not show a group smaller than five. Round to the nearest ten. Hide the cell when the count is low.
The instinct is correct. The usual implementation leaks anyway, and it leaks in a way that passes review because every individual screen looks compliant.
The Rule Everyone Starts With
The standard small-group rule suppresses any aggregate computed from fewer than n individuals, where n is commonly five. A team of three does not get an engagement score. A clinic with four patients in a cohort does not get an outcome breakdown.
It is a good rule. The threshold is rarely the problem. Where it is enforced almost always is.
Most implementations put it in the presentation layer: the query returns everything, and the component checks the count and renders a dash instead of a number. Viewed in isolation, each screen is correct.
The Differencing Attack
Now add two features every reporting product eventually grows: comparison over time and export.
A department head opens the Q1 engagement report. One team shows 5 respondents and an average of 3.4.
The same person opens the Q2 report. That team now shows 4 respondents — someone left — so the cell is suppressed. No number rendered. The rule worked.
Except they have the Q1 figure, know the team had five people, know who left, and can see how the total moved. From two views that were each individually compliant, they can reconstruct one departed colleague's score.
This is a differencing attack. It requires no technical skill — just two browser tabs and an interest in one person's answers.
The same failure appears in other shapes:
- Overlapping filters. "All staff" minus "staff excluding one team" isolates that team, whatever its size.
- Drill-down paths. Two suppressed cells and one visible total can be enough to solve for the suppressed values.
- Exports. A CSV generated before suppression is applied carries raw counts out of the building entirely.
- Caching. A cached pre-suppression result served to a later request bypasses the check completely.
Where Suppression Belongs
In the query layer, not the view.
If suppression is a rendering decision, then every new screen, export, API endpoint and scheduled email is a fresh opportunity to forget it. If it happens where the aggregate is produced, there is one place to get right and no path around it.
In practice that means:
- A single aggregation service that every reporting surface goes through, so no component ever touches raw grouped data.
- Suppression applied before the result is serialised, cached or exported — never after.
- The threshold held in configuration rather than as a literal, so it can be tightened without a deployment.
- Suppressed cells returned as an explicit suppressed state rather than
nullor0. A zero that means "hidden" will eventually be summed by something.
// Every reporting surface resolves aggregates through one path.
// Suppression happens here — before serialisation, caching or export.
public function teamAverages(Organisation $org, Period $period): AggregateResult
{
$rows = $this->rawAggregate($org, $period);
return AggregateResult::make($rows)
->suppressBelow(config('reporting.min_group_size'))
->withSuppressedMarker(); // explicit state, never null or 0
}
Comparison Needs Its Own Rule
Enforcing at the query layer fixes the per-view leak. It does not by itself fix the differencing attack, because both views were individually compliant.
Time comparison needs an additional rule: if a group falls below the threshold in any period in the comparison, suppress it in every period. A cohort that drops from five to four does not show the Q1 number and hide the Q2 one — the row leaves the comparison entirely.
This feels aggressive the first time it is specified. It is considerably less aggressive than explaining to a data protection officer how a manager derived one employee's answers from two compliant screens.
Log Who Looked
Suppression prevents the obvious leak. Logging answers the question that arrives afterwards.
When someone eventually asks — a compliance review, a data protection officer, an employee who suspects their responses were read — the difference between "the system does not allow that" and "here is every aggregate that account accessed, and when" is the difference between an assurance and an answer.
An access log over reporting views is cheap: viewer, scope, period, timestamp. Nobody looks at it for a year, and then one day it is the most valuable table in the database.
Design the Purpose In, Not Just the Threshold
The controls above assume a decision has already been made about what the reporting is for. That decision does more work than any threshold.
Reporting built to find gaps and direct investment wants aggregates and nothing else. Reporting that permits individual drill-down is an assessment tool, whatever the documentation calls it. Those are different products, and users can tell which one they are looking at.
If the intent is genuinely the former, let the interface enforce it rather than the policy document: no ranking, no percentiles, no leaderboards, no path from an aggregate to a person, and purpose-limitation language carried into exported files so it survives being emailed onward.
A threshold protects data. The absence of a drill-down path protects candour — and candour is what determines whether the responses are worth analysing at all. Suppressing a dataset of dishonest answers perfectly is still worthless.
Best Practices
- Suppression lives in the query layer, never in components
- Applied before serialisation, caching and export
- Threshold is configuration, not a literal
- Suppressed values carry an explicit state, never
nullor0 - A group below threshold in any compared period is suppressed in all of them
- Overlapping filters and drill-downs are tested for differencing, not assumed safe
- Exports and scheduled reports resolve through the same path as the screen
- Access to aggregate reporting is logged
- No interface path leads from an aggregate back to an individual
Conclusion
The threshold is the easy part, and it is where most teams stop. The leak is almost never a missing rule — it is a correct rule enforced in the wrong layer, then quietly bypassed by the export button, the cache, or the comparison view added three sprints later.
Put suppression where the aggregate is produced, extend it across compared periods, log the access, and remove the drill-down path entirely. Most of it costs a few hours during the build. Retrofitting it after someone has already reconstructed a colleague's responses costs considerably more than hours.