feat(rag): audit document ingestion, deletion, and chunk purges - #577
feat(rag): audit document ingestion, deletion, and chunk purges#577hamza-56 wants to merge 2 commits into
Conversation
RAG content entered and left the retrieval corpus with no record of who did it, when, or whether it succeeded (issue #502, epic #435) — a gap under EU AI Act Art. 12 provenance obligations. Three new mutation event types close it: - rag.document_ingested: recorded by ingest_document for every attempt; success joins the chunk-store transaction (fail-closed, so content that cannot be recorded cannot enter the corpus), while ineligible or unextractable files record a failure in its own transaction before the error propagates. - rag.document_deleted: recorded by soft_delete_document in the caller's transaction, attributing corpus removal to the acting user. - rag.chunks_purged: a system-actor event per cleanup run, committed atomically with the hard delete, evidencing that removal happened. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@claude-review |
|
Claude finished @hamza-56's task in 3m 19s —— View job Claude Code Review
Solid, well-scoped PR that closes a real provenance gap (#502). The event-type taxonomy is clean, the transaction semantics are thought through (success joins the chunk-store transaction fail-closed; soft-delete rides the caller's transaction; the purge commits atomically), and the tests are readable and cover the happy paths plus the interesting edge cases. Three things to address, none of them large. 🟠 Medium1. Cleanup re-records 2. "Every attempt is audited" isn't quite true — 🟡 Low3. Docstring formatting — 4. Test coverage for the no-op-with-deleted-docs case — 👍 Nits / notes (non-blocking)
AssessmentApprove-with-changes. Item 1 is the one I'd want fixed before merge (it actively pollutes the audit trail); item 2 is a correctness/claim mismatch that should be reconciled; 3 and 4 are quick polish. No security issues found; no migration needed as stated. |
| except (UnsupportedFileTypeError, ScannedPDFError) as exc: | ||
| logger.warning("Ingestion of '%s' (document_id=%d) failed: %s", filename, document_id, exc) | ||
| await record_event_now(_ingested_event(document_id, AuditOutcome.FAILURE, error_detail=scrub_error_detail(exc))) | ||
| raise |
There was a problem hiding this comment.
Medium — "every attempt is audited" doesn't hold for unexpected failures.
The docstring (lines 60–64) states every attempt is audited, but a failure event is only recorded for UnsupportedFileTypeError and ScannedPDFError. Anything else that can raise between here and the commit — extract_to_markdown hitting a corrupt/parse error, DocumentChunker().chunk raising, or a SQLAlchemyError from store_and_link_chunks — propagates with no audit record. For a provenance control (EU AI Act Art. 12), a failed ingestion that leaves no trace is exactly the gap the PR set out to close.
I understand the repo rule forbids swallowing via bare except Exception, so two acceptable options:
- Narrow the claim — change the docstring to say only eligibility/extraction failures of these known types are recorded as
failure, so the guarantee matches the code. - Broaden coverage while staying fail-closed — record the failure event for a wider (still explicit) set and re-raise, so unexpected errors are on record too.
Either is fine, but the docstring and the code should agree. Right now they don't.
There was a problem hiding this comment.
Reconciled by narrowing the claim (option 1) in db90834. The docstring now states that a failure event is recorded only for the two declared eligibility/extraction errors, and that anything else (extractor parse error, chunker error, database error from the chunk write) propagates without a record — noting that nothing entered the corpus in those cases, so the trail stays accurate about the corpus itself but is not a complete log of attempts.
| """Soft-delete a Document by id. Does not commit. | ||
|
|
||
| Records a ``rag.document_deleted`` audit event in the caller's transaction | ||
| , so the deletion and its record commit or roll back together. | ||
| A missing id is a no-op and records nothing. | ||
| """ |
There was a problem hiding this comment.
Low — docstring formatting. The leading spaces and the line that begins with , so ... read as a mis-wrapped sentence:
"""Soft-delete a Document by id. Does not commit.
Records a ``rag.document_deleted`` audit event in the caller's transaction,
so the deletion and its record commit or roll back together. A missing id
is a no-op and records nothing.
"""Review follow-up on #577. Cleanup never hard-deletes the soft-deleted Document rows it processes, so those rows matched on every subsequent run and each run recorded a rag.chunks_purged event with purged_chunk_count 0 and the same document_ids, indefinitely. The record (and the link delete) is now guarded on there having been links to remove this run, so the trail carries one entry per real purge. Also reconciles the ingest_document docstring with the code: only the two declared eligibility/extraction errors produce a failure event, so the docstring no longer claims every attempt is audited, and fixes the mis-wrapped soft_delete_document docstring.
|
All four addressed in db90834.
|
Closes: RAG document ingestion and deletion leave no audit record
What
Records audit events for every RAG corpus mutation (ingestion attempts, document deletions, and cleanup chunk purges), so the provenance of retrieval content is reconstructable and attributable.
Changes
rag.document_ingested,rag.document_deleted, andrag.chunks_purgedmutation event types and expose them throughsparkth.lib.audit.eventsingest_documentattempt (success joins the chunk-store transaction fail-closed; ineligible/unextractable files record a failure in their own transaction before the error propagates)soft_delete_documentHow to Test
uv run pytest tests/rag/test_ingestion_audit.py tests/rag/test_cleanup.py tests/core/test_documents.pymake backend.up.dev, ingest a document through the Google Drive plugin, then delete it and run the cleanup jobmake db-shell:SELECT category, action, outcome, actor_type, target_id, new_values, old_values FROM audit_events WHERE category = 'rag';shows the ingestion, deletion, and purge recordsNotes
No migration needed (the
audit_eventstable already exists). Actor and source attribution come from the ambient audit context, so REST/MCP-originated ingestions are attributed to the acting user automatically; the cleanup job stamps asystemactor labeledrag-cleanup.This PR description was written with the assistance of an LLM (Claude).