diff --git a/docs/branch-review-records/0a18b8e9ba7fa494f75b85ffd05fd9b65fead2f59404ca47158efc100ed27dc6.record.md b/docs/branch-review-records/0a18b8e9ba7fa494f75b85ffd05fd9b65fead2f59404ca47158efc100ed27dc6.record.md new file mode 100644 index 0000000000..a6f5d29149 --- /dev/null +++ b/docs/branch-review-records/0a18b8e9ba7fa494f75b85ffd05fd9b65fead2f59404ca47158efc100ed27dc6.record.md @@ -0,0 +1 @@ +| 2026-08-18 | claude/restore-source-metadata-pin | c5effece629b7eeadd96a4dd92bbc8c7de0cda71 | restore .nullable() source_metadata pin in src/lib/rag/rag-row-contracts.ts after PR #2107 loosened it; add absent-key rejection test | strict pin restored; 26/26 contract tests; offline 623/623; 36 golden | vitest rag-retrieval-row-contract 26/26; eval:rag:offline 623/623; check:rag:fixtures; tsc clean | diff --git a/src/lib/rag/rag-row-contracts.ts b/src/lib/rag/rag-row-contracts.ts index 9dcb84c0b8..5fafb1797a 100644 --- a/src/lib/rag/rag-row-contracts.ts +++ b/src/lib/rag/rag-row-contracts.ts @@ -41,11 +41,17 @@ const retrievalImageSchema = z.looseObject({ caption: z.string(), }); +// Deliberately `.nullable()`, not `.nullish()`: the key must be PRESENT (object or null). +// This is the one field pinned stricter than the rest so an RPC whose column set drifts +// (source_metadata dropped from the SELECT) fails loudly as RetrievalRowShapeError instead +// of silently degrading every citation to "unknown" governance defaults. Tranche 1 decision +// (PR #1946, "throw on mismatch"); PR #2107 loosened it to `.nullish()` and this PR +// restores it. See docs/outstanding-issues.md #343 for the constraint-backing follow-up. const sourceMetadataSchema = z .record(z.string(), z.unknown(), { message: "source_metadata must be a JSON object", }) - .nullish(); + .nullable(); const retrievalRowSchema = z.looseObject({ id: z.string().min(1), diff --git a/tests/rag-retrieval-row-contract.test.ts b/tests/rag-retrieval-row-contract.test.ts index 32438b3ab6..0c1a597071 100644 --- a/tests/rag-retrieval-row-contract.test.ts +++ b/tests/rag-retrieval-row-contract.test.ts @@ -146,6 +146,20 @@ describe("retrieval row shape contract", () => { expect(thrown?.message).toContain("source_metadata"); }); + it("rejects a row whose source_metadata key is absent (RPC column drift must fail loudly)", () => { + // Present-and-null is accepted (see the next test); ABSENT is not. This is the pin PR #2107 + // loosened to .nullish() and the restore re-tightened: an RPC that drops the column must + // surface as RetrievalRowShapeError, not degrade every citation to "unknown" governance. + let thrown: RetrievalRowShapeError | null = null; + try { + assertRetrievalRows([withoutColumn("source_metadata")], "match_document_chunks_hybrid"); + } catch (error) { + thrown = error as RetrievalRowShapeError; + } + expect(thrown).toBeInstanceOf(RetrievalRowShapeError); + expect(thrown?.message).toContain("source_metadata"); + }); + it("accepts absent or null scores, which downstream already coalesces to 0", () => { expect(() => assertRetrievalRows([withoutColumn("text_rank")], "match_document_chunks")).not.toThrow(); expect(() => assertRetrievalRows([hybridRow({ rrf_score: null })], "match_document_chunks")).not.toThrow();