Retrieval over a permissioned corpus: ACL handling, the update pipeline, and what to measure
A bench specification for retrieval over a corpus that carries permissions: the four access-control failure modes, why post-filtering starves an approximate index, where the cache breakpoint belongs, and the six numbers to record on every run.
This is a bench specification for retrieval over a corpus that carries permissions, written against pinned vendor documentation and checked on 2026-07-28. No production deployment stands behind it. Every figure below is either quoted from a vendor page named in the sentence, or a formula you run against your own corpus.
The three parts that decide whether such a system is safe to switch on are the permission model, the update pipeline, and the arithmetic that tells you when a design choice pays. Retrieval quality is the part everyone tunes. The permission layer is the part that turns a quality problem into a disclosure incident.
Test conditions
These are the versions and documented defaults the arithmetic below assumes. Nothing was stood up against them. Change one and recompute.
- Generation and caching. Anthropic Claude API. Model identifiers
claude-sonnet-5andclaude-haiku-4-5-20251001, taken from the Claude models overview page on 2026-07-28. That page states that identifiers from the 4.6 generation onward use a dateless format which is still a pinned snapshot, not an evergreen pointer, so a bare identifier does not silently roll forward under you. - Vector store. PostgreSQL with pgvector, HNSW index, documented defaults. The pgvector README documents
hnsw.ef_searchat 40 by default, and iterative index scans off unless you turn them on. - Source system. SharePoint and OneDrive for work or school through Microsoft Graph v1.0: the driveItem permissions collection for the access model, change notifications for the update path.
- Labels. Microsoft Purview sensitivity labels, both with and without encryption applied.
- Corpus. Yours. The bench takes the document count, the mean chunk size, the number of distinct principals and the read-share distribution as inputs, and reports nothing until you supply them.
Where a permissioned corpus breaks
Four failure modes account for most of the disclosure risk. Each has a different detection path, and the paths do not overlap, which is why a single webhook subscription never covers the set.
- Stale group membership. A document is indexed while a user sits in a group. Six weeks later the user leaves. If the store holds a flattened list of user identifiers captured at index time, that user keeps matching. The membership has to be expanded from the directory at query time, not baked into the row.
- Inherited permissions read as unique ones. Graph returns effective sharing permissions on a driveItem, and the list-permissions reference tells callers to separate inherited grants from direct ones by checking the
inheritedFromproperty. The permission resource page carries the carve-out that voids that advice on the platform this article is scoped to: "OneDrive for Business and SharePoint document libraries don't return the inheritedFrom property." The sample response that does show a populatedinheritedFromis a OneDrive Personal payload, carrying onedrive.live.com URLs. On a SharePoint or OneDrive for work or school tenant the property is absent on every row, so inheritance has to be inferred: diff the item's effective permission set against its parent folder's, or read it from SharePoint's own securable-object model, whereHasUniqueRoleAssignmentson the list item states directly whether the role assignments are unique or inherited from a parent. Two further traps sit in the same Graph documentation: the permissions relationship cannot be expanded as part of a driveItem get, so the indexer pays a separate call per item, and for a non-owner caller Graph returns only the sharing permissions that apply to the caller. An indexer running on delegated permissions therefore records a truncated access list that looks complete. - Labels that carry protection the folder does not. A Purview sensitivity label enforces nothing by virtue of its name. Enforcement comes from the encryption and usage rights the label applies, which Microsoft documents as separate configuration on the label and which travel with the file. A label without encryption is metadata; a label with encryption restricts who can open the content independently of the library it sits in. An indexer reading the label name rather than the protection settings gets this backwards in both directions.
- Removal that never lands. A file is deleted or a grant is withdrawn, and the store keeps answering from it. Graph publishes an expected latency for driveItem change notifications of under one minute on average and up to 60 minutes at maximum. Graph also ships lifecycle notifications whose stated purpose is to alert you when you are at risk of missing change notifications. Both facts say the same thing: the event stream is a latency optimisation, not a guarantee. Where the deletion was an erasure request under GDPR Article 17, the obligation is not discharged until the derived chunks and their embeddings are gone as well, which makes the reconciliation sweep below the evidence that it was.
Group membership deserves its own subscription. A directory change never touches the document, so a driveItem subscription will not fire for it. Graph exposes /groups/{id}/members as a separately subscribable resource, and the subscription lifetime table gives users, groups and other directory resources a maximum of 41,760 minutes, under 29 days, against 42,300 minutes for driveItem on OneDrive. Two different renewal clocks, two alarms.
| Failure mode | Detection path | What it costs when missed |
|---|---|---|
| Stale membership | Directory expansion at query time | Answers from documents the caller lost access to |
| Inherited read as unique | Diff the item's effective set against the parent's; inheritedFrom is not returned for SharePoint or OneDrive for Business | Whole folder trees indexed at the wrong scope |
| Label protection ignored | Label encryption and usage rights, not the name | Protected content surfaced to unprotected surfaces |
| Removal not applied | Reconciliation sweep against the source | Answers from a document already erased; an Article 17 erasure request left unfulfilled where the deletion was one |
The check below reads one item and resolves every principal on it. Run it against a file, then against the folder that contains it, and compare the two sets: on SharePoint and OneDrive for work or school that comparison is the only way to tell an inherited grant from a unique one.
# Effective sharing permissions for one driveItem, principals resolved.
# App-only is required: a delegated non-owner caller sees a truncated list
# containing only the permissions that apply to that caller.
Connect-MgGraph -ClientId $appId -TenantId $tenantId -CertificateThumbprint $thumbprint -NoWelcome
$driveId = '<drive-id>'
$itemId = '<item-id>'
Get-MgDriveItemPermission -DriveId $driveId -DriveItemId $itemId | ForEach-Object {
$perm = $_
# grantedToV2 carries the principal of a direct grant.
# grantedToIdentitiesV2 carries the principals of a 'specific people' link.
$identities = @($perm.GrantedToV2) + @($perm.GrantedToIdentitiesV2) | Where-Object { $_ }
if ($identities) {
foreach ($identity in $identities) {
[pscustomobject]@{
PrincipalId = $(if ($identity.Group) { $identity.Group.Id } else { $identity.User.Id })
PrincipalType = $(if ($identity.Group) { 'group' } else { 'user' })
Roles = $perm.Roles -join ','
LinkScope = $perm.Link.Scope
}
}
} else {
[pscustomobject]@{
PrincipalId = $null
PrincipalType = 'link'
Roles = $perm.Roles -join ','
LinkScope = $perm.Link.Scope
}
}
} | Sort-Object PrincipalType, PrincipalId | Format-Table -AutoSize
The app registration behind that bind needs the Files.Read.All application permission with admin consent, which the list-permissions reference names as the least privileged application permission for this call.
Expected output is one row per resolved principal. A grant made directly to a user or group produces one row from grantedToV2. A specific-people sharing link produces one row per identity in grantedToIdentitiesV2, and those identities are real directory principals your expansion can follow, which is why they belong in the permission set rather than in a bucket marked unresolvable. Only the rows that fall through to PrincipalType of link have no principal at all, and those are the anonymous and organisation-scoped links, which LinkScope names. Inheritance does not appear in this output, because the property that would carry it is not returned here.
Pre-filtering, and the arithmetic of over-fetching
Storing a hash of the access list is a common shortcut and it does not survive contact with the query path, because a hash cannot be matched against an expanded set of group identifiers. Store the identifiers, or a normalised permission-set identifier, in a column the index can filter on.
Then decide where the filter runs. The pgvector README is explicit that with approximate indexes, filtering is applied after the index is scanned, and gives the worked case: a condition matching 10 percent of rows, against the default hnsw.ef_search of 40, leaves roughly four rows. That is the whole problem in one sentence. Three ways out, all documented in the same README: raise ef_search, enable iterative index scans, added in pgvector 0.8.0 through hnsw.iterative_scan with strict_order or relaxed_order, or build a partial index when the filter takes only a few distinct values. Once iterative scans are on, hnsw.max_scan_tuples caps the visit at 20,000 tuples by default; the README notes that cap is approximate and does not affect the initial scan, so at a very low read share the budget can run out before k survivors are found.
-- principal_ids holds the expanded reader set for the chunk.
CREATE INDEX ON chunks USING gin (principal_ids);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
SET hnsw.ef_search = 200; -- default is 40
SET hnsw.iterative_scan = 'relaxed_order'; -- pgvector 0.8.0 and later
SELECT id, document_id, 1 - (embedding <=> $1) AS score
FROM chunks
WHERE principal_ids && $2::text[] -- caller's live user + group ids
AND deleted_at IS NULL
ORDER BY embedding <=> $1
LIMIT 40;
If the store cannot pre-filter, over-fetch and filter afterwards, and size the pool from the read share. Let p be the fraction of the corpus a caller may read and k the number of survivors the reranker needs. The candidate pool has to be about k divided by p. The assumption is that permission and relevance are independent, which is usually optimistic, because the documents most relevant to someone are often the ones their own team owns. Measure the real joint distribution before you trust the pool size.
Caching the stable prefix
Anthropic's prompt caching documentation prices cache writes at 1.25x base input tokens for the default five-minute time to live, 2x for the one-hour option, and cache reads at 0.1x. It also states that cache_control of type ephemeral is the five-minute variant, that a write happens only at a breakpoint, and that a read walks backward at most 20 blocks looking for an earlier write.
That pricing decides where the breakpoint goes. Retrieved context changes on every turn of a retrieval chat, so a breakpoint placed on the retrieved block is written and never read: full write premium, zero hits. Anthropic's own guidance is to place cache_control on the last block whose prefix is identical across requests, and it names this exact mistake. Put the answering rules and the tool definitions in front of the breakpoint, and the retrieved chunks behind it.
{
"model": "claude-sonnet-5",
"max_tokens": 2048,
"system": [
{ "type": "text",
"text": "Answering rules, citation format, refusal policy." },
{ "type": "text",
"text": "Tool and output schema definitions.",
"cache_control": { "type": "ephemeral" } }
],
"messages": [
{ "role": "user",
"content": "Retrieved chunks for THIS turn, then the question." }
]
}
One caveat from the same page: prompts below the per-model minimum are not cached at all, and no error is raised. The documented minimum is 1,024 tokens for Claude Sonnet 5 and 4,096 for Claude Haiku 4.5. Read cache_creation_input_tokens and cache_read_input_tokens off the response to confirm caching actually happened rather than assuming it did.
Results
Two results follow from the documented rates alone, and both are reproducible with a calculator.
Result 1: the second request pays for the write. For a prefix hit by R requests inside the time to live, relative input cost is 1.25 plus 0.1 times (R minus 1), against R without caching. Setting the two equal gives R of about 1.28, so a single re-read inside five minutes already clears the premium.
| Requests hitting the prefix inside the TTL | Relative input cost of that block | Change against not caching |
|---|---|---|
| 1 (written, never read) | 1.25 | 25 percent more expensive |
| 2 | 1.35 | 32.5 percent cheaper |
| 5 | 1.65 | 67 percent cheaper |
| 20 | 3.15 | 84 percent cheaper |
Result 2: post-filtering collapses at low read share. Applying the pgvector default of 40 candidates to a range of read shares gives the survivor counts below. The 10 percent row reproduces the example in the pgvector README.
| Share of corpus the caller may read | Survivors from a 40-candidate scan | Pool needed for 10 survivors |
|---|---|---|
| 50 percent | 20 | 20 |
| 20 percent | 8 | 50 |
| 10 percent | 4 | 100 |
| 2 percent | fewer than 1 | 500 |
Six figures are worth recording on every bench run. Each one names a specific way the system can be wrong:
- Refusal rate: the share of queries where the top reranked score fell under the threshold. Treat it as a first-class metric, because a reranker upgrade moves it without moving anything else you watch.
- Filter starvation rate: the share of queries where the permission filter left fewer candidates than the reranker was asked for.
- Cache write and read token counts, straight from the response fields, per surface.
- Reconciliation delta: documents present in the source and absent from the store, and the reverse, counted separately.
- Source-to-store lag at p50 and p95, compared against the 60-minute worst case Graph documents for driveItem notifications.
- Citation validation failures: answers citing a chunk identifier that does not exist.
Failures and unexpected results
Four things go wrong quietly enough that a dashboard will not show them.
A silent starvation looks like a quality problem. When the permission filter empties the candidate pool, the model receives thin context and answers anyway. The symptom presents as hallucination and gets treated with prompt engineering. The cause is the filter arithmetic above. Reach for ef_search, iterative scans or a partial index; a better instruction will not move it.
A cache breakpoint on a volatile block costs money without ever failing. The request succeeds, the answer is fine, and every call pays the 1.25x write premium with no read to amortise it. The only signal is the token counts on the response.
A fixed reranker threshold does not travel. Cohere's Rerank API reference documents relevance_score as normalized into the range 0 to 1, and warns that a score of 0.9 does not mean a document is twice as relevant as one scoring 0.45. Normalized is a weaker property than calibrated, and nothing in the documentation makes the score comparable across queries or corpora. Treat any absolute cut-off as a per-corpus constant, calibrate it against a labelled set, and recalibrate on every reranker version change. A threshold carried across an upgrade turns a system either mute or credulous, and both look like a model problem.
Indexed content is an injection surface. OWASP lists prompt injection as LLM01:2025 in its Top 10 for LLM applications and defines indirect injection as input arriving from external sources such as websites or files. A retrieval corpus is exactly that. OWASP's mitigations include segregating and identifying untrusted external content and enforcing least-privilege access, which for a retrieval system means the corpus is untrusted input even when the source system is trusted.
Limitations
This bench has not been run. It is a specification and the arithmetic that follows from the vendor documentation named above, and it publishes no observed outcome.
The permission analysis is specific to Microsoft Graph over SharePoint and OneDrive for work or school. Google Drive, Confluence and Box expose different inheritance semantics and different change feeds; the four failure modes generalise, the API details do not. The inheritance carve-out cuts the other way on OneDrive Personal, where inheritedFrom is returned and the parent-versus-child diff is unnecessary. The retrieval arithmetic assumes an approximate index that post-filters, which is what pgvector documents; a store with true pre-filtering has a different failure curve and the over-fetch table does not apply to it. The caching result depends on rate multipliers and per-model minimums that move on a months-long cycle, so recompute against the pricing page rather than this table. The independence assumption behind the survivor counts is the weakest link here: if relevance correlates with permission, and it usually does, the real survivor count is higher and the pool size smaller.
Nothing here addresses multi-hop questions spanning unrelated documents, or point-in-time questions over a corpus without versioned chunks. Both remain open, and neither is solved by tuning the pieces described above.
The retrieval product this bench was written for is DECKLOG, private retrieval over a permissioned corpus. The Graph application identity the indexer needs is a standing read grant across the document estate, so it belongs in the same review as every other privileged application, covered under Microsoft 365 tenant hardening.
Last verified 2026-07-28. Checked against the Anthropic prompt caching and models overview pages, the Microsoft Graph permission resource page and its driveItem list-permissions and change-notifications documentation, the SharePoint securable-object reference, the Microsoft Purview encryption documentation, the pgvector README, the Cohere Rerank API reference and OWASP LLM01:2025 on that date. Model identifiers, cache rate multipliers and per-model minimums change; recheck them before reusing the arithmetic.
Sources and further reading
- Anthropic prompt caching documentation (cache write and read rates, TTL, breakpoint placement)
- Anthropic Claude models overview (current API model identifiers and versioning)
- Microsoft Graph permission resource (grantedToV2, grantedToIdentitiesV2, and the inheritedFrom carve-out for SharePoint and OneDrive for Business)
- Microsoft Graph: list who has access to a file (driveItem permissions, least privileged permissions, non-owner truncation)
- Microsoft Graph change notifications overview (supported resources, subscription lifetimes, latency, lifecycle notifications)
- SharePoint SecurableObject.HasUniqueRoleAssignments (unique versus inherited role assignments)
- Microsoft Purview: apply encryption using sensitivity labels
- pgvector README: filtering, iterative index scans and HNSW search settings
- Cohere Rerank API reference (relevance_score normalization and score comparability)
- Cohere Rerank overview
- OWASP LLM01:2025 Prompt Injection, Top 10 for LLM Applications
- Regulation (EU) 2016/679 (GDPR), Article 17, right to erasure
Test the same boundary in your environment.
Use a focused diagnostic to compare the lab result with the controls and constraints in your own environment.
Choose a diagnostic