Skip to content

fix(platform): make the RAG retrieval gate fail closed - #3141

Closed
Israeltheminer wants to merge 1 commit into
mainfrom
fix/rag-retrieval-gate-fail-closed
Closed

fix(platform): make the RAG retrieval gate fail closed#3141
Israeltheminer wants to merge 1 commit into
mainfrom
fix/rag-retrieval-gate-fail-closed

Conversation

@Israeltheminer

Copy link
Copy Markdown
Collaborator

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.ts says so and names filterRetrievableRagFileIds as the decider. That function had drifted to allow by default, under a comment claiming it was 0.4's posture:

// Legacy/unbound: same-org fallback (the 0.4 posture).
retrievable.push(ref);

0.4's line for the same case is continue — deny.

The shape is reachable. indexUploadedFile stamps corpus scope only from a bound document, so a row with no document carries no project and no team, and corpus.ts reads 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:

Ref origin/main This branch
the document's current file allowed allowed
a ref the document has since replaced allowed denied
document_id pointing at a deleted row allowed denied
bound to nothing allowed denied
bound to a conversation allowed denied
rag_status = 'running' allowed denied
the file row trashed allowed denied

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 both d.id IS NOT NULL and d.file_ref.

  • Indexing must have finished. A row mid-reindex still has its previous chunks in the corpus, and answering from them serves content the caller's current scope was never checked against.
  • A trashed file row is denied, not only a trashed document.
  • A document_id whose row is gone no longer reads as a hub document. The LEFT JOIN's NULLs were falling into the hub branch.
  • A ref the document has since replaced is denied. A replacement upload moves file_ref on and leaves the previous corpus row behind, so this also closes the retrieval half of the superseded-document defect.
  • Bound to nothing, or bound to a conversation, is denied.

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 conversationAssignmentAllows against 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.ts sets rag_status = NULL and re-queues. That is the conservative direction and it is what 0.4 did. Every other rag_status writer converges on the one completion write in knowledge/service.ts.

Tests

A new checkRetrievalGate in integration-check.ts pins the table above. It needs no object storage, so it runs on every invocation — unlike the RAG loop below it, which skips without ITEST_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 --check and lint:sast green.

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.
@Israeltheminer

Copy link
Copy Markdown
Collaborator Author

Closing in favour of #3177.

#3140 rewrote filterRetrievableRagFileIds into a pure decideRetrievable in a new retrievable.ts, which incidentally fixed three of this branch's four findings: superseded refs (it now queries documents BY file_ref), trashed file rows, and the deleted-document-behind-a-live-id case. So this 130-line SQL change is obsolete.

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 rag_status guard is deliberately dropped there, with the reasoning stated: the superseded case that motivated it is already handled, and the remaining window is stale own-content rather than a leak.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: emailed attachments are not indexed or retrievable on the Postgres backend

1 participant