diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 84252d0e3..e8d2f8797 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -1983,6 +1983,13 @@ export type Database = { }; Functions: { analyze_rag_tables: { Args: never; Returns: undefined }; + apply_document_metadata_patch: { + Args: { + p_document_id: string; + p_metadata_patch?: Json; + }; + Returns: undefined; + }; consume_api_subject_rate_limit: { Args: { p_subject_key: string; @@ -2186,6 +2193,13 @@ export type Database = { Args: { document_metadata: Json; row_generation: string }; Returns: boolean; }; + jsonb_merge_deep: { + Args: { + patch_obj?: Json; + target_obj?: Json; + }; + Returns: Json; + }; match_document_chunks: { Args: { document_filter?: string | null; diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 25f5901ee..d1978e577 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-08T16:31:51.432Z", + "generated_at": "2026-07-08T17:13:03.597Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "3ee18ec46fd0d7f9586858f8b0345355c60dbdc235f0dd4a802c04c06d750f70", - "replay_seconds": 13, + "schema_sha256": "c004ebf92005475495394533bed3880f5372f16b90aab24f1a790e4d748d052c", + "replay_seconds": 31, "snapshot": { "views": [ { @@ -5635,6 +5635,14 @@ "def_hash": "fc2b64914e236c259c192c7527a32af7", "signature": "public.analyze_rag_tables()" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "91318c428667d073d12d42e2f7bf5505", + "signature": "public.apply_document_metadata_patch(uuid,jsonb)" + }, { "acl": [ "postgres=X/postgres", @@ -5672,7 +5680,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "b9b0836c354240f45f61ccfe40392317", + "def_hash": "594b1f715fbbfa1df1b1f1183a7fef5a", "signature": "public.commit_document_index_generation(uuid,uuid,text,integer,integer,integer,jsonb,jsonb,jsonb)" }, { @@ -5811,6 +5819,14 @@ "def_hash": "d4913081cb28b11030c854deaa55c816", "signature": "public.is_committed_document_generation(uuid,jsonb)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "69c0b6affcaeb9a0e23a8673d4bbe7c0", + "signature": "public.jsonb_merge_deep(jsonb,jsonb)" + }, { "acl": [ "postgres=X/postgres", diff --git a/supabase/migrations/20260708310000_r5_document_metadata_merge.sql b/supabase/migrations/20260708310000_r5_document_metadata_merge.sql new file mode 100644 index 000000000..f4e89a323 --- /dev/null +++ b/supabase/migrations/20260708310000_r5_document_metadata_merge.sql @@ -0,0 +1,307 @@ +-- R5: deep-merge documents.metadata on worker/commit writes. +-- +-- Root cause: commit_document_index_generation and worker updateDocument paths +-- full-replaced documents.metadata, erasing concurrent renames / bulk-metadata +-- / agent-state patches under reclaim races (docs/ingestion-concurrency-fix-workorder.md). +-- +-- Expand/contract: new helpers are additive; commit RPC keeps the same signature +-- and only changes the metadata assignment from replace to deep-merge. Safe to +-- apply before the worker deploy. Old workers that still send a full object still +-- benefit because merge preserves keys absent from the patch; the paired worker +-- change sends worker-owned key deltas only. + +create or replace function public.jsonb_merge_deep(target_obj jsonb, patch_obj jsonb) +returns jsonb +language plpgsql +immutable +set search_path = public, extensions, pg_temp +as $$ +declare + merged jsonb := coalesce(target_obj, '{}'::jsonb); + key text; + incoming_value jsonb; +begin + for key, incoming_value in + select j.key, j.value + from jsonb_each(coalesce(patch_obj, '{}'::jsonb)) as j + loop + -- JSON null means "delete this key" so worker deltas can clear sticky + -- error/gate fields that the old full-replace used to wipe implicitly. + if incoming_value = 'null'::jsonb then + merged := merged - key; + elsif jsonb_typeof(merged -> key) = 'object' and jsonb_typeof(incoming_value) = 'object' then + merged := jsonb_set( + merged, + array[key], + public.jsonb_merge_deep(merged -> key, incoming_value), + true + ); + else + merged := jsonb_set(merged, array[key], incoming_value, true); + end if; + end loop; + return merged; +end; +$$; + +create or replace function public.apply_document_metadata_patch( + p_document_id uuid, + p_metadata_patch jsonb +) +returns void +language plpgsql +set search_path = public, extensions, pg_temp +as $$ +begin + update public.documents + set + metadata = public.jsonb_merge_deep( + coalesce(metadata, '{}'::jsonb), + coalesce(p_metadata_patch, '{}'::jsonb) + ), + updated_at = now() + where id = p_document_id; +end; +$$; + +create or replace function public.commit_document_index_generation( + p_document_id uuid, + p_index_generation_id uuid, + p_status text default 'indexed', + p_page_count integer default 0, + p_chunk_count integer default 0, + p_image_count integer default 0, + p_metadata jsonb default '{}'::jsonb, + p_pages jsonb default null, + p_quality jsonb default null +) +returns jsonb +language plpgsql +set search_path = public, extensions, pg_temp +as $$ +begin + perform set_config('statement_timeout', '180000', true); + + update public.documents + set + status = p_status, + page_count = p_page_count, + chunk_count = p_chunk_count, + image_count = p_image_count, + error_message = null, + updated_at = now() + where id = p_document_id; + + -- R5: merge worker-owned keys onto live metadata instead of full-replace. + perform public.apply_document_metadata_patch( + p_document_id, + coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id) + ); + + if p_pages is not null then + delete from public.document_pages + where document_id = p_document_id; + + insert into public.document_pages (document_id, page_number, text, ocr_used, metadata) + select + p_document_id, + page_row.page_number, + coalesce(page_row.text, ''), + coalesce(page_row.ocr_used, false), + coalesce(page_row.metadata, '{}'::jsonb) + from jsonb_to_recordset(coalesce(p_pages, '[]'::jsonb)) as page_row( + page_number integer, + text text, + ocr_used boolean, + metadata jsonb + ) + where page_row.page_number is not null; + end if; + + if p_quality is not null then + insert into public.document_index_quality ( + document_id, + owner_id, + quality_score, + extraction_quality, + metrics, + issues, + updated_at + ) + values ( + p_document_id, + nullif(p_quality->>'owner_id', '')::uuid, + coalesce((p_quality->>'quality_score')::real, 0), + coalesce(nullif(p_quality->>'extraction_quality', ''), 'unknown'), + coalesce(p_quality->'metrics', '{}'::jsonb), + coalesce( + array(select jsonb_array_elements_text(coalesce(p_quality->'issues', '[]'::jsonb))), + '{}'::text[] + ), + now() + ) + on conflict on constraint document_index_quality_pkey + do update set + owner_id = excluded.owner_id, + quality_score = excluded.quality_score, + extraction_quality = excluded.extraction_quality, + metrics = excluded.metrics, + issues = excluded.issues, + updated_at = excluded.updated_at; + end if; + + -- Preserve legacy NULL-generation rows unless this generation wrote replacements. + delete from public.document_chunks + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and exists ( + select 1 + from public.document_chunks replacement + where replacement.document_id = p_document_id + and replacement.index_generation_id = p_index_generation_id + ) + ) + ); + + -- artifact tables: use typed column where set; fall back to metadata when typed is NULL + -- because the writer still populates metadata.index_generation_id rather than the typed + -- column. Without the metadata fallback, stale null-typed rows from a prior run would + -- never be cleaned up (the typed-column EXISTS guard would always be false), allowing + -- artifact rows to accumulate across re-indexes. + delete from public.document_images + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_images replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_table_facts + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_table_facts replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_embedding_fields + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_embedding_fields replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_index_units + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_index_units replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_memory_cards + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_memory_cards replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + delete from public.document_sections + where document_id = p_document_id + and ( + (index_generation_id is not null and index_generation_id <> p_index_generation_id) + or ( + index_generation_id is null + and (metadata->>'index_generation_id')::uuid is distinct from p_index_generation_id + and exists ( + select 1 + from public.document_sections replacement + where replacement.document_id = p_document_id + and ( + replacement.index_generation_id = p_index_generation_id + or (replacement.index_generation_id is null + and (replacement.metadata->>'index_generation_id')::uuid = p_index_generation_id) + ) + ) + ) + ); + + return jsonb_build_object( + 'ok', true, + 'document_id', p_document_id, + 'index_generation_id', p_index_generation_id + ); +end; +$$; + +revoke execute on function public.jsonb_merge_deep(jsonb, jsonb) from public, anon, authenticated; +grant execute on function public.jsonb_merge_deep(jsonb, jsonb) to service_role; +revoke execute on function public.apply_document_metadata_patch(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.apply_document_metadata_patch(uuid, jsonb) to service_role; +revoke execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated; +grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index f262448c1..483caecff 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1250,6 +1250,60 @@ begin end; $$; +create or replace function public.jsonb_merge_deep(target_obj jsonb, patch_obj jsonb) +returns jsonb +language plpgsql +immutable +set search_path = public, extensions, pg_temp +as $$ +declare + merged jsonb := coalesce(target_obj, '{}'::jsonb); + key text; + incoming_value jsonb; +begin + for key, incoming_value in + select j.key, j.value + from jsonb_each(coalesce(patch_obj, '{}'::jsonb)) as j + loop + -- JSON null means "delete this key" so worker deltas can clear sticky + -- error/gate fields that the old full-replace used to wipe implicitly. + if incoming_value = 'null'::jsonb then + merged := merged - key; + elsif jsonb_typeof(merged -> key) = 'object' and jsonb_typeof(incoming_value) = 'object' then + merged := jsonb_set( + merged, + array[key], + public.jsonb_merge_deep(merged -> key, incoming_value), + true + ); + else + merged := jsonb_set(merged, array[key], incoming_value, true); + end if; + end loop; + return merged; +end; +$$; + +create or replace function public.apply_document_metadata_patch( + p_document_id uuid, + p_metadata_patch jsonb +) +returns void +language plpgsql +set search_path = public, extensions, pg_temp +as $$ +begin + update public.documents + set + metadata = public.jsonb_merge_deep( + coalesce(metadata, '{}'::jsonb), + coalesce(p_metadata_patch, '{}'::jsonb) + ), + updated_at = now() + where id = p_document_id; +end; +$$; + create or replace function public.commit_document_index_generation( p_document_id uuid, p_index_generation_id uuid, @@ -1275,10 +1329,15 @@ begin chunk_count = p_chunk_count, image_count = p_image_count, error_message = null, - metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id), updated_at = now() where id = p_document_id; + -- R5: merge worker-owned keys onto live metadata instead of full-replace. + perform public.apply_document_metadata_patch( + p_document_id, + coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id) + ); + if p_pages is not null then delete from public.document_pages where document_id = p_document_id; @@ -4623,6 +4682,10 @@ grant select, insert, update, delete on table public.document_index_units to ser grant select on table public.document_index_units to authenticated; revoke execute on function public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid) from public, anon, authenticated; grant execute on function public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid) to service_role; +revoke execute on function public.jsonb_merge_deep(jsonb, jsonb) from public, anon, authenticated; +grant execute on function public.jsonb_merge_deep(jsonb, jsonb) to service_role; +revoke execute on function public.apply_document_metadata_patch(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.apply_document_metadata_patch(uuid, jsonb) to service_role; revoke execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated; grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role; revoke execute on function public.cleanup_abandoned_document_index_generations(uuid, integer, boolean) from public, anon, authenticated; diff --git a/tests/document-metadata-merge.test.ts b/tests/document-metadata-merge.test.ts new file mode 100644 index 000000000..c15dd5a26 --- /dev/null +++ b/tests/document-metadata-merge.test.ts @@ -0,0 +1,138 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const workerMain = readFileSync(new URL("../worker/main.ts", import.meta.url), "utf8"); +const schemaSql = readFileSync(new URL("../supabase/schema.sql", import.meta.url), "utf8"); +const r5Migration = readFileSync( + new URL("../supabase/migrations/20260708310000_r5_document_metadata_merge.sql", import.meta.url), + "utf8", +); + +/** + * Mirrors public.jsonb_merge_deep so the worker-owned-delta contract stays + * covered offline (the SQL helper is exercise-checked via migration + live + * apply). Nested objects merge recursively; scalars/arrays overwrite; JSON + * null deletes the key (same as `merged - key` in SQL). + */ +function jsonbMergeDeep( + targetObj: Record | null | undefined, + patchObj: Record | null | undefined, +): Record { + const merged: Record = { ...(targetObj ?? {}) }; + for (const [key, incoming] of Object.entries(patchObj ?? {})) { + if (incoming === null) { + delete merged[key]; + continue; + } + const existing = merged[key]; + if ( + existing !== null && + typeof existing === "object" && + !Array.isArray(existing) && + typeof incoming === "object" && + !Array.isArray(incoming) + ) { + merged[key] = jsonbMergeDeep(existing as Record, incoming as Record); + } else { + merged[key] = incoming; + } + } + return merged; +} + +describe("R5 document metadata deep-merge", () => { + it("preserves concurrent rename/agent keys when worker sends owned deltas only", () => { + const live = { + title_override: "Renamed while reindexing", + agent_state: { pass: "indexing-v3-agent", note: "keep me" }, + bulk_tag: "operator", + enrichment_status: "completed", + }; + const workerDelta = { + indexed_at: "2026-07-08T12:00:00.000Z", + index_generation_id: "gen-2", + enrichment_status: "pending", + indexing_v3_agent_status: "pending", + page_count: 12, + }; + + expect(jsonbMergeDeep(live, workerDelta)).toEqual({ + title_override: "Renamed while reindexing", + agent_state: { pass: "indexing-v3-agent", note: "keep me" }, + bulk_tag: "operator", + indexed_at: "2026-07-08T12:00:00.000Z", + index_generation_id: "gen-2", + enrichment_status: "pending", + indexing_v3_agent_status: "pending", + page_count: 12, + }); + }); + + it("deep-merges nested objects without dropping sibling keys", () => { + const live = { + index_quality_metrics: { text_character_count: 100, page_count: 3 }, + labels: ["keep"], + }; + const workerDelta = { + index_quality_metrics: { text_character_count: 400, ocr_page_count: 1 }, + }; + + expect(jsonbMergeDeep(live, workerDelta)).toEqual({ + index_quality_metrics: { + text_character_count: 400, + page_count: 3, + ocr_page_count: 1, + }, + labels: ["keep"], + }); + }); + + it("overwrites scalar and array keys from the patch", () => { + expect( + jsonbMergeDeep( + { enrichment_status: "completed", issues: ["a"], note: "old" }, + { enrichment_status: "failed", issues: ["b"], note: "set" }, + ), + ).toEqual({ + enrichment_status: "failed", + issues: ["b"], + note: "set", + }); + }); + + it("deletes sticky keys when the patch sends JSON null", () => { + expect( + jsonbMergeDeep( + { + indexing_v3_agent_last_error: "old", + completion_gate_missing: ["image_caption"], + keep: true, + }, + { + indexing_v3_agent_last_error: null, + completion_gate_missing: null, + }, + ), + ).toEqual({ keep: true }); + }); + + it("keeps the SQL merge helpers + grant posture in schema and the R5 migration", () => { + for (const sql of [schemaSql, r5Migration]) { + expect(sql).toContain("create or replace function public.jsonb_merge_deep"); + expect(sql).toContain("create or replace function public.apply_document_metadata_patch"); + expect(sql).toContain("perform public.apply_document_metadata_patch("); + expect(sql).toContain( + "revoke execute on function public.jsonb_merge_deep(jsonb, jsonb) from public, anon, authenticated", + ); + expect(sql).toContain( + "grant execute on function public.apply_document_metadata_patch(uuid, jsonb) to service_role", + ); + } + }); + + it("routes worker document metadata updates through the merge RPC with owned deltas only", () => { + expect(workerMain).toContain('rpc("apply_document_metadata_patch"'); + expect(workerMain).toContain("send only worker-owned key deltas"); + expect(workerMain).not.toContain("...(job.documents.metadata"); + }); +}); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 1347cbe8f..fe9c25716 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -210,6 +210,11 @@ describe("Supabase schema Data API grants", () => { expect(sql).toContain("insert into public.document_pages"); expect(sql).toContain("insert into public.document_index_quality"); } + // R5 helpers live only in schema.sql (+ the dedicated migration), not in the + // original atomic-reindex migration snapshot. + expect(schema).toContain("create or replace function public.jsonb_merge_deep"); + expect(schema).toContain("create or replace function public.apply_document_metadata_patch"); + expect(schema).toContain("perform public.apply_document_metadata_patch"); expect(schema).toContain("public.is_committed_document_generation(c.index_generation_id, d.metadata)"); expect(schema).toContain("public.is_committed_artifact_generation(m.metadata, d.metadata)"); expect(schema).toContain("public.is_committed_artifact_generation(f.metadata, d.metadata)"); diff --git a/worker/main.ts b/worker/main.ts index 55c339601..e705e071d 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -167,9 +167,45 @@ async function updateJobProgress(jobId: string, patch: { stage: string; progress } async function updateDocument(documentId: string, patch: TablesUpdate<"documents">) { - const sanitized = patch.metadata ? { ...patch, metadata: sanitizeJsonbRecord(patch.metadata) } : patch; - const { error } = await supabase.from("documents").update(sanitized).eq("id", documentId); - if (error) throw supabaseStageError("update document", error); + const { metadata, ...remainingPatch } = patch as TablesUpdate<"documents">; + const updatePayload = remainingPatch; + const hasUpdatePayload = Object.keys(updatePayload).length > 0; + if (hasUpdatePayload) { + const { error } = await supabase.from("documents").update(updatePayload).eq("id", documentId); + if (error) throw supabaseStageError("update document", error); + } + + // R5: deep-merge worker-owned metadata keys onto live documents.metadata so + // concurrent renames / bulk-metadata / agent patches survive reclaim races. + if (typeof metadata !== "undefined") { + const metadataPatch = sanitizeJsonbRecord(metadata); + const { error } = await supabase.rpc("apply_document_metadata_patch", { + p_document_id: documentId, + p_metadata_patch: metadataPatch, + }); + if (!error) return; + if (!isMissingSchemaError(error)) throw supabaseStageError("apply document metadata patch", error); + + // Expand/contract fallback before the R5 migration is applied: best-effort + // shallow merge against the current row (still races under reclaim, same as + // the pre-R5 path). Prefer the RPC once live has the migration. + const { data: current, error: readError } = await supabase + .from("documents") + .select("metadata") + .eq("id", documentId) + .maybeSingle(); + if (readError) throw supabaseStageError("read document metadata for merge fallback", readError); + const { error: fallbackError } = await supabase + .from("documents") + .update({ + metadata: sanitizeJsonbRecord({ + ...((current?.metadata as Record | null) ?? {}), + ...metadataPatch, + }), + }) + .eq("id", documentId); + if (fallbackError) throw supabaseStageError("fallback document metadata merge", fallbackError); + } } async function markSupersededSiblingJobs(job: JobRow) { @@ -1677,8 +1713,10 @@ async function processJob(job: JobRow) { const indexedAt = new Date().toISOString(); const coreAgentMessage = "Core index committed; enrichment pending."; + // R5: send only worker-owned key deltas. apply_document_metadata_patch / + // commit_document_index_generation deep-merge onto live metadata so + // concurrent renames and agent patches are not clobbered by a stale job snapshot. const committedCoreMetadata = { - ...(job.documents.metadata ?? {}), indexed_at: indexedAt, index_generation_id: indexGenerationId, rag_enrichment_version: ragEnrichmentVersion, @@ -1811,8 +1849,10 @@ async function processJob(job: JobRow) { } : { indexing_v3_agent_status: "completed", + // JSON null deletes sticky keys via jsonb_merge_deep (R5). indexing_v3_agent_last_error: null, indexing_v3_agent_repair_reason: null, + completion_gate_missing: null, indexing_v3_agent_updated_at: enrichmentUpdatedAt ?? new Date().toISOString(), }), embedding_model: env.OPENAI_EMBEDDING_MODEL,