From 4cc9e1f5ae930a4102e6f01d43bdd2811f3e696a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 06:34:24 +0000 Subject: [PATCH 01/12] harden(rag): validate signal rows from the candidate-source RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ledger #212 tranche 2, continuing the row contract from PR #1946 into `rag-candidate-sources.ts`, the sibling module deliberately left out of that PR. Three unchecked casts of RPC results are replaced with validated assertions: - `match_document_chunks_text` rows were cast straight to `SearchResult[]`. Both the `_v2` wrapper and the legacy function return every field the existing contract requires, so `assertRetrievalRows` applies unchanged. - Embedding-field and index-unit rows are NOT `SearchResult`s — they carry a chunk id plus scores, and `loadChunksForSignalMatches` then loads the real chunk. A wrong `source_chunk_id` loads the wrong evidence, and the mapping coerces scores with `Number(row.similarity ?? 0)`, which turns a stringified score into a silently different number rather than an error. Each gets its own schema. Every pinned field is backed by a constraint in `supabase/schema.sql`. The `extraction_mode` enum and the `unit_type`/`title`/`content` requirements mirror the `not null` and `check` constraints on `public.document_index_units`, so the schema cannot reject a row the database would accept. Collection columns stay `.nullish()` to match the `?? []` / `?? null` handling the callers already apply. Also corrects the tranche 1 doc comment, which claimed every required field is `not null` in the schema. That is true for all of them except `source_metadata`: `documents.metadata` is bare `jsonb` and permits arrays and scalars, so that pin is guaranteed by the data, not by a constraint. Verified against the live project on 2026-08-15 — all 2851 documents are object-typed — and queued as its own ledger item. No ranking, ordering, scoring, comparator or selection logic is touched. The score imputation formulas in this file are untouched and their source pins stay green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013xVTc5bPihwqTfnr8yFwWq --- src/lib/rag/rag-candidate-sources.ts | 47 +++------ src/lib/rag/rag-row-contracts.ts | 79 ++++++++++++++- tests/rag-retrieval-row-contract.test.ts | 122 ++++++++++++++++++++++- 3 files changed, 212 insertions(+), 36 deletions(-) diff --git a/src/lib/rag/rag-candidate-sources.ts b/src/lib/rag/rag-candidate-sources.ts index 3d5d04b294..95ae78d599 100644 --- a/src/lib/rag/rag-candidate-sources.ts +++ b/src/lib/rag/rag-candidate-sources.ts @@ -26,6 +26,13 @@ import { import { applyMemoryCardBoosts, fetchMemoryCardsForQuery } from "@/lib/deep-memory"; import { env } from "@/lib/env"; import { logger } from "@/lib/logger"; +import { + assertEmbeddingFieldRows, + assertIndexUnitRows, + assertRetrievalRows, + type EmbeddingFieldSignalRow, + type IndexUnitSignalRow, +} from "@/lib/rag/rag-row-contracts"; import { firstVariantPoolIsStrong, maxTextRpcQueryVariants, @@ -211,7 +218,9 @@ export async function searchTextChunkCandidates(args: { // most-terminal lexical layer surfaces in hybrid_rpc_errors telemetry // instead of silently degrading to zero candidates. Return value unchanged. if (error) recordHybridRpcError(args.telemetry, "match_document_chunks_text", error); - return error || !data?.length ? ([] as SearchResult[]) : (data as SearchResult[]); + if (error || !data?.length) return [] as SearchResult[]; + assertRetrievalRows(data, "match_document_chunks_text"); + return data; }; const variants = args.queryVariants.slice(0, maxTextRpcQueryVariants); @@ -338,14 +347,6 @@ export type ChunkSignalMatch = { indexUnit?: DocumentIndexUnitMatch | null; }; -type IndexUnitRpcRow = DocumentIndexUnitMatch & { - document_id: string; - source_chunk_id: string | null; - similarity?: number | null; - text_rank?: number | null; - hybrid_score?: number | null; -}; - type TableFactRpcRow = { id: string; document_id: string; @@ -1058,26 +1059,9 @@ export async function searchEmbeddingFieldCandidates(args: { ); if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; - const matches = ( - data as Array<{ - source_chunk_id: string | null; - field_type: string | null; - similarity?: number | null; - text_rank?: number | null; - hybrid_score?: number | null; - }> - ) - .filter( - ( - row, - ): row is { - source_chunk_id: string; - field_type: string | null; - similarity?: number | null; - text_rank?: number | null; - hybrid_score?: number | null; - } => Boolean(row.source_chunk_id), - ) + assertEmbeddingFieldRows(data, "match_document_embedding_fields_hybrid"); + const matches = data + .filter((row): row is EmbeddingFieldSignalRow & { source_chunk_id: string } => Boolean(row.source_chunk_id)) .map((row) => ({ chunkId: row.source_chunk_id, similarity: Number(row.similarity ?? 0), @@ -1125,8 +1109,9 @@ export async function searchIndexUnitCandidates(args: { ); if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; - const matches = (data as IndexUnitRpcRow[]) - .filter((row): row is IndexUnitRpcRow & { source_chunk_id: string } => Boolean(row.source_chunk_id)) + assertIndexUnitRows(data, "match_document_index_units_hybrid"); + const matches = data + .filter((row): row is IndexUnitSignalRow & { source_chunk_id: string } => Boolean(row.source_chunk_id)) .map((row) => ({ chunkId: row.source_chunk_id, similarity: Number(row.similarity ?? 0), diff --git a/src/lib/rag/rag-row-contracts.ts b/src/lib/rag/rag-row-contracts.ts index 566ce5cf53..9dbfbef687 100644 --- a/src/lib/rag/rag-row-contracts.ts +++ b/src/lib/rag/rag-row-contracts.ts @@ -17,9 +17,13 @@ import type { SearchResult } from "@/lib/types"; * * The schema is deliberately asymmetric: * - * - **Strict on the ranking, citation, and evidence fields.** The required chunk identity, - * provenance, and visual fields are `not null` in `supabase/schema.sql`, so requiring them - * cannot reject a row that works today. The four score fields are `.nullish()` — absent or + * - **Strict on the ranking, citation, and evidence fields.** Every required field except + * `source_metadata` is `not null` in `supabase/schema.sql`, so requiring it cannot reject a + * row that works today. `source_metadata` is the exception: `documents.metadata` is bare + * `jsonb`, which permits arrays and scalars, so pinning it to an object is guaranteed by the + * data rather than by a constraint. Measured 2026-08-15, all 2851 live documents are + * objects; a `check (jsonb_typeof(metadata) = 'object')` would make that structural. + * The four score fields are `.nullish()` — absent or * null already flows through the downstream `?? 0` handling unchanged — but a *string where * a number belongs* is rejected, which is precisely the silent-misranking case this exists * to catch. @@ -100,7 +104,12 @@ function describeIssues(error: z.ZodError): string[] { * has them, which would otherwise swallow the signal entirely. */ export function assertRetrievalRows(rows: unknown, rpc: string): asserts rows is SearchResult[] { - const parsed = retrievalRowsSchema.safeParse(rows); + assertRowsAgainst(retrievalRowsSchema, rows, rpc); +} + +/** Shared validate-log-throw step. Kept separate so every row contract fails identically. */ +function assertRowsAgainst(schema: z.ZodType, rows: unknown, rpc: string): void { + const parsed = schema.safeParse(rows); if (parsed.success) return; const issues = describeIssues(parsed.error); logger.error("retrieval_row_shape_mismatch", { @@ -111,6 +120,68 @@ export function assertRetrievalRows(rows: unknown, rpc: string): asserts rows is throw new RetrievalRowShapeError(rpc, issues); } +/** + * Signal rows from `match_document_embedding_fields_hybrid` — not `SearchResult`s. + * + * These carry a chunk id plus scores; `loadChunksForSignalMatches` then loads the real chunk. + * A wrong `source_chunk_id` loads the wrong evidence, and the mapping coerces scores with + * `Number(row.similarity ?? 0)`, which turns a stringified score into a silently different + * number rather than an error. Both are validated here; `field_type` is only a provenance + * label, so it stays permissive. + */ +const embeddingFieldRowSchema = z.looseObject({ + source_chunk_id: z.string().nullable(), + field_type: z.string().nullable(), + similarity: z.number().nullish(), + text_rank: z.number().nullish(), + hybrid_score: z.number().nullish(), +}); + +export type EmbeddingFieldSignalRow = z.infer; + +/** Validate embedding-field signal rows before they select chunks and scores. */ +export function assertEmbeddingFieldRows(rows: unknown, rpc: string): asserts rows is EmbeddingFieldSignalRow[] { + assertRowsAgainst(z.array(embeddingFieldRowSchema), rows, rpc); +} + +/** + * Index-unit rows from `match_document_index_units_hybrid`. + * + * Every pinned field is backed by a constraint on `public.document_index_units` in + * `supabase/schema.sql`: `unit_type`, `title`, `content` and `extraction_mode` are `not null`, + * and both `unit_type` and `extraction_mode` carry `check` constraints — so the enum below + * cannot reject a row the database would accept. `heading_path`, `normalized_terms` and + * `metadata` are `not null` too, but stay `.nullish()` to match the `?? []` / `?? null` + * handling the caller already applies. + */ +const indexUnitRowSchema = z.looseObject({ + id: z.string().min(1), + document_id: z.string().min(1), + source_chunk_id: z.string().nullable(), + source_image_id: z.string().nullable(), + unit_type: z.string().min(1), + title: z.string(), + content: z.string(), + page_start: z.number().int().nullable(), + page_end: z.number().int().nullable(), + heading_path: z.array(z.string()).nullish(), + normalized_terms: z.array(z.string()).nullish(), + source_span: z.record(z.string(), z.unknown()).nullish(), + quality_score: z.number().nullable(), + extraction_mode: z.enum(["deterministic", "model_heavy", "hybrid"]), + metadata: z.record(z.string(), z.unknown()).nullish(), + similarity: z.number().nullish(), + text_rank: z.number().nullish(), + hybrid_score: z.number().nullish(), +}); + +export type IndexUnitSignalRow = z.infer; + +/** Validate index-unit signal rows before they select chunks, scores, and unit provenance. */ +export function assertIndexUnitRows(rows: unknown, rpc: string): asserts rows is IndexUnitSignalRow[] { + assertRowsAgainst(z.array(indexUnitRowSchema), rows, rpc); +} + /** Build and validate the locally retrieved rows used as document-summary context. */ export function buildDocumentSummaryResults( chunks: unknown[], diff --git a/tests/rag-retrieval-row-contract.test.ts b/tests/rag-retrieval-row-contract.test.ts index 4aa447865f..fe3653b8d3 100644 --- a/tests/rag-retrieval-row-contract.test.ts +++ b/tests/rag-retrieval-row-contract.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { RetrievalRowShapeError, assertRetrievalRows, buildDocumentSummaryResults } from "@/lib/rag/rag-row-contracts"; +import { + RetrievalRowShapeError, + assertEmbeddingFieldRows, + assertIndexUnitRows, + assertRetrievalRows, + buildDocumentSummaryResults, +} from "@/lib/rag/rag-row-contracts"; vi.mock("@/lib/logger", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -144,3 +150,117 @@ describe("retrieval row shape contract", () => { expect(() => assertRetrievalRows(null, "match_document_chunks_hybrid")).toThrow(RetrievalRowShapeError); }); }); + +// Signal rows carry a chunk id plus scores; loadChunksForSignalMatches then loads the real +// chunk. Column lists mirror the RPCs' `returns table (...)` in +// supabase/migrations/20260713020000_owner_plus_public_retrieval.sql. +function embeddingFieldRow(overrides: Record = {}) { + return { + id: "9c1e0000-1111-4aaa-8bbb-000000000001", + document_id: "9c1e0000-2222-4aaa-8bbb-000000000002", + source_chunk_id: "9c1e0000-3333-4aaa-8bbb-000000000003", + field_type: "section_context", + content: "Monitoring schedule after a dose change.", + similarity: 0.74, + text_rank: 0.21, + hybrid_score: 0.68, + ...overrides, + }; +} + +function indexUnitRow(overrides: Record = {}) { + return { + id: "4b2d0000-1111-4aaa-8bbb-000000000001", + document_id: "4b2d0000-2222-4aaa-8bbb-000000000002", + source_chunk_id: "4b2d0000-3333-4aaa-8bbb-000000000003", + source_image_id: null, + unit_type: "threshold", + title: "Serum lithium target range", + content: "0.6–0.8 mmol/L for maintenance.", + page_start: 12, + page_end: 12, + heading_path: ["Monitoring", "Lithium"], + normalized_terms: ["lithium", "serum level"], + source_span: { start: 10, end: 240 }, + quality_score: 0.86, + extraction_mode: "deterministic", + metadata: { producer: "deterministic-v3" }, + similarity: 0.81, + text_rank: 0.33, + hybrid_score: 0.77, + ...overrides, + }; +} + +describe("signal row shape contracts", () => { + it("accepts realistic embedding-field and index-unit rows without mutating them", () => { + const fieldRows: unknown = [embeddingFieldRow()]; + const unitRows: unknown = [indexUnitRow()]; + const fieldsBefore = structuredClone(fieldRows); + const unitsBefore = structuredClone(unitRows); + + assertEmbeddingFieldRows(fieldRows, "match_document_embedding_fields_hybrid"); + assertIndexUnitRows(unitRows, "match_document_index_units_hybrid"); + + expect(fieldRows).toEqual(fieldsBefore); + expect(unitRows).toEqual(unitsBefore); + }); + + it("rejects a stringified score, which Number() would otherwise coerce silently", () => { + // `Number("0.74")` is 0.74 and `Number("high")` is NaN — neither fails today, and both + // reach the ranking pool as a score nobody computed. + expect(() => + assertEmbeddingFieldRows([embeddingFieldRow({ hybrid_score: "0.68" })], "match_document_embedding_fields_hybrid"), + ).toThrow(RetrievalRowShapeError); + expect(() => + assertIndexUnitRows([indexUnitRow({ similarity: "0.81" })], "match_document_index_units_hybrid"), + ).toThrow(RetrievalRowShapeError); + }); + + it("rejects a signal row whose chunk pointer is the wrong type", () => { + expect(() => + assertEmbeddingFieldRows([embeddingFieldRow({ source_chunk_id: 12 })], "match_document_embedding_fields_hybrid"), + ).toThrow(RetrievalRowShapeError); + expect(() => assertIndexUnitRows([indexUnitRow({ id: "" })], "match_document_index_units_hybrid")).toThrow( + RetrievalRowShapeError, + ); + }); + + it("accepts a null chunk pointer, which the caller filters out itself", () => { + expect(() => + assertEmbeddingFieldRows( + [embeddingFieldRow({ source_chunk_id: null })], + "match_document_embedding_fields_hybrid", + ), + ).not.toThrow(); + expect(() => + assertIndexUnitRows([indexUnitRow({ source_chunk_id: null })], "match_document_index_units_hybrid"), + ).not.toThrow(); + }); + + it("pins extraction_mode to the values its check constraint allows", () => { + expect(() => + assertIndexUnitRows([indexUnitRow({ extraction_mode: "model_heavy" })], "match_document_index_units_hybrid"), + ).not.toThrow(); + expect(() => + assertIndexUnitRows([indexUnitRow({ extraction_mode: "guessed" })], "match_document_index_units_hybrid"), + ).toThrow(RetrievalRowShapeError); + }); + + it("tolerates null collection columns the caller already coalesces", () => { + expect(() => + assertIndexUnitRows( + [indexUnitRow({ heading_path: null, normalized_terms: null, source_span: null, metadata: null })], + "match_document_index_units_hybrid", + ), + ).not.toThrow(); + }); + + it("preserves unknown columns on signal rows too", () => { + const rows: unknown = [indexUnitRow({ a_future_column: "kept" })]; + + assertIndexUnitRows(rows, "match_document_index_units_hybrid"); + + expect(rows[0]).toMatchObject({ a_future_column: "kept" }); + }); +}); From f4bc9033d5482922de60681d764eb5e6c6ebb73f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 06:34:28 +0000 Subject: [PATCH 02/12] chore(issues): capture the source_metadata pin and the untriaged search-route error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two inbox requests, both found while verifying PR #1946 against production: - P3 task: the retrieval contract's `source_metadata` pin is data-guaranteed rather than schema-guaranteed. A `check (jsonb_typeof(metadata) = 'object')` would make it structural. - P2 issue: `Error: Unhandled server request error` recurring on `/api/search` and `/api/search/universal` — 17 events across three Sentry groups, 0 users impacted, first seen ~6 hours before #1946 merged, so not caused by it. Currently unowned and its origin is unidentified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013xVTc5bPihwqTfnr8yFwWq --- .../d194f4ec-568c-4689-a411-22447c59fb53.json | 13 +++++++++++++ .../fd42e1f9-4012-4296-b7c2-102c7d199738.json | 13 +++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 docs/outstanding-issues-inbox/d194f4ec-568c-4689-a411-22447c59fb53.json create mode 100644 docs/outstanding-issues-inbox/fd42e1f9-4012-4296-b7c2-102c7d199738.json diff --git a/docs/outstanding-issues-inbox/d194f4ec-568c-4689-a411-22447c59fb53.json b/docs/outstanding-issues-inbox/d194f4ec-568c-4689-a411-22447c59fb53.json new file mode 100644 index 0000000000..7b08a9fc48 --- /dev/null +++ b/docs/outstanding-issues-inbox/d194f4ec-568c-4689-a411-22447c59fb53.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "id": "d194f4ec-568c-4689-a411-22447c59fb53", + "createdOn": "2026-08-15", + "action": "add", + "payload": { + "pri": "P2", + "type": "issue", + "summary": "Recurring 'Unhandled server request error' on /api/search and /api/search/universal is untriaged", + "detail": "Three Sentry issue groups in clinibase-xz over 24h (JAVASCRIPT-NEXTJS-Y, -Z, -10), 17 events, 0 users impacted, all titled 'Error: Unhandled server request error' with culprit chunk 1261.js:2:4801. Top frames are /api/search/route.js and /api/search/universal/route.js. First seen 2026-08-14T08:44:37Z on release c9b089c92c975297c10649b005401d5ae337cf48, roughly six hours BEFORE PR #1946 merged, so it is not caused by the retrieval row contract; the post-merge group is the same error refingerprinted by the release change. The error string does not appear anywhere in repo source, so it likely originates in a dependency or an instrumentation wrapper — origin unidentified. Nobody owns this. Next step: identify what throws it, then decide whether it is a bot/scanner artefact or a real request-handling gap.", + "source": "Sentry clinibase-xz, reviewed 2026-08-15" + } +} diff --git a/docs/outstanding-issues-inbox/fd42e1f9-4012-4296-b7c2-102c7d199738.json b/docs/outstanding-issues-inbox/fd42e1f9-4012-4296-b7c2-102c7d199738.json new file mode 100644 index 0000000000..ae5f752687 --- /dev/null +++ b/docs/outstanding-issues-inbox/fd42e1f9-4012-4296-b7c2-102c7d199738.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "id": "fd42e1f9-4012-4296-b7c2-102c7d199738", + "createdOn": "2026-08-15", + "action": "add", + "payload": { + "pri": "P3", + "type": "task", + "summary": "Make the retrieval row contract's source_metadata pin structural, not data-guaranteed", + "detail": "rag-row-contracts.ts pins source_metadata to a JSON object via z.record(...), but documents.metadata is bare jsonb and permits arrays and scalars. Measured against the live project (sjrfecxgysukkwxsowpy) on 2026-08-15: all 2851 documents are object-typed, so nothing breaks today and no live errors exist. The guarantee is data, not schema — a future ingest path could violate it and take retrieval down for that document's chunks. Fix is either a check (jsonb_typeof(metadata) = 'object') constraint on public.documents, or loosening the pin. Every other required field in that contract is backed by a not-null constraint.", + "source": "PR #1946 review + live Supabase verification 2026-08-15" + } +} From cf4d0005fc101134d11ae7407b2a7d765f14eece Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 06:43:24 +0000 Subject: [PATCH 03/12] chore(ledger): record the review for the tranche 2 row-contract branch Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013xVTc5bPihwqTfnr8yFwWq --- ...402a2e5b6d928c83a1455ccff9c97fc5f4a99286d566142b192.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/0cf1eff09758f402a2e5b6d928c83a1455ccff9c97fc5f4a99286d566142b192.record.md diff --git a/docs/branch-review-records/0cf1eff09758f402a2e5b6d928c83a1455ccff9c97fc5f4a99286d566142b192.record.md b/docs/branch-review-records/0cf1eff09758f402a2e5b6d928c83a1455ccff9c97fc5f4a99286d566142b192.record.md new file mode 100644 index 0000000000..2c92a3970a --- /dev/null +++ b/docs/branch-review-records/0cf1eff09758f402a2e5b6d928c83a1455ccff9c97fc5f4a99286d566142b192.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/rag-zod-hardening-tranche2 | f4bc9033d5482922de60681d764eb5e6c6ebb73f | rag-candidate-sources.ts signal-row Zod contracts (ledger #212 tranche 2) + 2 ledger captures | Approved — 3 unchecked RPC-result casts replaced with validated assertions; similarity_origin item deliberately excluded as a clinical-output behaviour change; no ranking/ordering/scoring logic touched | verify:pr-local all 18 steps green, zero failures; contract + rag-imputation-contract pins 21 passed; typecheck exit 0 | From a1b1fba2d80be09e11d49691c3b7923953207a98 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:56:01 +0800 Subject: [PATCH 04/12] fix: complete RAG row-contract review follow-ups --- ...78fb142e1e4e1d4b8e8d787d6abf9ea62f.record.md | 1 + ...a5a415ce87df2c2daba46045b0b84391ab.record.md | 1 + src/lib/rag/rag-candidate-sources.ts | 9 +++++++-- src/lib/rag/rag-row-contracts.ts | 17 +++++++++-------- tests/rag-retrieval-row-contract.test.ts | 9 +++++++++ 5 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 docs/branch-review-records/6fdbf4d3fb732b73a4c56c0bb3c09278fb142e1e4e1d4b8e8d787d6abf9ea62f.record.md create mode 100644 docs/branch-review-records/c87cf33f8557b35b1edb08b1fc38f1a5a415ce87df2c2daba46045b0b84391ab.record.md diff --git a/docs/branch-review-records/6fdbf4d3fb732b73a4c56c0bb3c09278fb142e1e4e1d4b8e8d787d6abf9ea62f.record.md b/docs/branch-review-records/6fdbf4d3fb732b73a4c56c0bb3c09278fb142e1e4e1d4b8e8d787d6abf9ea62f.record.md new file mode 100644 index 0000000000..095a5459c7 --- /dev/null +++ b/docs/branch-review-records/6fdbf4d3fb732b73a4c56c0bb3c09278fb142e1e4e1d4b8e8d787d6abf9ea62f.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/rag-zod-hardening-tranche2 | 671b0b99f7fdd33e83e5fa55a29470690c9243f2 | RAG row-contract tranche 2 P2: unconstrained JSON provenance acceptance | Fixed P2 — index-unit source_span and metadata accept all JSON allowed by the database; non-object provenance is safely omitted from record-only downstream consumers | manual adversarial review; focused scalar/array contract regression added; git diff --check; ci-change-scope self-test; ledger/inbox/outstanding/discipline guards passed; targeted Vitest blocked: node_modules/vitest absent | diff --git a/docs/branch-review-records/c87cf33f8557b35b1edb08b1fc38f1a5a415ce87df2c2daba46045b0b84391ab.record.md b/docs/branch-review-records/c87cf33f8557b35b1edb08b1fc38f1a5a415ce87df2c2daba46045b0b84391ab.record.md new file mode 100644 index 0000000000..f5594b258e --- /dev/null +++ b/docs/branch-review-records/c87cf33f8557b35b1edb08b1fc38f1a5a415ce87df2c2daba46045b0b84391ab.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/rag-zod-hardening-tranche2 | 20304ddc703fdc6913e1b62ad532b552ff919dac | RAG row-contract tranche 2: optional signal shape-mismatch degradation; merged main | Approved with P1 fix — malformed optional embedding-field/index-unit rows are logged and degrade to no signal candidates, preserving chunk retrieval; no ranking, ordering, or clinical-output contract changed | manual adversarial control-flow review; git diff --check; ci-change-scope self-test; ledger/inbox/outstanding/discipline guards passed; targeted Vitest blocked: node_modules/vitest absent | diff --git a/src/lib/rag/rag-candidate-sources.ts b/src/lib/rag/rag-candidate-sources.ts index ee47801974..22730e6b8a 100644 --- a/src/lib/rag/rag-candidate-sources.ts +++ b/src/lib/rag/rag-candidate-sources.ts @@ -163,6 +163,11 @@ function assertOptionalSignalRows(assertRows: () => void) { } } +/** Index-unit consumers read these provenance fields as maps; retain that output contract. */ +function optionalJsonRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} + /** Record how many variant RPCs a lexical surface actually issued (PT-02 early-exit). */ function recordTextVariantFanout( telemetry: SearchTelemetry | undefined, @@ -1151,13 +1156,13 @@ export async function searchIndexUnitCandidates(args: { page_end: row.page_end, heading_path: row.heading_path ?? [], normalized_terms: row.normalized_terms ?? [], - source_span: row.source_span ?? null, + source_span: optionalJsonRecord(row.source_span), quality_score: row.quality_score, extraction_mode: row.extraction_mode, similarity: row.similarity, text_rank: row.text_rank, hybrid_score: row.hybrid_score, - metadata: row.metadata ?? null, + metadata: optionalJsonRecord(row.metadata), }, })); return loadChunksForSignalMatches({ diff --git a/src/lib/rag/rag-row-contracts.ts b/src/lib/rag/rag-row-contracts.ts index 9dbfbef687..8b140732cf 100644 --- a/src/lib/rag/rag-row-contracts.ts +++ b/src/lib/rag/rag-row-contracts.ts @@ -147,12 +147,13 @@ export function assertEmbeddingFieldRows(rows: unknown, rpc: string): asserts ro /** * Index-unit rows from `match_document_index_units_hybrid`. * - * Every pinned field is backed by a constraint on `public.document_index_units` in - * `supabase/schema.sql`: `unit_type`, `title`, `content` and `extraction_mode` are `not null`, - * and both `unit_type` and `extraction_mode` carry `check` constraints — so the enum below - * cannot reject a row the database would accept. `heading_path`, `normalized_terms` and - * `metadata` are `not null` too, but stay `.nullish()` to match the `?? []` / `?? null` - * handling the caller already applies. + * The fields that select a chunk, control scoring, or label extraction are backed by + * constraints on `public.document_index_units` in `supabase/schema.sql`: `unit_type`, `title`, + * `content` and `extraction_mode` are `not null`, and both `unit_type` and `extraction_mode` + * carry `check` constraints — so the enum below cannot reject a row the database would accept. + * `source_span` and `metadata` are unconstrained `jsonb`, so they accept any JSON value rather + * than turning a non-object provenance value into a retrieval outage. `heading_path` and + * `normalized_terms` stay `.nullish()` to match the `?? []` handling the caller already applies. */ const indexUnitRowSchema = z.looseObject({ id: z.string().min(1), @@ -166,10 +167,10 @@ const indexUnitRowSchema = z.looseObject({ page_end: z.number().int().nullable(), heading_path: z.array(z.string()).nullish(), normalized_terms: z.array(z.string()).nullish(), - source_span: z.record(z.string(), z.unknown()).nullish(), + source_span: z.json().nullish(), quality_score: z.number().nullable(), extraction_mode: z.enum(["deterministic", "model_heavy", "hybrid"]), - metadata: z.record(z.string(), z.unknown()).nullish(), + metadata: z.json().nullish(), similarity: z.number().nullish(), text_rank: z.number().nullish(), hybrid_score: z.number().nullish(), diff --git a/tests/rag-retrieval-row-contract.test.ts b/tests/rag-retrieval-row-contract.test.ts index 035c17b25a..895c1594ad 100644 --- a/tests/rag-retrieval-row-contract.test.ts +++ b/tests/rag-retrieval-row-contract.test.ts @@ -260,6 +260,15 @@ describe("signal row shape contracts", () => { ).not.toThrow(); }); + it("accepts array and scalar provenance from unconstrained jsonb columns", () => { + expect(() => + assertIndexUnitRows( + [indexUnitRow({ source_span: ["page", 12], metadata: "legacy-provenance" })], + "match_document_index_units_hybrid", + ), + ).not.toThrow(); + }); + it("preserves unknown columns on signal rows too", () => { const rows: unknown = [indexUnitRow({ a_future_column: "kept" })]; From a417776fabdaf847174316be84b7d2ea47357069 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:08:05 +0800 Subject: [PATCH 05/12] fix: preserve RAG row assertion narrowing --- ...6e929cbbe76b46ab8d3a953de2b0883f.record.md | 1 + src/lib/rag/rag-candidate-sources.ts | 28 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) create mode 100644 docs/branch-review-records/719b4f9ab3ccc24a497b1f84a5262acd6e929cbbe76b46ab8d3a953de2b0883f.record.md diff --git a/docs/branch-review-records/719b4f9ab3ccc24a497b1f84a5262acd6e929cbbe76b46ab8d3a953de2b0883f.record.md b/docs/branch-review-records/719b4f9ab3ccc24a497b1f84a5262acd6e929cbbe76b46ab8d3a953de2b0883f.record.md new file mode 100644 index 0000000000..0fd7e1802e --- /dev/null +++ b/docs/branch-review-records/719b4f9ab3ccc24a497b1f84a5262acd6e929cbbe76b46ab8d3a953de2b0883f.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/rag-zod-hardening-tranche2 | 1a59ce0b128fbdabb9a24c3ea95c1123299693f3 | RAG row-contract CI follow-up: optional-signal assertion narrowing | Fixed P1 — explicit local mismatch catches preserve both logged optional-signal degradation and TypeScript row narrowing | CI typecheck failure reproduced from exact-head log; git diff --check; ci-change-scope self-test; ledger/inbox/outstanding/discipline guards passed; local Vitest unavailable because node_modules/vitest is absent | diff --git a/src/lib/rag/rag-candidate-sources.ts b/src/lib/rag/rag-candidate-sources.ts index 22730e6b8a..2029e7e9ff 100644 --- a/src/lib/rag/rag-candidate-sources.ts +++ b/src/lib/rag/rag-candidate-sources.ts @@ -148,24 +148,10 @@ export function recordHybridRpcError(telemetry: SearchTelemetry | undefined, rpc } } -/** - * The embedding-field and index-unit layers are optional retrieval signals. Their - * row contracts log a redacted shape mismatch before throwing; preserve that - * visibility while allowing the primary chunk retrieval path to continue. - */ -function assertOptionalSignalRows(assertRows: () => void) { - try { - assertRows(); - return true; - } catch (error) { - if (error instanceof RetrievalRowShapeError) return false; - throw error; - } -} - /** Index-unit consumers read these provenance fields as maps; retain that output contract. */ function optionalJsonRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; } /** Record how many variant RPCs a lexical surface actually issued (PT-02 early-exit). */ @@ -1080,7 +1066,10 @@ export async function searchEmbeddingFieldCandidates(args: { ); if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; - if (!assertOptionalSignalRows(() => assertEmbeddingFieldRows(data, "match_document_embedding_fields_hybrid"))) { + try { + assertEmbeddingFieldRows(data, "match_document_embedding_fields_hybrid"); + } catch (error) { + if (!(error instanceof RetrievalRowShapeError)) throw error; return [] as SearchResult[]; } const matches = data @@ -1132,7 +1121,10 @@ export async function searchIndexUnitCandidates(args: { ); if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; - if (!assertOptionalSignalRows(() => assertIndexUnitRows(data, "match_document_index_units_hybrid"))) { + try { + assertIndexUnitRows(data, "match_document_index_units_hybrid"); + } catch (error) { + if (!(error instanceof RetrievalRowShapeError)) throw error; return [] as SearchResult[]; } const matches = data From 22380cd98705cb68099572436738b4e505167310 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:31:24 +0800 Subject: [PATCH 06/12] docs(ledger): record RAG base sync --- ...a4c0f36e12b0294e651372309f02b547300fa7ca6426587d82c.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/60f458bc7e118a4c0f36e12b0294e651372309f02b547300fa7ca6426587d82c.record.md diff --git a/docs/branch-review-records/60f458bc7e118a4c0f36e12b0294e651372309f02b547300fa7ca6426587d82c.record.md b/docs/branch-review-records/60f458bc7e118a4c0f36e12b0294e651372309f02b547300fa7ca6426587d82c.record.md new file mode 100644 index 0000000000..e037df2b02 --- /dev/null +++ b/docs/branch-review-records/60f458bc7e118a4c0f36e12b0294e651372309f02b547300fa7ca6426587d82c.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/rag-zod-hardening-tranche2 | 18253c2bc555d424e83f987712cf528dd3910e3f | Carry validated optional RAG signal degradation fix through main d301d8f4 | fixed | git diff --check; static RAG contract inspection; ledger and issue guards; tests blocked without node_modules | From c11e9bff9752de0f30d1b4b8e33b2a7abdf9210c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:56:28 +0800 Subject: [PATCH 07/12] style(rag): format signal row contract --- tests/rag-retrieval-row-contract.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/rag-retrieval-row-contract.test.ts b/tests/rag-retrieval-row-contract.test.ts index 895c1594ad..43b2ba14ab 100644 --- a/tests/rag-retrieval-row-contract.test.ts +++ b/tests/rag-retrieval-row-contract.test.ts @@ -214,7 +214,10 @@ describe("signal row shape contracts", () => { // `Number("0.74")` is 0.74 and `Number("high")` is NaN — neither fails today, and both // reach the ranking pool as a score nobody computed. expect(() => - assertEmbeddingFieldRows([embeddingFieldRow({ hybrid_score: "0.68" })], "match_document_embedding_fields_hybrid"), + assertEmbeddingFieldRows( + [embeddingFieldRow({ hybrid_score: "0.68" })], + "match_document_embedding_fields_hybrid", + ), ).toThrow(RetrievalRowShapeError); expect(() => assertIndexUnitRows([indexUnitRow({ similarity: "0.81" })], "match_document_index_units_hybrid"), From 5b253e9077742008149b471315d01b4110e95954 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:56:40 +0800 Subject: [PATCH 08/12] docs(ledger): record RAG formatter follow-up --- ...a1206d6eea9b313f4a39e88a0195f76078511821ab44cc09456.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/e28c56717b466a1206d6eea9b313f4a39e88a0195f76078511821ab44cc09456.record.md diff --git a/docs/branch-review-records/e28c56717b466a1206d6eea9b313f4a39e88a0195f76078511821ab44cc09456.record.md b/docs/branch-review-records/e28c56717b466a1206d6eea9b313f4a39e88a0195f76078511821ab44cc09456.record.md new file mode 100644 index 0000000000..bc56f2235c --- /dev/null +++ b/docs/branch-review-records/e28c56717b466a1206d6eea9b313f4a39e88a0195f76078511821ab44cc09456.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/rag-zod-hardening-tranche2 | 690204f669db3be9995b6c658ad2eb35befbdace | RAG signal-row formatter follow-up | Formatted the signal-row regression assertion reported by changed-file formatting. Targeted Vitest unavailable because this isolated worktree has no node_modules/vitest. | node --check tests/rag-retrieval-row-contract.test.ts; git diff --check; ledger-inbox; outstanding-issues; branch-review-ledger; ledger-write-discipline; ci-change-scope --self-test | From 6f73a03e14090604e09ddd277eb3f861e1a6bec4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:14:45 +0800 Subject: [PATCH 09/12] style(rag): collapse candidate-source import --- tests/rag-retrieval-row-contract.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/rag-retrieval-row-contract.test.ts b/tests/rag-retrieval-row-contract.test.ts index 43b2ba14ab..933fe6b254 100644 --- a/tests/rag-retrieval-row-contract.test.ts +++ b/tests/rag-retrieval-row-contract.test.ts @@ -6,10 +6,7 @@ import { assertRetrievalRows, buildDocumentSummaryResults, } from "@/lib/rag/rag-row-contracts"; -import { - searchEmbeddingFieldCandidates, - searchIndexUnitCandidates, -} from "@/lib/rag/rag-candidate-sources"; +import { searchEmbeddingFieldCandidates, searchIndexUnitCandidates } from "@/lib/rag/rag-candidate-sources"; vi.mock("@/lib/logger", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, From 22a3eb3cda9200327bf666308856ba21931a0a79 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:14:55 +0800 Subject: [PATCH 10/12] docs(ledger): record RAG import formatter follow-up --- ...88768dc59074dcd77476f562cfdaabd4d2b23cd3a67336a02ff.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/8f593be414cd088768dc59074dcd77476f562cfdaabd4d2b23cd3a67336a02ff.record.md diff --git a/docs/branch-review-records/8f593be414cd088768dc59074dcd77476f562cfdaabd4d2b23cd3a67336a02ff.record.md b/docs/branch-review-records/8f593be414cd088768dc59074dcd77476f562cfdaabd4d2b23cd3a67336a02ff.record.md new file mode 100644 index 0000000000..594dc1b4c8 --- /dev/null +++ b/docs/branch-review-records/8f593be414cd088768dc59074dcd77476f562cfdaabd4d2b23cd3a67336a02ff.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/rag-zod-hardening-tranche2 | 690204f669db3be9995b6c658ad2eb35befbdace | PR #1981 retrieval row contract formatter follow-up | Collapsed a formatter-stable candidate-source import after the exact-head changed-file format gate failed; retained the validated row-shape assertions. | git diff --check; ledger/outstanding/branch-ledger/ledger-discipline guards; ci-change-scope self-test passed; npm test -- tests/rag-retrieval-row-contract.test.ts unavailable: node_modules/vitest/vitest.mjs absent. | From 87a8886f1f761bdab92324e0d5ea5e11d3111bb9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:26:29 +0800 Subject: [PATCH 11/12] docs(ledger): record RAG base sync --- ...c0fc9a79c97c63bbacb3bac3f9a436acc5b0336b62533ab1c54.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/150c95562bf34c0fc9a79c97c63bbacb3bac3f9a436acc5b0336b62533ab1c54.record.md diff --git a/docs/branch-review-records/150c95562bf34c0fc9a79c97c63bbacb3bac3f9a436acc5b0336b62533ab1c54.record.md b/docs/branch-review-records/150c95562bf34c0fc9a79c97c63bbacb3bac3f9a436acc5b0336b62533ab1c54.record.md new file mode 100644 index 0000000000..c9aa58217a --- /dev/null +++ b/docs/branch-review-records/150c95562bf34c0fc9a79c97c63bbacb3bac3f9a436acc5b0336b62533ab1c54.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/rag-zod-hardening-tranche2 | 690204f669db3be9995b6c658ad2eb35befbdace | PR #1981 base sync | Merged main 6f7b7deefaf7e0cd062b748f18fc6ca8988093f6 into the reviewed PR head; merge tree was clean. | git merge-tree --write-tree exact-head main: clean; git diff --check; ledger guards. | From b693e373d6a29fd24e65b105d2a9e634c66d988a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:42:03 +0800 Subject: [PATCH 12/12] fix(rag): format retrieval row contract test --- tests/rag-retrieval-row-contract.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/rag-retrieval-row-contract.test.ts b/tests/rag-retrieval-row-contract.test.ts index 933fe6b254..ac1b7a0467 100644 --- a/tests/rag-retrieval-row-contract.test.ts +++ b/tests/rag-retrieval-row-contract.test.ts @@ -211,10 +211,7 @@ describe("signal row shape contracts", () => { // `Number("0.74")` is 0.74 and `Number("high")` is NaN — neither fails today, and both // reach the ranking pool as a score nobody computed. expect(() => - assertEmbeddingFieldRows( - [embeddingFieldRow({ hybrid_score: "0.68" })], - "match_document_embedding_fields_hybrid", - ), + assertEmbeddingFieldRows([embeddingFieldRow({ hybrid_score: "0.68" })], "match_document_embedding_fields_hybrid"), ).toThrow(RetrievalRowShapeError); expect(() => assertIndexUnitRows([indexUnitRow({ similarity: "0.81" })], "match_document_index_units_hybrid"),