fix(platform): make the RAG retrieval gate fail closed - #3141
fix(platform): make the RAG retrieval gate fail closed#3141Israeltheminer wants to merge 1 commit into
Conversation
The mandatory re-check that decides which corpus refs a chat turn may read was serving refs it should refuse. Its own comment claimed the 0.4 posture while doing the opposite of it. A file row bound to nothing was ALLOWED to any org member. That shape is reachable: the indexer stamps corpus scope only from a bound document, so an unbound row carries no project and no team, which the SQL half then reads as an org-wide hub row. Both gates passed it. Four guards are restored, all of which 0.4 had: - a row bound to nothing is denied, and so is one bound to a conversation until that branch lands (#3121) - rag_status must be 'completed'; a row mid-reindex still has its previous chunks in the corpus - a trashed file row is denied, not only a trashed document - a document_id pointing at a deleted row no longer falls through to the hub branch, and a ref the document has since replaced is denied instead of answering from a superseded version The SQL half admits rows and fails open by design — core/knowledge/corpus.ts says so and names this function as the decider — so this is the gate that has to fail closed. Proven by a new integration check that needs no object storage, so it runs even where the S3-gated RAG loop skips.
|
Closing in favour of #3177. #3140 rewrote The one that survived is the important one — an unbound row is still admitted to the whole organization — and against the new structure it is a two-line change in a pure function that already has a test harness. #3177 does that. The |
The re-check that decides which corpus refs a chat turn may read was serving refs it should refuse. A file row bound to nothing was allowed to any member of the organization.
Why
Retrieval has two gates. The SQL half admits rows and fails open on purpose —
backend/core/knowledge/corpus.tssays so and namesfilterRetrievableRagFileIdsas the decider. That function had drifted to allow by default, under a comment claiming it was 0.4's posture:0.4's line for the same case is
continue— deny.The shape is reachable.
indexUploadedFilestamps corpus scope only from a bound document, so a row with no document carries no project and no team, andcorpus.tsreads that as an org-wide hub row. Both gates then passed it.Four guards 0.4 had were missing. Measured against a real Postgres, seven refs where only the first should pass:
origin/maindocument_idpointing at a deleted rowrag_status = 'running'Six of the seven denials were being served.
What changed
The SELECT reads four more columns:
fm.rag_status,fm.lifecycle_status,fm.conversation_id, and bothd.id IS NOT NULLandd.file_ref.document_idwhose row is gone no longer reads as a hub document. The LEFT JOIN's NULLs were falling into the hub branch.file_refon and leaves the previous corpus row behind, so this also closes the retrieval half of the superseded-document defect.The conversation case gets its own explicit branch rather than relying on the new default, so the next reader sees where the allow branch goes. That branch is #3121's, decided by
conversationAssignmentAllowsagainst the conversation's current assignment.Risk
The
rag_status = 'completed'requirement makes retrieval briefly deny a knowledge entry while a new version reindexes —knowledge_entries/service.tssetsrag_status = NULLand re-queues. That is the conservative direction and it is what 0.4 did. Every otherrag_statuswriter converges on the one completion write inknowledge/service.ts.Tests
A new
checkRetrievalGateinintegration-check.tspins the table above. It needs no object storage, so it runs on every invocation — unlike the RAG loop below it, which skips withoutITEST_S3_ENDPOINT. Placing it inside that function would have meant it never ran where the gate matters most.Mutation: against
origin/main's copy of the function, six of the seven assertions go red and the legitimate hub document still passes.Scope
Does not close #3121. Emailed attachments still are not indexed; this makes the gate safe for that work to land against, which is why it goes first.
The indexer-side half of the superseded defect is untouched — a replacement still leaves an orphaned corpus row, and a stale job still spends extract-and-embed cost on a blob nobody will read. This denies the read; purging the row is separate.
Gate:
typecheck,oxlint --type-aware,oxfmt --checkandlint:sastgreen.