Skip to content

harden(rag): validate retrieval RPC rows against a Zod shape contract - #1946

Merged
BigSimmo merged 6 commits into
mainfrom
claude/rag-zod-hardening-tranche1
Aug 14, 2026
Merged

harden(rag): validate retrieval RPC rows against a Zod shape contract#1946
BigSimmo merged 6 commits into
mainfrom
claude/rag-zod-hardening-tranche1

Conversation

@BigSimmo

Copy link
Copy Markdown
Owner

Summary

Ledger #212 tranche 1 — replace the unsafe casts on the highest-consequence file, src/lib/rag/rag.ts, with runtime shape validation.

rag.ts asserted the rows returned by the retrieval RPCs straight into the ranking pipeline with a bare as SearchResult[]. That cast is a compile-time fiction: the rows are untrusted external data, the RPCs are versioned (*_v2 with a legacy fallback), and ledger #316 records ten retrieval RPC bodies on the live database diverging from the migrations in this repo, with weekly live-drift red since 2026-07-26. Under that cast a renamed column or a numeric returned as a string does not fail — it misranks or mis-cites silently, which is the worst failure mode for a clinical reference surface.

RAG impact: no retrieval behaviour change — type-validation hardening only, no ranking/ordering/scoring logic touched.

  • New src/lib/rag/rag-row-contracts.tsassertRetrievalRows, a TypeScript assertion function backed by a Zod schema, plus a named RetrievalRowShapeError. The schema is deliberately asymmetric: strict on what ranking actually reads (id, document_id, content, and the four score fields), loose about everything else via z.looseObject, because column sets genuinely differ between RPC versions — retrieval_synopsis is absent from the older base hybrid function, and document_labels / document_summary appear only on match_document_chunks_v2. Rejecting or stripping an unlisted column would turn a harmless schema difference into an outage or silent data loss. id, document_id and content are not null in supabase/schema.sql, so requiring them cannot reject a row that works today; the score fields are .nullish() so an absent or null score still flows through the existing downstream ?? 0 handling, while a string where a number belongs is caught.
  • Four call sites in src/lib/rag/rag.ts — the hybrid layer's telemetry and merge, the vector-fallback result set, and the document-summary context. Each replaces a cast with a preceding assertion; no surrounding expression changes. telemetry.vector_candidate_count = hybridRows.length is exactly the previous hybridData?.length ?? 0.
  • New tests/rag-retrieval-row-contract.test.ts — 8 offline cases covering the accept path, unknown-column preservation, a string score, a missing chunk identity, absent/null scores, the issue cap, non-array payloads, and a case asserting the error text leaks no row content.

The assertion asserts rather than transforms. On success the caller keeps the original array and the original row objects, so object identity, key order, and the nested images / source_metadata references reaching the ranking pipeline are unchanged from what the RPC returned. That property is why this is safe to land on a live-validated protected surface: validation is observable only when the data is already wrong.

