Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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 |
8 changes: 7 additions & 1 deletion src/lib/rag/rag-row-contracts.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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),
Expand Down
14 changes: 14 additions & 0 deletions tests/rag-retrieval-row-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand Down
Loading