diff --git a/docs/branch-review-records/66904e0507ba08c4f28e5760ba2b025ce41fac8f096ab7ba1cc4ef61db2eb599.record.md b/docs/branch-review-records/66904e0507ba08c4f28e5760ba2b025ce41fac8f096ab7ba1cc4ef61db2eb599.record.md new file mode 100644 index 0000000000..4622589d17 --- /dev/null +++ b/docs/branch-review-records/66904e0507ba08c4f28e5760ba2b025ce41fac8f096ab7ba1cc4ef61db2eb599.record.md @@ -0,0 +1 @@ +| 2026-08-17 | https://github.com/BigSimmo/Database/pull/2023 | 5bcce8927e60ce8271473823460aa281d79fca1f | src/app/api Zod row contracts (ledger #212 tranche 3) + 2 ledger inbox requests | Approved -- 4 unchecked structure-asserting casts on inbound DB/RPC data replaced with constraint-backed Zod assertions in new src/lib/validation/row-contracts.ts; 3 outbound Json telemetry casts audited and deliberately left; no src/lib/rag/** edit so ragRanking is false; 4 unrealistic document_labels test fixtures corrected without weakening assertions | verify:pr-local 13 steps green (format:changed, lint, typecheck, check:ledger-write-discipline); npm run test 6699 passed with only 2 pre-existing failures proved identical on a clean worktree at merge base d02767184; build exit 0; check:rag:fixtures 36 golden cases; api-row-contract 27/27; pr-policy evaluator 0 errors 0 warnings | diff --git a/docs/outstanding-issues-inbox/2f5fbcde-438b-47ab-a1b7-01309802d935.json b/docs/outstanding-issues-inbox/2f5fbcde-438b-47ab-a1b7-01309802d935.json new file mode 100644 index 0000000000..d38b1216ff --- /dev/null +++ b/docs/outstanding-issues-inbox/2f5fbcde-438b-47ab-a1b7-01309802d935.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "2f5fbcde-438b-47ab-a1b7-01309802d935", + "createdOn": "2026-08-17", + "action": "update", + "payload": { + "id": "#212", + "detail": "Corrected 2026-08-17: the previous text was stale in two ways. (1) The \"40 unsafe casts\" headline is wrong. Measured on main at d0276718 across src/, worker/ and scripts/: 80 \"as unknown as\" and 60 \"JSON.parse\" occurrences -- but most are legitimate OUTBOUND serialization (pgvector query_embedding args, telemetry inserts cast to Json) or client handles, to which no Zod schema applies, so the remediable number is far lower than either figure suggests. (2) The named remaining population was wrong: src/app/api/documents/route.ts, src/app/api/ingestion/{batches,jobs,quality}/route.ts and src/app/api/jobs/route.ts were listed as holding 11 casts; each in fact holds ZERO \"as unknown as\" and ZERO JSON.parse. STATUS: src/lib/rag/** is COMPLETE for this class of defect as of tranche 1 (PR #1946, rag.ts, live-verified) and tranche 2 (PR #1981, rag-candidate-sources.ts); five casts remain there and all five are deliberate (outbound pgvector args plus one client handle). Tranche 3 covered src/app/api/**: an audit of all 41 route files found only 3 \"as unknown as\" (all outbound telemetry cast to Json, correctly left alone) and no JSON.parse at all, and confirmed every request body is already validated through parseJsonBody with a Zod schema. The genuine targets were a different metric -- unchecked casts that assert STRUCTURE onto inbound DB/RPC data -- of which there were 4, now replaced by src/lib/validation/row-contracts.ts: search_document_chunks RPC rows, the document_chunks table fallback rows, document_labels rows, and the search_schema_health payload (declared Returns: Json, so previously a structure claim with nothing behind it). REMAINING: worker/main.ts (11 \"as unknown as\") is the next and largest cluster, deferred as its own tranche because ingestion is a separate risk domain. Scripts are not production paths.", + "source": "ledger #212 tranche 3 (PR for src/app/api row contracts), session 2026-08-17" + } +} diff --git a/docs/outstanding-issues-inbox/aa0eb6ae-8f6a-4440-8a15-6ba0d17f812d.json b/docs/outstanding-issues-inbox/aa0eb6ae-8f6a-4440-8a15-6ba0d17f812d.json new file mode 100644 index 0000000000..1bf67a503d --- /dev/null +++ b/docs/outstanding-issues-inbox/aa0eb6ae-8f6a-4440-8a15-6ba0d17f812d.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "aa0eb6ae-8f6a-4440-8a15-6ba0d17f812d", + "createdOn": "2026-08-17", + "action": "add", + "payload": { + "pri": "P1", + "type": "issue", + "summary": "Decide whether a fabricated similarity of 1 on document-summary rows may earn the high confidence label a clinician reads", + "detail": "buildDocumentSummaryResults (src/lib/rag/rag-row-contracts.ts) stamps similarity: 1 on document-summary rows -- a fabricated score, not a measured cosine -- and does NOT set similarity_origin. deriveConfidence (src/lib/rag/rag-answer-support.ts:32-36) computes strongestNonSynthetic by EXCLUDING rows tagged similarity_origin === \"synthetic_text\", so an untagged fabricated 1.0 IS counted, and line 36 gates the \"high\" verdict on strongestNonSynthetic >= 0.82 with at least 2 accepted citations. A document summary therefore reaches the \"high\" confidence label on a score nobody measured. Note the mechanism precisely: buildDocumentSummaryResults does NOT set the tag -- the three call sites that DO tag synthetic scores are in rag-candidate-sources.ts (lines 626, 721, 934), and it is the ABSENCE of the tag here that admits the fabricated score. docs/clinical-hazard-analysis.md H5a records the adjacent document-lookup fast-path hazard. QUESTION: should a fabricated similarity count toward \"high\"? Adding the tag would demote these answers to medium or low. This is a clinical-governance decision before it is an engineering one: either way it needs its own design, discriminating offline tests that separate tagged from untagged rows, and a live eval-canary before/after pair per docs/rag-behaviour/. Deliberately excluded from PR #1981 and from the #212 tranche 3 PR rather than bundled, because it changes clinical output. Verified on main at d0276718.", + "source": "ledger #212 tranche 3 (src/app/api row contracts), session 2026-08-17", + "issueUlid": "01M07DCMBNJ912J9F17RHNW2AJ" + } +} diff --git a/src/app/api/documents/[id]/labels/route.ts b/src/app/api/documents/[id]/labels/route.ts index 39ae94d018..c503eaec8e 100644 --- a/src/app/api/documents/[id]/labels/route.ts +++ b/src/app/api/documents/[id]/labels/route.ts @@ -7,9 +7,10 @@ import { normalizeDocumentLabelForStorage } from "@/lib/document-tags"; import { invalidateRagCachesForDocumentMutation } from "@/lib/rag/rag"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; -import type { DocumentLabel, DocumentLabelType } from "@/lib/types"; +import type { DocumentLabelType } from "@/lib/types"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; +import { assertDocumentLabelRows } from "@/lib/validation/row-contracts"; export const runtime = "nodejs"; @@ -101,7 +102,9 @@ async function selectLabels(supabase: ReturnType, docu .order("label", { ascending: true }); if (error) throw new Error(error.message); - return (data ?? []) as DocumentLabel[]; + const rows = data ?? []; + assertDocumentLabelRows(rows); + return rows; } export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { diff --git a/src/app/api/documents/[id]/search/route.ts b/src/app/api/documents/[id]/search/route.ts index 01bce7b55b..079633cf6f 100644 --- a/src/app/api/documents/[id]/search/route.ts +++ b/src/app/api/documents/[id]/search/route.ts @@ -11,22 +11,14 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRouteParams } from "@/lib/validation/params"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; +import { + assertDocumentChunkSearchRpcRows, + assertDocumentChunkSearchTableRows, + type DocumentChunkSearchRow, +} from "@/lib/validation/row-contracts"; export const runtime = "nodejs"; -type DocumentChunkSearchRow = { - id: string; - page_number: number | null; - chunk_index: number; - section_heading: string | null; - content: string; - image_ids: string[] | null; - text_rank?: number | null; - trigram_score?: number | null; - metadata?: Record | null; - index_generation_id?: string | null; -}; - const maxSearchTerms = 8; const defaultSearchLimit = 20; const maxSearchLimit = 60; @@ -218,7 +210,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }); if (!rpcError) { - const results = ((rpcData ?? []) as DocumentChunkSearchRow[]) + const rpcRows = rpcData ?? []; + assertDocumentChunkSearchRpcRows(rpcRows); + const results = rpcRows .filter((row) => isCommittedGenerationMetadata({ rowMetadata: generationMetadataForRow(row), @@ -262,7 +256,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: if (error) throw new Error(error.message); const importantTerms = importantTermsFor(terms); - const committedData = ((data ?? []) as DocumentChunkSearchRow[]).filter((row) => + const fallbackTableRows = data ?? []; + assertDocumentChunkSearchTableRows(fallbackTableRows); + const committedData = fallbackTableRows.filter((row) => isCommittedGenerationMetadata({ rowMetadata: generationMetadataForRow(row), committedGeneration, diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index 25608e1ba2..10a25ab805 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -7,6 +7,7 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser } from "@/lib/supabase/auth"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; +import { assertSearchSchemaHealth } from "@/lib/validation/row-contracts"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -153,13 +154,6 @@ async function readSchemaStatus(supabase: AdminClient | null) { } } -type SearchSchemaHealth = { - ok?: boolean; - missing?: string[]; - vector_extension_schema?: string | null; - checked_at?: string; -}; - async function readSearchSchemaStatus(supabase: AdminClient | null) { const label = "Search RPC and vector indexes"; if (!requiredSupabaseEnvPresent) { @@ -176,8 +170,9 @@ async function readSearchSchemaStatus(supabase: AdminClient | null) { if (error) { return check("search", label, "needs_setup", "Search health checks are temporarily unavailable."); } - const health = (data ?? {}) as SearchSchemaHealth; - const missing = Array.isArray(health.missing) ? health.missing : []; + const health = data ?? {}; + assertSearchSchemaHealth(health); + const missing = health.missing; if (!health.ok || missing.length > 0) { return check( "search", diff --git a/src/lib/validation/row-contracts.ts b/src/lib/validation/row-contracts.ts new file mode 100644 index 0000000000..97ed8f91c0 --- /dev/null +++ b/src/lib/validation/row-contracts.ts @@ -0,0 +1,218 @@ +import { z } from "zod"; +import { logger } from "@/lib/logger"; +import type { DocumentLabel, DocumentLabelType } from "@/lib/types"; + +/** + * Runtime shape contracts for database rows and RPC payloads consumed by `src/app/api/**`. + * + * Route handlers had been asserting these shapes with a bare `as SomeRow[]`, which is a + * compile-time claim about a live database that is known to drift: `docs/outstanding-issues.md` + * `#316` records 20 missing indexes and 10 retrieval RPC bodies diverging from this repo's + * migrations, with weekly live-drift red since 2026-07-26. A renamed column or a numeric + * returned as a string then does not fail — it silently mis-scores, mis-filters, or mis-renders. + * This module is the `src/app/api` counterpart of `src/lib/rag/rag-row-contracts.ts` and + * deliberately duplicates that module's small validate-log-throw core rather than importing it, + * so that hardening an API route never edits a protected RAG ranking surface. + * + * Every contract here follows the same two rules: + * + * - **`z.looseObject`, never `z.object`.** Zod strips unknown keys by default, which would be + * silent data loss whenever the live column set is ahead of this repo. Unknown keys pass + * through untouched. + * - **Pin only what a constraint backs.** Each required field below is `not null` or carries a + * `check` in `supabase/schema.sql`, cited per contract, so requiring it cannot reject a row + * the database would accept today. Anything unconstrained stays permissive. + */ + +/** Cap on reported issues; a wholesale shape change would otherwise report one per row. */ +const MAX_REPORTED_ISSUES = 5; + +/** + * Thrown when a database row or RPC payload does not satisfy its route's contract. + * + * The message carries only Zod issue paths and codes — never a row value. These rows carry + * clinical document text and owner identifiers, so echoing one into a log or an error response + * would leak content past the privacy boundary `query-privacy.ts` maintains. + */ +export class ApiRowShapeError extends Error { + readonly source: string; + readonly issues: string[]; + + constructor(source: string, issues: string[]) { + super(`"${source}" returned data that does not match its route contract: ${issues.join("; ")}`); + this.name = "ApiRowShapeError"; + this.source = source; + this.issues = issues; + } +} + +function describeIssues(error: z.ZodError): string[] { + const described = error.issues + .slice(0, MAX_REPORTED_ISSUES) + .map((issue) => `${issue.path.join(".") || ""}: ${issue.message}`); + const remaining = error.issues.length - described.length; + return remaining > 0 ? [...described, `and ${remaining} more`] : described; +} + +/** + * Shared validate-log-throw step. Kept separate so every contract fails identically, and logs + * before throwing so drift stays visible even where a caller catches and degrades. + */ +function assertAgainst(schema: z.ZodType, value: unknown, source: string): void { + const parsed = schema.safeParse(value); + if (parsed.success) return; + const issues = describeIssues(parsed.error); + logger.error("api_row_shape_mismatch", { + source, + issues, + rowCount: Array.isArray(value) ? value.length : null, + }); + throw new ApiRowShapeError(source, issues); +} + +/** + * Columns shared by both in-document search paths, backed by `public.document_chunks` + * in `supabase/schema.sql`: `chunk_index` and `content` are `not null`, `image_ids` is + * `not null default '{}'`, and `id` is the primary key. `page_number` and `section_heading` + * are nullable columns and stay `.nullable()`. + * + * The two score fields are `.nullish()` — absent or null already flows through the caller's + * `Number(row.text_rank ?? 0)` handling unchanged — but a *string where a number belongs* is + * rejected. That is the case worth catching: `scoreChunk` coerces with `Number(...)`, so a + * stringified rank becomes a silently different score rather than an error, and `score` + * orders the snippets a clinician reads. + */ +const documentChunkSearchBase = z.looseObject({ + id: z.string().min(1), + page_number: z.number().int().nullable(), + chunk_index: z.number().int(), + section_heading: z.string().nullable(), + content: z.string(), + image_ids: z.array(z.string()).nullish(), + text_rank: z.number().nullish(), + trigram_score: z.number().nullish(), +}); + +/** + * Rows from the `search_document_chunks` RPC. + * + * The RPC's `returns table (...)` in `supabase/schema.sql` lists exactly the eight columns + * above — it returns neither `metadata` nor `index_generation_id`, because migration + * `20260717130000_filter_search_document_chunks_committed_generation.sql` moved the + * committed-generation filter into the SQL body itself. The route's client-side + * `isCommittedGenerationMetadata` pass over these rows is therefore inert (it fails open on + * absent generation data) and load-bearing only on the table fallback below. That is left + * exactly as it behaves today; this contract only stops the shape claim from being unchecked. + */ +const documentChunkSearchRpcRowsSchema = z.array(documentChunkSearchBase); + +/** + * Rows from the `document_chunks` table fallback used when the RPC is unavailable. + * + * This path selects `metadata` and `index_generation_id` explicitly, and here the + * committed-generation filter genuinely gates which chunks a clinician sees. `metadata` is + * `not null` on the table but is bare `jsonb` with no `jsonb_typeof` check, so it is + * deliberately **not** pinned to an object — the caller passes it to `committedIndexGeneration`, + * which already accepts `unknown`. `index_generation_id` is a nullable `uuid` column. + */ +const documentChunkSearchTableRowsSchema = z.array( + documentChunkSearchBase.extend({ + metadata: z.unknown(), + index_generation_id: z.string().nullish(), + }), +); + +export type DocumentChunkSearchRow = z.infer & { + metadata?: unknown; + index_generation_id?: string | null; +}; + +/** Validate `search_document_chunks` RPC rows before they are scored and ordered. */ +export function assertDocumentChunkSearchRpcRows(rows: unknown): asserts rows is DocumentChunkSearchRow[] { + assertAgainst(documentChunkSearchRpcRowsSchema, rows, "search_document_chunks"); +} + +/** Validate `document_chunks` fallback rows before generation filtering, scoring, and ordering. */ +export function assertDocumentChunkSearchTableRows(rows: unknown): asserts rows is DocumentChunkSearchRow[] { + assertAgainst(documentChunkSearchTableRowsSchema, rows, "document_chunks.portable_ilike_fallback"); +} + +/** + * Rows from `public.document_labels`. + * + * The two enum pins are exactly the table's `check` constraints in `supabase/schema.sql`: + * `label_type` is checked against the same fourteen values as `DocumentLabelType` in + * `src/lib/types.ts`, and `source` against `('generated', 'manual')`. Both columns are plain + * `text not null`, so the hand-written union in `DocumentLabel` was previously an unchecked + * narrowing — an out-of-union `label_type` would have flowed straight into the clinical badge + * and filter surfaces that switch on it. `confidence` is `real not null check (0..1)`, and + * `label`/`document_id`/`id` are all `not null`. + * + * `metadata` is `not null` but is bare `jsonb` with no `jsonb_typeof` check, so it is left + * unpinned rather than asserted to be an object. `DocumentLabel` types it as + * `Record | null`; that remains an unproven narrowing, unchanged by this + * contract and flagged here rather than silently blessed. + */ +const documentLabelRowsSchema = z.array( + z.looseObject({ + id: z.string().min(1), + document_id: z.string().min(1), + owner_id: z.string().nullish(), + label: z.string(), + // `satisfies` pins this list to `DocumentLabelType`, so widening the union without + // widening the database `check` (or the reverse) fails the build rather than silently + // admitting a label type the badge surfaces cannot render. + label_type: z.enum([ + "site", + "topic", + "document_type", + "medication", + "risk", + "setting", + "workflow", + "population", + "service", + "clinical_action", + "care_phase", + "document_intent", + "content_feature", + "custom", + ] satisfies [DocumentLabelType, ...DocumentLabelType[]]), + source: z.enum(["generated", "manual"]), + confidence: z.number().min(0).max(1), + }), +); + +/** Validate `document_labels` rows before they reach clinical badge and filter surfaces. */ +export function assertDocumentLabelRows(rows: unknown): asserts rows is DocumentLabel[] { + assertAgainst(documentLabelRowsSchema, rows, "document_labels"); +} + +/** + * The `search_schema_health` RPC payload. + * + * This RPC is declared `Returns: Json` in `src/lib/supabase/database.types.ts` — genuinely + * unstructured as far as the client is concerned — so the route's previous + * `(data ?? {}) as SearchSchemaHealth` was a structure claim with nothing behind it. The + * function's `jsonb_build_object` in `supabase/schema.sql` always emits `ok`, `missing`, + * `vector_extension_schema` and `checked_at`, so pinning them cannot reject a healthy + * response. `vector_extension_schema` is a nullable local, and `checked_at` stays permissive + * because the route never reads it. + * + * The caller already wraps this read in a `try`/`catch` that reports `needs_setup`, so a + * mismatch degrades to the same status a falsy `ok` produces today — with a logged issue path + * instead of a silent `Missing or stale search schema items: unknown.` + */ +const searchSchemaHealthSchema = z.looseObject({ + ok: z.boolean(), + missing: z.array(z.string()), + vector_extension_schema: z.string().nullish(), + checked_at: z.unknown(), +}); + +export type SearchSchemaHealth = z.infer; + +/** Validate the `search_schema_health` payload before it drives the setup-status verdict. */ +export function assertSearchSchemaHealth(value: unknown): asserts value is SearchSchemaHealth { + assertAgainst(searchSchemaHealthSchema, value, "search_schema_health"); +} diff --git a/tests/api-row-contract.test.ts b/tests/api-row-contract.test.ts new file mode 100644 index 0000000000..f48d75ede7 --- /dev/null +++ b/tests/api-row-contract.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ApiRowShapeError, + assertDocumentChunkSearchRpcRows, + assertDocumentChunkSearchTableRows, + assertDocumentLabelRows, + assertSearchSchemaHealth, +} from "@/lib/validation/row-contracts"; + +vi.mock("@/lib/logger", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +/** + * A realistic `search_document_chunks` row. The column list mirrors that function's + * `returns table (...)` in supabase/schema.sql — eight columns, with neither `metadata` + * nor `index_generation_id`, because the committed-generation filter lives in the SQL body. + */ +function rpcRow(overrides: Record = {}) { + return { + id: "3f1a2b6c-1111-4aaa-8bbb-000000000001", + page_number: 14, + chunk_index: 7, + section_heading: "Lithium monitoring", + content: "Check serum lithium 5 days after any dose change.", + image_ids: [], + text_rank: 0.42, + trigram_score: 0.31, + ...overrides, + }; +} + +/** The table-fallback shape: the RPC columns plus the two the fallback selects explicitly. */ +function tableRow(overrides: Record = {}) { + return { + ...rpcRow(), + metadata: { index_generation_id: "9c2f0d10-3333-4aaa-8bbb-000000000003" }, + index_generation_id: "9c2f0d10-3333-4aaa-8bbb-000000000003", + ...overrides, + }; +} + +function labelRow(overrides: Record = {}) { + return { + id: "7d5e1a90-4444-4aaa-8bbb-000000000004", + document_id: "3f1a2b6c-2222-4aaa-8bbb-000000000002", + owner_id: "0b6c9e21-5555-4aaa-8bbb-000000000005", + label: "lithium", + label_type: "medication", + source: "generated", + confidence: 0.82, + metadata: {}, + created_at: "2026-08-15T00:00:00.000Z", + updated_at: "2026-08-15T00:00:00.000Z", + ...overrides, + }; +} + +function healthPayload(overrides: Record = {}) { + return { + ok: true, + missing: [], + vector_extension_schema: "extensions", + legacy_ivfflat_indexes: [], + deferred_hnsw_indexes: [], + checked_at: "2026-08-15T00:00:00.000Z", + ...overrides, + }; +} + +describe("document chunk search RPC contract", () => { + it("accepts a realistic RPC row without mutating it", () => { + const rows: unknown = [rpcRow()]; + const before = structuredClone(rows); + const firstRowReference = (rows as unknown[])[0]; + + expect(() => assertDocumentChunkSearchRpcRows(rows)).not.toThrow(); + + // Assert, do not transform: the caller scores and orders these rows, so a valid row must + // reach `scoreChunk` by the same reference and with its key order unchanged. + expect(rows).toEqual(before); + expect((rows as unknown[])[0]).toBe(firstRowReference); + expect(Object.keys((rows as Record[])[0])).toEqual(Object.keys(rpcRow())); + }); + + it("preserves unknown columns rather than stripping them", () => { + // z.looseObject, not z.object: the live column set may run ahead of this repo, and + // dropping a column the caller has not read yet would be silent data loss. + const rows: unknown = [rpcRow({ retrieval_synopsis: "Lithium dosing summary." })]; + assertDocumentChunkSearchRpcRows(rows); + expect((rows as Record[])[0].retrieval_synopsis).toBe("Lithium dosing summary."); + }); + + it("accepts an empty result set", () => { + expect(() => assertDocumentChunkSearchRpcRows([])).not.toThrow(); + }); + + it("accepts null and absent scores, which the caller already coerces with ?? 0", () => { + expect(() => assertDocumentChunkSearchRpcRows([rpcRow({ text_rank: null })])).not.toThrow(); + const withoutTrigram: Record = rpcRow(); + delete withoutTrigram.trigram_score; + expect(() => assertDocumentChunkSearchRpcRows([withoutTrigram])).not.toThrow(); + }); + + it("rejects a stringified score, the silent-misordering case", () => { + // `scoreChunk` does Number(row.text_rank ?? 0), so a string becomes a different score + // rather than an error — this is the failure the contract exists to surface. + expect(() => assertDocumentChunkSearchRpcRows([rpcRow({ text_rank: "0.42" })])).toThrow(ApiRowShapeError); + }); + + it("rejects a row missing a column the caller reads", () => { + const missingContent: Record = rpcRow(); + delete missingContent.content; + expect(() => assertDocumentChunkSearchRpcRows([missingContent])).toThrow(ApiRowShapeError); + }); + + it("accepts nullable page_number and section_heading, which are nullable columns", () => { + expect(() => + assertDocumentChunkSearchRpcRows([rpcRow({ page_number: null, section_heading: null })]), + ).not.toThrow(); + }); + + it("reports only Zod issue paths, never row content", () => { + // These rows carry clinical document text; an error message that echoed one would leak + // source content past the boundary query-privacy.ts maintains. + const secret = "Check serum lithium 5 days after any dose change."; + try { + assertDocumentChunkSearchRpcRows([rpcRow({ chunk_index: "seven" })]); + throw new Error("expected the contract to reject this row"); + } catch (error) { + expect(error).toBeInstanceOf(ApiRowShapeError); + const message = (error as ApiRowShapeError).message; + expect(message).toContain("chunk_index"); + expect(message).not.toContain(secret); + expect(message).not.toContain("seven"); + } + }); + + it("rejects a non-array payload", () => { + expect(() => assertDocumentChunkSearchRpcRows(rpcRow())).toThrow(ApiRowShapeError); + }); +}); + +describe("document chunk table fallback contract", () => { + it("accepts the fallback row shape, which adds generation columns", () => { + const rows: unknown = [tableRow()]; + const before = structuredClone(rows); + expect(() => assertDocumentChunkSearchTableRows(rows)).not.toThrow(); + expect(rows).toEqual(before); + }); + + it("accepts any JSON type in metadata, which carries no jsonb_typeof check", () => { + // documents/document_chunks metadata is bare `jsonb not null`, so pinning it to an object + // would be a claim the schema does not back. The caller passes it to + // committedIndexGeneration, which already accepts unknown. + expect(() => assertDocumentChunkSearchTableRows([tableRow({ metadata: [] })])).not.toThrow(); + expect(() => assertDocumentChunkSearchTableRows([tableRow({ metadata: "generation-1" })])).not.toThrow(); + }); + + it("accepts a null index_generation_id, which is a nullable column", () => { + expect(() => assertDocumentChunkSearchTableRows([tableRow({ index_generation_id: null })])).not.toThrow(); + }); + + it("rejects a non-string index_generation_id, which would break the generation filter", () => { + expect(() => assertDocumentChunkSearchTableRows([tableRow({ index_generation_id: 7 })])).toThrow(ApiRowShapeError); + }); +}); + +describe("document label contract", () => { + it("accepts a realistic label row without mutating it", () => { + const rows: unknown = [labelRow()]; + const before = structuredClone(rows); + const firstRowReference = (rows as unknown[])[0]; + + expect(() => assertDocumentLabelRows(rows)).not.toThrow(); + + expect(rows).toEqual(before); + expect((rows as unknown[])[0]).toBe(firstRowReference); + }); + + it("accepts every label_type the database check constraint permits", () => { + // The enum mirrors document_labels' check constraint in supabase/schema.sql exactly, so + // it cannot reject a row the database would accept today. + const permitted = [ + "site", + "topic", + "document_type", + "medication", + "risk", + "setting", + "workflow", + "population", + "service", + "clinical_action", + "care_phase", + "document_intent", + "content_feature", + "custom", + ]; + for (const label_type of permitted) { + expect(() => assertDocumentLabelRows([labelRow({ label_type })])).not.toThrow(); + } + }); + + it("rejects a label_type outside the check constraint", () => { + // The previous `as DocumentLabel[]` cast narrowed a plain `text` column to a TS union with + // nothing checking it, so an out-of-union value reached the clinical badge surfaces. + expect(() => assertDocumentLabelRows([labelRow({ label_type: "diagnosis" })])).toThrow(ApiRowShapeError); + }); + + it("rejects a source outside ('generated', 'manual')", () => { + expect(() => assertDocumentLabelRows([labelRow({ source: "imported" })])).toThrow(ApiRowShapeError); + }); + + it("rejects a confidence outside the 0..1 check constraint", () => { + expect(() => assertDocumentLabelRows([labelRow({ confidence: 1.4 })])).toThrow(ApiRowShapeError); + expect(() => assertDocumentLabelRows([labelRow({ confidence: -0.1 })])).toThrow(ApiRowShapeError); + }); + + it("accepts a null owner_id, which is a nullable column", () => { + expect(() => assertDocumentLabelRows([labelRow({ owner_id: null })])).not.toThrow(); + }); + + it("leaves metadata unpinned, since the column carries no jsonb_typeof check", () => { + expect(() => assertDocumentLabelRows([labelRow({ metadata: [] })])).not.toThrow(); + }); + + it("never reports the label value in an error", () => { + try { + assertDocumentLabelRows([labelRow({ label: "clozapine-titration", confidence: "high" })]); + throw new Error("expected the contract to reject this row"); + } catch (error) { + expect(error).toBeInstanceOf(ApiRowShapeError); + expect((error as ApiRowShapeError).message).not.toContain("clozapine-titration"); + } + }); +}); + +describe("search_schema_health contract", () => { + it("accepts the payload jsonb_build_object always emits", () => { + const payload: unknown = healthPayload(); + const before = structuredClone(payload); + expect(() => assertSearchSchemaHealth(payload)).not.toThrow(); + expect(payload).toEqual(before); + }); + + it("accepts a null vector_extension_schema, which is a nullable local", () => { + expect(() => assertSearchSchemaHealth(healthPayload({ vector_extension_schema: null }))).not.toThrow(); + }); + + it("accepts an unhealthy payload listing missing items", () => { + expect(() => + assertSearchSchemaHealth(healthPayload({ ok: false, missing: ["documents_title_trgm_idx"] })), + ).not.toThrow(); + }); + + it("rejects an empty object, which the previous cast accepted silently", () => { + // `(data ?? {}) as SearchSchemaHealth` made every field optional, so an empty payload + // reported "Missing or stale search schema items: unknown." with no signal about why. + expect(() => assertSearchSchemaHealth({})).toThrow(ApiRowShapeError); + }); + + it("rejects a non-array missing field", () => { + expect(() => assertSearchSchemaHealth(healthPayload({ missing: "documents_title_trgm_idx" }))).toThrow( + ApiRowShapeError, + ); + }); + + it("preserves keys the route does not read", () => { + const payload: unknown = healthPayload(); + assertSearchSchemaHealth(payload); + expect((payload as Record).legacy_ivfflat_indexes).toEqual([]); + }); +}); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 3dd5c200cb..e0afd01467 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -3585,7 +3585,19 @@ describe("private document API access", () => { return ok({ id: manualLabelId, ...(call.insertPayload as Record) }); } if (call.table === "document_labels" && call.operation === "select") { - return ok([{ id: manualLabelId, document_id: documentId, label: "clozapine monitoring" }]); + // selectLabels issues a select("*"), so a realistic row carries every column. + // label_type, source and confidence are all `not null` with check constraints on + // public.document_labels, and assertDocumentLabelRows now holds the route to that. + return ok([ + { + id: manualLabelId, + document_id: documentId, + label: "clozapine monitoring", + label_type: "medication", + source: "manual", + confidence: 1, + }, + ]); } return ok([]); }); @@ -3625,7 +3637,16 @@ describe("private document API access", () => { return ok({ id: manualLabelId, ...(call.insertPayload as Record) }); } if (call.table === "document_labels" && call.operation === "select") { - return ok([{ id: manualLabelId, document_id: documentId, label: "fiona stanley hospital" }]); + return ok([ + { + id: manualLabelId, + document_id: documentId, + label: "fiona stanley hospital", + label_type: "site", + source: "manual", + confidence: 1, + }, + ]); } return ok([]); }); @@ -3687,7 +3708,16 @@ describe("private document API access", () => { return ok({ id: manualLabelId, ...(call.updatePayload as Record) }); } if (call.table === "document_labels" && call.operation === "select") { - return ok([{ id: manualLabelId, document_id: documentId, label: "lithium toxicity" }]); + return ok([ + { + id: manualLabelId, + document_id: documentId, + label: "lithium toxicity", + label_type: "risk", + source: "manual", + confidence: 1, + }, + ]); } return ok([]); }); @@ -3725,7 +3755,16 @@ describe("private document API access", () => { return ok({ id: labelId, ...(call.updatePayload as Record) }); } if (call.table === "document_labels" && call.operation === "select") { - return ok([{ id: labelId, document_id: documentId, label: "lithium", label_type: "medication" }]); + return ok([ + { + id: labelId, + document_id: documentId, + label: "lithium", + label_type: "medication", + source: "generated", + confidence: 0.72, + }, + ]); } return ok([]); });