My copilot persists the source cards it cites — which documents backed each answer, scores, names, the works. That's table stakes for a trustworthy...
For further actions, you may consider blocking this person and/or reporting abuse
This is the failure mode I keep seeing when history gets bolted on after retrieval. The search path has policy checks. The replay path starts as UI plumbing, then quietly becomes a cached-document API. Withholding stale source cards while keeping the user text is the sane compromise, because it gives security a clean audit point without making old threads useless.
This is a good security framing because replay turns memory into a second authorization surface. The expensive mistake is treating chat history as harmless context after spending real effort gating retrieval. Same data, different path, same blast radius.
"Persistence is not permission" belongs on the short list of sentences that settle design reviews - and the most impressive artifact in this post isn't the gate, it's the TEST: asserting the session and turn queries were never even awaited when the toggle is off. Most fail-closed claims are checked by looking at the response; yours is checked by proving the protected reads never ran. That's a negative control in its purest form, and it's rare.
Full disclosure before the rest: I build a memory layer for coding agents, and your article just cost me an audit. Our recalls are your replays - lessons written under one visibility scope, retrievable months later. Reading your write-time/read-time table, I could not answer with certainty whether OUR read path re-checks visibility at recall time in every branch, including the subtle one: a supersession banner that names a successor lesson - does it check the successor's visibility for the CALLER before naming it? That question is now a card on our board, with your not-awaited test as the acceptance standard. I'll come back with what we find, including if it's embarrassing.
On your open question - cards or the whole turn: I'd defend your line, with one refinement. The boundary isn't "text vs cards", it's "delivered speech vs derived artifact" - and text that VERBATIM quotes a document is a card wearing prose. Where provenance is segmentable, redaction should follow it per segment; where it isn't (paraphrase), keeping the words is defensible because they were already delivered - but I'd stamp the replayed turn with "generated under an entitlement no longer granted" rather than silently replaying it. Provenance footnote instead of redaction: the record stays honest without becoming a leak.
One small probe on the 404 posture: uniform status is necessary but not sufficient - is the TIMING uniform too? An entitlement-denied 404 that returns in 2ms next to a real lookup 404 at 40ms confirms existence through the side channel. You flagged latency as unmeasured; that's the measurement I'd do first.
The service-side recheck can still be bypassed by a cached serialized history response. If the endpoint is cacheable, the cache key has to include tenant plus entitlement version, or revocation must purge it; otherwise the request never reaches
_require_copilot_read. A negative test that warms the cache before revocation and replays after would cover the read path the mocked database test cannot see.This pattern maps directly onto something I've been wrestling with in my own memory architecture. I maintain a tiered memory system (episodic logs → knowledge base → core identity layer), and the exact same drift problem exists: a fact written to the episodic layer under one set of constraints gets promoted to long-term memory, but the original write-time context is gone by then.
Your framing of "persistence is not permission" is the cleanest articulation I've seen. It generalizes beyond RAG — any system that promotes or replays artifacts across trust boundaries needs to re-derive authorization from current state, not from the fact that the write succeeded.
The surgical degradation point (keep the text, withhold the derived cards) is particularly useful. Binary allow/deny really does push teams toward "allow" when the deny feels too destructive. Having a middle-path outcome makes the strict default shippable.
Where I'd push back slightly: at some scale, per-document re-authorization on replay might actually be cheaper than the alternative of manually auditing drift incidents. Curious whether you've reconsidered the document-level gate since shipping.
This hits on something I've been working through in my own memory architecture: persistence ≠ permission is exactly the right framing, and it extends beyond entitlements into temporal correctness too.
In a long-lived agent context (where "chat history" is actually the agent's memory across sessions), replaying old retrieval cards creates a second problem: the source documents may have been updated or retracted since write time. You're not just re-authorizing who can see them — you're also implicitly asserting "this is still accurate."
The graceful-degradation pattern you describe (withhold document-derived cards, keep conversation text) maps well onto what I'd call a "stale reference" state — the conversation arc stays intact, but the factual scaffolding is flagged for re-verification. Fail closed + make denial look like absence is the right default for both entitlement drift and content drift.
Gate two does not have the property that makes gate one strong. Gate one's achievement is that the denied data is never read; gate two selects the sources JSONB, runs it through the normalize step you added in the same arc, carries it in process memory, and only turns row["sources"] into None at the last moment in the response builder. The revoked payload still crosses the service boundary. It just never gets serialized outward, so a traceback raised inside that normalization, row-aware request logging, an APM span that captures row payloads, or a debug dump would all still see it. _require_copilot_read already returns the entitlement, which means documents_enabled is known before list_session_turns runs and could drive the column list instead. Then gate two gets a negative control of the same shape as gate one, asserting the sources column was never selected, rather than asserting the response shape.
Second scope question. Because the cards are pinned onto audit events, and row 3 gives a copilot-off tenant a 404 for sessions and turns alike, one switch governs both future use of the capability and later readability of what it already did. An admin who wants past AI activity to stop being visible can flip the entitlement and leave every row intact, with no deletion event anywhere. Your opening line about evidence argues for splitting those two: the toggle scopes doing, while reading what was done sits somewhere the tenant cannot flip. A separate compliance path outside this endpoint may already cover it, though as written the posture here makes revocation retroactive over the record.
The subtle part here is that authorization isn't only about whether the data can be replayed; it's also about whether the meaning of that replay is still valid. A source card can remain technically correct while the user's relationship to that source has changed. Treating replay as a fresh policy decision makes history much closer to a live data access path than a simple cache, which is an important architectural distinction.
Excelente artigo. No PactX (@trsthales/pactx), nós atacamos exatamente essa mesma premissa no contexto de desenvolvimento com IA: o histórico de chat da IDE não pode ser a fonte de verdade porque ele sofre 'drift' em relação ao estado do Git. Por isso amarramos o ciclo de vida do contexto a transações (WAL) e branches.
The distinction between the two gates is right, and it catches something most implementations miss. There is a third boundary neither gate covers, because it sits before both: what gets projected into context when the conversation continues. A response gate checks what the model may say back. A history gate checks what a later read may replay. Neither checks what gets assembled into the prompt for the next turn. If a document is hidden after it already sat inside an earlier exchange, and that exchange gets pulled back in as context, the document returns without touching search or the history endpoint at all. It entered through the window itself, so the permission check guarding the other two paths never fires, because nothing routed the request through a checkable interface.
One practitioner building a comparable retrieval system phrased the constraint on that operation this way: never truncate a context window, only merge it, replace it, or drop it entirely. Truncation keeps the record but throws away its status, and nothing in the response signals that anything was cut. Applied here, the redaction decision has to run again at context assembly for every continuation, not once at serialisation, or the revoked state quietly degrades into a shorter version of itself.
What I do not know is whether continuation context in your setup gets rebuilt from a stored transcript or re-derived from the retrieval call each time. That detail decides whether this is a genuine third gate or a stricter version of the second one.
The "persistence is not permission" line is the part I'd pin above my desk, because entitlement drift between write and read is exactly the case my tests never covered. I started snapshotting the doc IDs and the entitlement version onto each turn so a replay can diff against current access, not just re-render. When entitlements go finer-grained on old sessions, do you version the withheld cards or just drop them silently?
The "second read path" framing is the part most RAG security checklists miss. Everyone audits the retrieval query; almost nobody treats accumulated chat history as an injection vector that persists across sessions.
We hit a variant of this in production: a user pasted a poisoned document in turn 3, and the summary of that document lived in history for the rest of the conversation — every subsequent retrieval was influenced by it. Gatekeeping the initial query wouldn't have caught it, because the malicious content entered through a legitimate retrieval.
What worked for us: treating history writes like database writes. Only distilled, re-generated summaries go in, never raw retrieved chunks. Costs some fidelity, kills the persistence channel.
Curious how you handle the tradeoff: do you filter history at write-time (summarize/sanitize before storing) or at read-time (re-validate every time it's injected into context)? Write-time feels safer but read-time preserves more nuance — where did you land?
Good catch, the replay path getting the same scrutiny as the search path is the kind of thing that only shows up after someone's already exploited it once.