Errors carry Zod issue paths and codes only, never a row value — retrieval rows contain clinical document text, and echoing one into a log or an error response would leak source content past the boundary query-privacy.ts maintains. The assertion logs before throwing, so drift stays visible even where a caller degrades (the vector fallback's existing .catch falls back to lexical results when it has them, which would otherwise swallow the signal).

Deliberately not changed, because they are outbound serialization rather than an inbound runtime shape and a Zod schema does not apply:

  • rag.tsquery_embedding: embedding as unknown as string (two sites) — a pgvector argument where the generated Supabase types want string for a number[].
  • src/app/api/search/route.tsas unknown as Json (two sites) — telemetry inserts built from local values; validating inside a fire-and-forget write would lose rows.

Audited and already hardened, so untouched: all three immediate API-route callers (api/answer, api/answer/stream, api/search) already validate request bodies with parseJsonBody(request, <zodSchema>), and parseAnswerJson already wraps answerJsonSchema.parse(JSON.parse(raw)) in a try/catch that degrades to safeFallbackAnswer.

src/lib/rag/rag-candidate-sources.ts carries the same class of cast at its own RPC boundaries and is left for tranche 2 rather than widening this PR.

Verification

  • npm run verify:pr-local — passed every step except one pre-existing failure unrelated to this diff (below): check:runtime, check:installed-lock-parity, format:changed, lint, typecheck, test, build, eval:rag:offline, check:medication-interactions.
  • npm run testTest Files 602 passed (602), Tests 6517 passed | 4 skipped (6521). This includes tests/rag-imputation-contract.test.ts, the repo's own source-text pins on the score-imputation formulas and the release comparator key order; those stayed green, which is the direct evidence that no ranking formula or comparator order moved.
  • npm run typecheck — exit 0. The asserts rows is SearchResult[] narrowing is the load-bearing type change, and it is what allows all four as SearchResult[] casts to be deleted rather than merely guarded.
  • npm run eval:rag:offline (run inside verify:pr-local) — Offline RAG fixture and manifest validation passed (36 golden cases, 23 suites), then Test Files 23 passed (23), Tests 579 passed (579).
  • npx vitest run tests/rag-retrieval-row-contract.test.tsTest Files 1 passed (1), Tests 8 passed (8). npm run test:focused correctly refuses this file, since focused selection fails closed when test infrastructure changes.

Verification not run: npm run eval:retrieval:quality, npm run eval:rag, npm run eval:quality, npm run verify:release, npm run check:supabase-project — all provider-backed and require explicit approval, which was not given. docs/rag-behaviour/ requires a live eval-canary pair for a retrieval/ranking/ordering behaviour change; this PR deletes casts and adds an assertion without touching ranking, selection, scoring, or comparator logic, so no canary pair is required.

UI verification not run: no UI, routing, styling, reduced-motion, or forced-colors change — the diff is two library files and one unit test.

Pre-existing failure, not from this diff: npm run check:medication-lexicon-report reports docs/medication-interaction-lexicon-review.md is stale. That report is generated from src/lib/medication-interaction-lexicon, the medication snapshot, and the medication interaction index. git diff origin/main for the report and for every one of those inputs is empty on this branch, so the staleness is already on main and is unrelated to this change. Left for whoever owns that surface rather than folded into a RAG PR.

Risk and rollout

  • Risk: Low, and concentrated in one place — a retrieval RPC whose live shape is already wrong now raises RetrievalRowShapeError where it previously returned quietly-misranked results. That is the intended trade and was chosen explicitly over dropping invalid rows. Given #316 reports live RPC divergence, this is the change most likely to surface something. It cannot reject data that works today: the three required fields are not null in the schema, the score fields accept absent and null, and z.looseObject preserves every column the schema does not name. The hybrid path's existing hybrid-error → vector-fallback route and the vector fallback's existing .catch degrade-to-lexical route are both unchanged, so two of the three call sites already sit behind a conservative fallback.
  • Rollback: Single-commit revert. No migration, no schema change, no data change, no configuration, no environment variable, no feature flag.
  • Provider or production effects: None. No live Supabase, OpenAI, hosted CI, or deployment call was made; every gate run was local and offline.

Clinical Governance Preflight

  • Source-backed claims still require linked source verification before clinical use
  • No patient-identifiable document workflow was introduced or expanded without explicit governance approval
  • Supabase target remains Clinical KB Database (sjrfecxgysukkwxsowpy)
  • Service-role keys and private document access remain server-only
  • Demo/synthetic content remains clearly separated from real clinical sources
  • Source metadata, review status, and outdated/unknown-source behavior remain conservative
  • Deployment classification/TGA SaMD impact was checked when clinical decision-support behavior changed

The change adds validation in front of the ranking pipeline and alters no clinical behaviour. Citation and source-verification requirements are untouched; no document-access, owner-scope, or privacy path is modified; no Supabase target, credential handling, or demo/live separation changes; source metadata and outdated-source handling are untouched. On the last item: no clinical decision-support behaviour changed, so the classification is unaffected — the answer path returns the same answers from the same sources in the same order, and the only new outcome is a loud failure on a shape that would previously have produced a quietly wrong one, which is a move toward conservative degradation, not away from it.

Notes

Flagged to the user before any edit, per AGENTS.md "RAG ranking protection", since the diff touches src/lib/rag/** even though it is type-safety hardening rather than a ranking change.

The throw-on-mismatch posture was confirmed with the user rather than assumed, against the two alternatives of dropping invalid rows with a log, or throwing behind an environment kill-switch.


🤖 Generated with Claude Code

https://claude.ai/code/session_013xVTc5bPihwqTfnr8yFwWq


Generated by Claude Code

Ledger #212 tranche 1. `rag.ts` asserted rows returned by the retrieval RPCs
straight into the ranking pipeline with a bare `as SearchResult[]`, so a
renamed column or a numeric returned as a string did not fail — it misranked
or mis-cited silently. That is not hypothetical: ledger #316 records ten
retrieval RPC bodies on the live database diverging from the migrations in
this repo, with weekly live-drift red since 2026-07-26.
Add `assertRetrievalRows`, a TypeScript assertion backed by a Zod schema that
is strict on what ranking reads (`id`, `document_id`, `content` and the four
score fields) and loose about everything else, since column sets genuinely
differ between RPC versions. It asserts rather than transforms, so a valid row
reaches ranking by the same reference with its key order and nested `images` /
`source_metadata` untouched. Errors carry Zod issue paths only, never a row
value, because retrieval rows contain clinical document text.
Applied at the four unchecked casts: the hybrid layer's telemetry and merge,
the vector-fallback result set, and the document-summary context.
No ranking, ordering, scoring, comparator or selection logic is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xVTc5bPihwqTfnr8yFwWq
@supabase

supabaseBot commented Aug 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project sjrfecxgysukkwxsowpy because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in:35 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5e4aaafc-0a4c-413c-afa1-c3ffaf8defee

📥 Commits

Reviewing files that changed from the base of the PR and between 372cb13 and 64ada9a.

📒 Files selected for processing (5)
  • docs/branch-review-records/3d45d96c0c8e97d835c1a3f6c12b320255c0164dc545dce172e67703b2be6fda.record.md
  • docs/branch-review-records/bb4cf300c0917336cbfb205dc3631242f4e32aab35d3d3bfe1d0654c41f7bf34.record.md
  • src/lib/rag/rag-row-contracts.ts
  • src/lib/rag/rag.ts
  • tests/rag-retrieval-row-contract.test.ts

Comment @coderabbitai help to get the list of available commands.

Immutable review record travelling with its owning product PR, per AGENTS.md
— review records use independent record files rather than a dedicated
ledger-only branch or an edit to the frozen historical table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xVTc5bPihwqTfnr8yFwWq
@github-actions

github-actionsBot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

CI triage

CI failed on this PR. Automated classification of the 2 failed job(s):

  • Static PR checksneeds investigation: inspect the failing step and uploaded diagnostics; rerun only after classifying the cause.
  • PR requiredneeds investigation: inspect the failing step and uploaded diagnostics; rerun only after classifying the cause.

Compared with main CI run #10820 (cancelled).

Classification is evidence routing, not permission to ignore a failure. Exact quarantined Playwright identities remain governed by the flake ledger.

@BigSimmo
BigSimmo enabled auto-merge August 14, 2026 12:39
@BigSimmo
BigSimmo disabled auto-merge August 14, 2026 12:46
@BigSimmo
BigSimmo enabled auto-merge (squash) August 14, 2026 13:14
@BigSimmo
BigSimmo merged commit 0011a05 into mainAug 14, 2026
24 checks passed
@BigSimmo
BigSimmo deleted the claude/rag-zod-hardening-tranche1 branch August 14, 2026 14:30
@BigSimmoChatGPT Codex Connector

Copy link
Copy Markdown
OwnerAuthor

Final review summary

Final PR head: 64ada9aefaf9665149dbbf47ac63a7f6ae5b94bc; it was current with base 372cb13fb2f530eab259e72cdff41e6aa31dabbb and merged externally as 0011a058fd1d68e7fb98f3411d505b71bee104cf. The published merge tree matched the verified local tree and was clean.

Fixed:

  • PR-introduced maintainability blocker: src/lib/rag/rag.ts had grown to 4,370 lines against its 4,362-line no-growth budget. Summary-row construction now lives in the dedicated retrieval-row contract module; the guard passes at 4,361 lines.
  • The contract now also validates required provenance and visual fields (title/file, page/chunk/section metadata, image IDs, source metadata, and image shape), with regression coverage for malformed rows. Ranking/order/scoring logic was not changed.

Review and threads:

  • Separate manual adversarial pass completed; no independent reviewer was available in this run.
  • No review threads were present or actionable.
  • Immutable review-ledger record added.

Decisive local checks:

  • Passed: diff check, Prettier for changed files, maintainability budget, branch-review ledger and write-discipline checks, docs links/scripts/inventory/index checks.
  • npm run test could not run locally because this isolated worktree lacked vitest; the exact-head hosted unit-coverage gate passed.

Exact-head CI:

  • Required PR required, Static PR checks, Unit coverage, Build, Safety/config, Change scope, SAST, and Secret Scan: passed.
  • CI-managed Lighthouse budget: passed.
  • UI, visual-baseline, migration, container, and release-browser advisory/not-applicable jobs were skipped.

Blockers/residual risk: none. The PR merged externally; I did not merge it.

Sign up for freeto 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.

2 participants

@BigSimmo@claude