Don't flatten permissions
The easiest design is also the fastest way to leak.
When you index a source system, the tempting move is to compute a single visibility bit per object per user and store it alongside the content. Queries become trivial: filter on the bit. We did not do this, and the reason is that the bit is a lossy projection of a richer rule, and lossy projections leak.
Three shapes that do not collapse
- Group membership changes. An object shared with "Engineering" is visible to whoever is in that group now, not to whoever was in it when we indexed.
- Nested restrictions. A page inside a shared database can be restricted to a subset. A single bit on the database cannot express that.
- External guests. The same object may be visible to a contractor with an expiring grant and invisible to a full member with a conflicting deny.
Any of these turns a cached bit into a stale bit, and a stale bit that says "visible" is an incident.
We keep the rule, not the answer. Evaluation happens at query time, against the permission state as it exists right then.
What it costs
Query latency. We evaluate a predicate tree per candidate object rather than reading a column. On a workspace with a hundred thousand objects and a deep group graph, that is the difference between twelve milliseconds and ninety.
We accept it. Ninety milliseconds is still interactive, and the alternative is a class of bug that no amount of testing fully closes.
The rule we hold ourselves to
If a user cannot see an object in the source system, they must not be able to see it here — including through citations. When an answer's supporting object turns out to be invisible to the requester, we drop the citation and mark the answer as partially supported rather than showing a redacted stub. A redacted stub is itself a disclosure.