A previous piece named the problem: some D365 actions, Post, Approve, Cancel, Confirm, are class-method calls with no declared data source, which means the platform's own compiler-generated table map has nothing to attribute them to. We called that audit-blind, and we were honest that the guardrail against dropping unobservable privileges only manages the risk, it doesn't close the gap. This is what closing part of that gap actually looks like, and why we only do it for a small, carefully chosen slice of it.

The scale of the gap, and where it actually concentrates

Across every full tenant we've analyzed this way, the pattern holds with almost no variation: roughly 90% of screen-based privileges are directly observable, versus roughly 20% of report privileges, roughly 10% of action-button privileges, and effectively 0% of the individual controls embedded inside a single screen. Menu-item action privileges specifically, the Posts and Approves and Cancels, are audit-blind more often than not.

That sounds unbounded, and the total count of audit-blind privileges in a large tenant is genuinely large. But when you filter out the ones that are audit-blind and correctly low-stakes, output-only reports, pure navigation links, batch-only operations nobody could reasonably act on interactively, the set of privileges where closing the gap is actually worth the engineering effort collapses to something much smaller: typically a few hundred privileges, clustered into a manageable number of recognizable patterns. Posting a journal. Posting an invoice. Confirming an agreement. Approving a bill of materials. Cancelling a work order. The problem looks unbounded until you sort it, and then it doesn't.

Grid of six categories: already mapped, report/output only, no persistent write, genuinely unobservable, curatable now, and curatable with care.

Three layers, used in order

For the privileges worth the effort, we extend observability in three layers, each one only attempted if the previous one wasn't sufficient, and each one requiring progressively more access to the client's environment.

Flow diagram: Layer A behavioral inference and a statistical test, Layer B a static code trace, Layer C a compiler cross-reference, feeding a curated mapping.

Layer A, behavioral inference, needs nothing beyond data we already have. The intuition: even if pressing "Post" leaves no direct trace, the record it creates downstream, a posted-transactions ledger, an invoice header, does leave a trace, and that table is often already in the audit sweep. We propose a candidate link between the action and that downstream table, then test it statistically: do people who hold this specific privilege actually show materially more activity on the candidate table than people who don't? A real link produces a clear, testable signal. A coincidental one usually doesn't survive the test.

Layer B, a static code trace, is used when the behavioral signal alone isn't conclusive and we have access to the platform's own standard application source. We trace the action's underlying class to the specific table writes it performs, confirming the link at the code level rather than inferring it statistically. This only works for logic in the platform's own standard, sealed packages, not custom client code, which keeps it repeatable across engagements rather than a one-off investigation each time.

Layer C, a compiler-level cross-reference, is the strongest evidence when it's available: a pre-computed map the platform's own build process already generates, linking classes and methods directly to the tables they touch. When we can get it, it makes the manual work of Layer B largely unnecessary for the objects it covers.

The rule that keeps this from making things worse

Every curated link goes through the same three checks before it's trusted: the target must be a table where activity can plausibly only come from the action in question, not a table five unrelated processes also write to; the write must be a genuine create, not an ambiguous modify that could mean almost anything; and it has to pass the statistical test in Layer A regardless of which layer actually produced the candidate link. A curated link on a shared table doesn't get treated as proof, only as a factor that rules out "definitely unused," never as confirmation of "definitely used." That distinction, ruling something out versus ruling something in, has to survive all the way through to the final disposition, or the fix becomes a new source of the exact over-attribution problem it was meant to solve.

⚠ Why we don't just curate everything

Two structural facts limit how far this can ever go, no matter how much engineering time is spent on it. Some actions run under a shared service or batch account rather than the individual person who triggered them, which erases the individual signal entirely regardless of how good the table mapping is. And some menu items share a single underlying class parameterized by an enum, meaning several distinct-looking actions in the security model all trace to the exact same code path, with no way to separate them after the fact. Both are permanent limits on observability, not curation gaps waiting to be closed.

What we tell a client about the privileges we can't reach

Every audit-blind privilege we don't curate stays exactly where the guardrail already puts it: kept, and flagged for a person to review, never auto-dropped for lack of evidence the system was never built to produce. Curation shrinks that review list. It's never expected, and never claimed, to shrink it to zero.

✓ Bottom line

Extending observability into audit-blind territory is real, careful engineering, not a workaround, and it's worth doing for the small set of high-value actions where a downstream trace genuinely exists. It's not a substitute for the guardrail, it's what lets the guardrail apply to a smaller list, with more of that list resolved by real evidence instead of an honest "we can't tell."

Questions we get asked

Does curation mean you eventually see everything?

No, and we're deliberately not claiming that. Batch-account attribution and enum-parameterized menu items that share one class are permanent structural limits, not gaps we expect to close with more engineering time. A meaningful share of audit-blind privileges will always stay flagged for a person to review.

Isn't inferring a table link from statistics a guess?

It's a tested hypothesis, not a guess. A candidate link only gets trusted after it passes a statistical test comparing privilege-holders against everyone else, and even then it's only ever treated as ruling out "definitely unused," never as proof of "definitely used" on a shared table.

Why does this matter more for some privileges than others?

Because the highest-stakes actions, posting, approving, cancelling, are exactly the ones most likely to be audit-blind by construction. Leaving that gap unaddressed means the least-observed privileges are also the most consequential ones, which is precisely backwards from where you'd want the uncertainty to sit.

Glossary

TermMeaning
Audit-blindA privilege whose entry points have no data source for the platform's own audit mapping to attach to, structurally unobservable, not necessarily unused
Behavioral inferenceProposing a link between an action and a downstream table it plausibly writes to, then testing that link statistically
Lift testThe statistical check confirming privilege-holders show materially more activity on a candidate table than non-holders
Curated mappingA verified, hand-added link between an audit-blind action and the table it actually affects, folded into the same evidence pipeline as native mappings