diff --git a/.env.example b/.env.example
index 054ab4eabc..c8ad634da0 100644
--- a/.env.example
+++ b/.env.example
@@ -270,6 +270,10 @@ CHUNK_STRATEGY=page
WORKER_POLL_MS=30000
WORKER_BATCH_SIZE=3
WORKER_CONCURRENCY=1
+# Optional comma-separated claim allowlists; both must match when set.
+# Requires the scoped claim RPC migration; blank means unrestricted.
+WORKER_ALLOWED_DOCUMENT_IDS=
+WORKER_ALLOWED_FILE_TYPES=
WORKER_MAX_ATTEMPTS=3
WORKER_STALE_AFTER_MINUTES=45
WORKER_HEALTH_BACKOFF_MS=120000
diff --git a/docs/branch-review-records/98cae095170833559454abb42fc538e3a295a1536724bbf956e26b442827879b.record.md b/docs/branch-review-records/98cae095170833559454abb42fc538e3a295a1536724bbf956e26b442827879b.record.md
new file mode 100644
index 0000000000..7c066139de
--- /dev/null
+++ b/docs/branch-review-records/98cae095170833559454abb42fc538e3a295a1536724bbf956e26b442827879b.record.md
@@ -0,0 +1 @@
+| 2026-10-17 | HEAD | 0537cc935b86824db40ca0c117894b67638e2d95 | PR 3628 review and fixes | Found atomic worker-scope, comparator-unit, telemetry-budget, and document-source retention defects; fixes prepared with regression coverage. | Independent standards and specification inspection of the pinned PR diff; follow-up working-tree targeted proof 295/295 tests and 10 local SQL scenarios; hosted CI pending. |
diff --git a/docs/clinical-hazard-controls.json b/docs/clinical-hazard-controls.json
index 7d566a85ba..2f03865882 100644
--- a/docs/clinical-hazard-controls.json
+++ b/docs/clinical-hazard-controls.json
@@ -198,6 +198,13 @@
"recordedOn": "2026-10-14",
"expiresOn": "2026-10-21"
},
+ {
+ "path": "tests/answer-verification.test.ts",
+ "reason": "PR #3628 adds regression tests for the 'no more than' comparator/unit parsing in src/lib/answer-verification.ts (itself under the existing PR #3546 drift exception). Engineering drift exception per owner ruling 2026-10-18: the owner will review and reseal this test file before this exception expires. Not a clinical review, sign-off or reseal.",
+ "recordedBy": "Engineering, Grok Bot sweep session for PR #3628, per owner ruling 2026-10-18. Engineering drift record only; not clinical-owner approval or release signoff.",
+ "recordedOn": "2026-10-18",
+ "expiresOn": "2026-12-02"
+ },
{
"path": "src/lib/clinical-safety.ts",
"reason": "Drift predates the blocking gate. Safety-behaviour changes since the 2026-08-23 review (e.g. 41b5a025a escalate/monitoring matching, 64db243a9 stop-instruction labelling, 1aa717e88 no all-clear over a failed medication check) need clinical re-review, then a re-seal.",
diff --git a/docs/worker-deploy-runbook.md b/docs/worker-deploy-runbook.md
index 58a542a5ec..a107d9a68a 100644
--- a/docs/worker-deploy-runbook.md
+++ b/docs/worker-deploy-runbook.md
@@ -661,3 +661,22 @@ Redeploy the previous image tag. The worker holds no durable local state; all
artifact writes are idempotent per generation/chunk-key, and completion is
gated by the strict completion RPCs plus the edge agent, so a redeploy costs at
most one stale window of latency on any in-flight job and zero data loss.
+
+### Scoped Node worker claims
+
+`WORKER_ALLOWED_DOCUMENT_IDS` is a comma-separated UUID allowlist;
+`WORKER_ALLOWED_FILE_TYPES` is a comma-separated MIME-type allowlist (for example,
+`text/plain,application/pdf`). Blank lists leave that dimension unrestricted; a
+non-blank list with no valid entries (for example `,`), or any document ID that is not
+a UUID, claims no jobs at all and logs the configuration error once;
+when both lists are set, a job must satisfy both. MIME types are case-insensitive
+and ignore parameters such as `charset`. UUIDs are normalized to lowercase.
+
+The worker passes active lists to the five-argument `claim_ingestion_jobs` overload.
+The database filters candidates before acquiring leases, then rechecks the locked
+document's MIME type. Unrelated queued jobs neither block scoped jobs nor consume
+attempts. Existing workers with no scope keep using the three-argument contract.
+A scoped worker requires the scope migration; a missing overload fails closed
+rather than falling back to an unfiltered claim. The migration changes the live
+database when merged to `main`: merge only inside the owner's specifically
+approved database-change window. No live database change is authorized by this PR.
diff --git a/scripts/claude-session-start.mjs b/scripts/claude-session-start.mjs
index 971c954729..70da79791b 100644
--- a/scripts/claude-session-start.mjs
+++ b/scripts/claude-session-start.mjs
@@ -55,7 +55,10 @@ for (const [label, command, args, timeout] of steps) {
const result = spawnSync(command, args, { cwd: repoRoot, input: payload, encoding: "utf8", timeout });
collect(result.stdout || "", label === "cloud setup");
if (result.stderr) process.stderr.write(result.stderr);
- if (result.status !== 0 || result.error)
+ // A dependent that exits 0 without reading stdin races the payload write and leaves
+ // EPIPE in result.error; it still completed, so only other errors count as failure.
+ const failed = result.status !== 0 || (result.error && result.error.code !== "EPIPE");
+ if (failed)
contexts.push(`SessionStart ${label} did not complete; check its diagnostics before trusting related checks.`);
}
if (contexts.length)
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index 402aa6d805..9dcdd6b5c9 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -15,6 +15,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges";
import { ContextualBackLink } from "@/components/contextual-back-link";
+import { documentFileKindFromType } from "@/components/clinical-dashboard/document-ui";
import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner";
import { PhoneHeaderCollapsePortal } from "@/components/clinical-dashboard/phone-header-collapse-portal";
import { useDocumentViewerChromeScroll } from "@/components/clinical-dashboard/use-document-viewer-chrome-scroll";
@@ -1010,6 +1011,7 @@ export function DocumentViewer({
const generatedAnswerIsSummary = summaryQuery === documentSummaryQuestion;
const storedSummaryText = document?.summary?.summary ?? null;
const documentLabels = document?.labels;
+ const viewerFileKind = documentFileKindFromType(document?.file_type, document?.file_name, "PDF");
const formattedStoredSummary = useMemo(() => formatDocumentSummary(storedSummaryText), [storedSummaryText]);
const summaryBadges = useMemo(
() => buildDocumentSummaryBadges({ labels: documentLabels, summaryText: storedSummaryText }),
@@ -1332,7 +1334,7 @@ export function DocumentViewer({
- Open original PDF
+ {`Open original ${viewerFileKind}`}
) : (
- Open original PDF
+ {`Open original ${viewerFileKind}`}
)}
)}
- {downloadingSource ? "Preparing PDF" : "Download PDF"}
+ {downloadingSource ? `Preparing ${viewerFileKind}` : `Download ${viewerFileKind}`}
{canUseAdministrativeApis ? (
@@ -1476,7 +1478,7 @@ export function DocumentViewer({
src={previewFrameSource}
controls={pdfFrameControls}
{...(effectiveLoadingDocument
- ? { state: "loading" as const, loadingLabel: "Preparing PDF preview" }
+ ? { state: "loading" as const, loadingLabel: `Preparing ${viewerFileKind} preview` }
: effectiveViewerError || previewError
? {
state: "error" as const,
@@ -1488,7 +1490,7 @@ export function DocumentViewer({
effectiveLoadingDocument ? (
Loading source metadata
-
Preparing PDF preview
+
{`Preparing ${viewerFileKind} preview`}
Loading extracted tables
) : undefined
@@ -1498,7 +1500,7 @@ export function DocumentViewer({
{signedUrl ? (
- Source PDF
+ {`Source ${viewerFileKind}`}
) : null}
{downloadSignedUrl ? (
@@ -1513,7 +1515,7 @@ export function DocumentViewer({
) : (
)}
- {downloadingSource ? "Preparing PDF" : "Download PDF"}
+ {downloadingSource ? `Preparing ${viewerFileKind}` : `Download ${viewerFileKind}`}
) : null}
>
diff --git a/src/components/clinical-dashboard/document-ui.tsx b/src/components/clinical-dashboard/document-ui.tsx
index 07bae79ba8..37a421c7d6 100644
--- a/src/components/clinical-dashboard/document-ui.tsx
+++ b/src/components/clinical-dashboard/document-ui.tsx
@@ -47,6 +47,30 @@ export function documentFileKind(fileName?: string | null, fallback = "PDF") {
return extension || fallback;
}
+const mimeFileKinds: Record = {
+ "application/pdf": "PDF",
+ "text/plain": "TXT",
+ "text/csv": "CSV",
+ "application/msword": "DOC",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX",
+ "application/vnd.ms-excel": "XLS",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX",
+ "image/png": "PNG",
+ "image/jpeg": "JPG",
+ "image/gif": "GIF",
+ "image/webp": "WEBP",
+ "image/svg+xml": "SVG",
+};
+
+/** The served file's kind: a recognised MIME type wins; the filename extension is only a fallback. */
+export function documentFileKindFromType(fileType?: string | null, fileName?: string | null, fallback = "PDF"): string {
+ const mime = fileType?.split(";")[0]?.trim().toLowerCase();
+ if (mime && mimeFileKinds[mime]) return mimeFileKinds[mime];
+ // The viewer renders any image/* as an image, so never fall back to "PDF" for one.
+ if (mime?.startsWith("image/")) return documentFileKind(fileName, "IMAGE");
+ return documentFileKind(fileName, fallback);
+}
+
export function documentTileTone(kind: string): DocumentTileTone {
const normalized = kind.toUpperCase();
return normalized === "DOC" || normalized === "DOCX" ? "info" : "teal";
diff --git a/src/components/document-viewer/document-overview-landing.tsx b/src/components/document-viewer/document-overview-landing.tsx
index e48cb14e13..9eddbb0316 100644
--- a/src/components/document-viewer/document-overview-landing.tsx
+++ b/src/components/document-viewer/document-overview-landing.tsx
@@ -9,7 +9,7 @@ import {
DocumentActionButton,
DocumentFileTile,
DocumentMetaRow,
- documentFileKind,
+ documentFileKindFromType,
documentTileTone,
} from "@/components/clinical-dashboard/document-ui";
import { cn, panel, floatingControl, ignoreUnavailableActivation, primaryControl } from "@/components/ui-primitives";
@@ -20,7 +20,7 @@ const primaryButton = primaryControl;
const secondaryButton = floatingControl;
function compactDocumentType(document: ClinicalDocument) {
- return documentFileKind(document.file_name, "PDF");
+ return documentFileKindFromType(document.file_type, document.file_name, "PDF");
}
// Derive the header eyebrow from the document's real type instead of asserting
diff --git a/src/lib/answer-verification.ts b/src/lib/answer-verification.ts
index d4c94182bf..4854415b7a 100644
--- a/src/lib/answer-verification.ts
+++ b/src/lib/answer-verification.ts
@@ -112,11 +112,11 @@ export type LabelledNumericBandConflict = {
};
const clinicalQuantityPattern =
- /\b(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most)\s+)?(\d+(?:[.,]\d+)?)(?:\s*[-–—]\s*(\d+(?:[.,]\d+)?))?\s*(ug|µg|μg|mcg|micrograms?|mg|g|kg|ml|l|mmol|mol|umol|µmol|ng|units?|iu|milliseconds?|msecs?|ms|tablets?|puffs?|hours?|hrs?|h|days?|weeks?|wk|months?|minutes?|mins?|years?|°c|mmhg|bpm|%)\b((?:\s*\/\s*(?:kg|m2|dose|seconds?|secs?|minutes?|mins?|hours?|hrs?|h|days?|weeks?|wk|ml|l)){0,3})/giu;
+ /\b(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than)\s+)?(\d+(?:[.,]\d+)?)(?:\s*[-–—]\s*(\d+(?:[.,]\d+)?))?\s*(ug|µg|μg|mcg|micrograms?|mg|g|kg|ml|l|mmol|mol|umol|µmol|ng|units?|iu|milliseconds?|msecs?|ms|tablets?|puffs?|hours?|hrs?|h|days?|weeks?|wk|months?|minutes?|mins?|years?|°c|mmhg|bpm|%)\b((?:\s*\/\s*(?:kg|m2|dose|seconds?|secs?|minutes?|mins?|hours?|hrs?|h|days?|weeks?|wk|ml|l)){0,3})/giu;
const scientificQuantityPattern =
- /(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)\s*[×x]\s*10\^?(\d+)\s*\/\s*([a-z])/giu;
+ /(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)\s*[×x]\s*10\^?(\d+)\s*\/\s*([a-z])/giu;
const unitlessThresholdPattern =
- /(?:\b(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most)\s+|(<=|>=|<|>|≤|≥)\s*)(\d+(?:[.,]\d+)?)(?:\s*[-–—]\s*(\d+(?:[.,]\d+)?))?/giu;
+ /(?:\b(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than)\s+|(<=|>=|<|>|≤|≥)\s*)(\d+(?:[.,]\d+)?)(?:\s*[-–—]\s*(\d+(?:[.,]\d+)?))?/giu;
// Infer omitted years only inside explicit adjacent population-age grammar.
// Closed continuations exclude an unknown measurement/category noun after the value.
const populationAgePrefixPattern = /\b(?:children|adolescents|adults|people|young[ \t]+people)(?:[ \t]+aged?)?[ \t]+$/i;
@@ -344,11 +344,11 @@ type LabelledNumericBand = {
};
const labelledNumericBandPattern =
- /\b(very\s+(?:high|low|severe)|borderline|moderate|medium|minimal|normal|high|low|mild|severe|green|amber|red)\b(?:\s+(?:scores?|ranges?|bands?|categor(?:y|ies)|zones?|severit(?:y|ies)|risks?|levels?))?\s*(?:(?:is|are|of|:|=|\|)\s*|(?:ranges?\s+)?from\s*)?\(?\s*(?:scores?\s*)?(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)(?:\s*(?:[-–—]|\bto\b|\bthrough\b)\s*(\d+(?:[.,]\d+)?))?(?:\s*(%|points?|mg|mcg|ug|µg|μg|g|kg|mmol(?:\s*\/\s*l)?|mol(?:\s*\/\s*l)?|ng(?:\s*\/\s*ml)?))?\s*\)?/giu;
+ /\b(very\s+(?:high|low|severe)|borderline|moderate|medium|minimal|normal|high|low|mild|severe|green|amber|red)\b(?:\s+(?:scores?|ranges?|bands?|categor(?:y|ies)|zones?|severit(?:y|ies)|risks?|levels?))?\s*(?:(?:is|are|of|:|=|\|)\s*|(?:ranges?\s+)?from\s*)?\(?\s*(?:scores?\s*)?(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)(?:\s*(?:[-–—]|\bto\b|\bthrough\b)\s*(\d+(?:[.,]\d+)?))?(?:\s*(%|points?|mg|mcg|ug|µg|μg|g|kg|mmol(?:\s*\/\s*l)?|mol(?:\s*\/\s*l)?|ng(?:\s*\/\s*ml)?))?\s*\)?/giu;
const numericBeforeLabelBandPattern =
- /(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)(?:\s*(?:[-–—]|\bto\b|\bthrough\b)\s*(\d+(?:[.,]\d+)?))?(?:\s*(%|points?|mg|mcg|ug|µg|μg|g|kg|mmol(?:\s*\/\s*l)?|mol(?:\s*\/\s*l)?|ng(?:\s*\/\s*ml)?))?\s*(?:\(\s*|\|\s*)(very\s+(?:high|low|severe)|borderline|moderate|medium|minimal|normal|high|low|mild|severe|green|amber|red)\s*(?:\)|\|)/giu;
+ /(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)(?:\s*(?:[-–—]|\bto\b|\bthrough\b)\s*(\d+(?:[.,]\d+)?))?(?:\s*(%|points?|mg|mcg|ug|µg|μg|g|kg|mmol(?:\s*\/\s*l)?|mol(?:\s*\/\s*l)?|ng(?:\s*\/\s*ml)?))?\s*(?:\(\s*|\|\s*)(very\s+(?:high|low|severe)|borderline|moderate|medium|minimal|normal|high|low|mild|severe|green|amber|red)\s*(?:\)|\|)/giu;
const numericBandPredicatePattern =
- /(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)(?:\s*(?:[-–—]|\bto\b|\bthrough\b)\s*(\d+(?:[.,]\d+)?))?(?:\s*(%|points?|mg|mcg|ug|µg|μg|g|kg|mmol(?:\s*\/\s*l)?|mol(?:\s*\/\s*l)?|ng(?:\s*\/\s*ml)?))?\s+(?:is|are|means?|indicates?|corresponds?\s+to)\s+(very\s+(?:high|low|severe)|borderline|moderate|medium|minimal|normal|high|low|mild|severe|green|amber|red)\b/giu;
+ /(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)(?:\s*(?:[-–—]|\bto\b|\bthrough\b)\s*(\d+(?:[.,]\d+)?))?(?:\s*(%|points?|mg|mcg|ug|µg|μg|g|kg|mmol(?:\s*\/\s*l)?|mol(?:\s*\/\s*l)?|ng(?:\s*\/\s*ml)?))?\s+(?:is|are|means?|indicates?|corresponds?\s+to)\s+(very\s+(?:high|low|severe)|borderline|moderate|medium|minimal|normal|high|low|mild|severe|green|amber|red)\b/giu;
const labelFirstBetweenBandPattern =
/\b(very\s+(?:high|low|severe)|borderline|moderate|medium|minimal|normal|high|low|mild|severe|green|amber|red)\b(?:\s+(?:scores?|ranges?|bands?|categor(?:y|ies)|zones?|severit(?:y|ies)|risks?|levels?))?\s*(?:(?:is|are|of|:|=|\|)\s*)?\(?\s*(?:scores?\s*)?between\s+(\d+(?:[.,]\d+)?)\s+and\s+(\d+(?:[.,]\d+)?)(?:\s*(%|points?|mg|mcg|ug|µg|μg|g|kg|mmol(?:\s*\/\s*l)?|mol(?:\s*\/\s*l)?|ng(?:\s*\/\s*ml)?))?\s*\)?/giu;
const betweenFirstLabelBandPattern =
@@ -902,7 +902,7 @@ type NumericBandReference = {
};
const numericBandReferencePattern =
- /\bbetween\s+(\d+(?:[.,]\d+)?)\s+and\s+(\d+(?:[.,]\d+)?)|(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)(?:\s*(?:[-–—]|\bto\b|\bthrough\b)\s*(\d+(?:[.,]\d+)?))?/giu;
+ /\bbetween\s+(\d+(?:[.,]\d+)?)\s+and\s+(\d+(?:[.,]\d+)?)|(?:(below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than|<=|>=|<|>|≤|≥)\s*)?(\d+(?:[.,]\d+)?)(?:\s*(?:[-–—]|\bto\b|\bthrough\b)\s*(\d+(?:[.,]\d+)?))?/giu;
function numericBandReferences(text: string): NumericBandReference[] {
return Array.from(text.matchAll(numericBandReferencePattern), (match) => {
@@ -1287,7 +1287,10 @@ function clinicalValueAtomKey(atom: ClinicalValueAtom) {
function clinicalValueAtomDisplay(atom: ClinicalValueAtom) {
if (atom.kind !== "quantity") return atom.canonicalValue;
return normalizeNumericToken(
- atom.rawText.replace(/^\s*(?:below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most)\s+/i, ""),
+ atom.rawText.replace(
+ /^\s*(?:below|under|less\s+than|above|over|greater\s+than|at\s+least|at\s+most|no\s+more\s+than)\s+/i,
+ "",
+ ),
);
}
diff --git a/src/lib/rag/rag-answer-shape-checks.ts b/src/lib/rag/rag-answer-shape-checks.ts
index 400d9120f2..a1f856996e 100644
--- a/src/lib/rag/rag-answer-shape-checks.ts
+++ b/src/lib/rag/rag-answer-shape-checks.ts
@@ -171,13 +171,13 @@ const openingSentenceActionPattern =
const drinkLimitQuantityPeriodPattern = String.raw`\d+(?:\.\d+)?\s+standard\s+drinks?\s+(?:a\s+(?:week|day)|per\s+(?:week|day)|on\s+any\s+one\s+day)`;
const openingDrinkLimitImperativePattern = new RegExp(
- String.raw`^(?:for\s+[^,.;!?]{1,140},\s*)?drink\s+no\s+more\s+than\s+${drinkLimitQuantityPeriodPattern}(?:\s+and\s+no\s+more\s+than\s+${drinkLimitQuantityPeriodPattern})?(?:;\s+less\s+drinking\s+further\s+lowers\s+risk)?[.!?]["')\]]*$`,
+ String.raw`^(?:for\s+[^,.;!?]{1,140},\s*)?drink\s+(?:no\s+more\s+than|at\s+most)\s+${drinkLimitQuantityPeriodPattern}(?:\s+and\s+(?:no\s+more\s+than|at\s+most)\s+${drinkLimitQuantityPeriodPattern})?(?:;\s+less\s+drinking\s+further\s+lowers\s+risk)?[.!?]["')\]]*$`,
"i",
);
// The recorded adult instruction shares its upper-limit verb across both periods.
const openingSharedDrinkLimitImperativePattern =
- /^for healthy adults aged \d+ years and over, drink no more than \d+ standard drinks a week and \d+ standard drinks on any one day;? drinking less further reduces risk\.$/i;
+ /^for healthy adults aged \d+ years and over, drink (?:no more than|at most) \d+ standard drinks a week and \d+ standard drinks on any one day;? drinking less further reduces risk\.$/i;
/** First sentence. */
export function firstSentence(value: string) {
diff --git a/src/lib/rag/rag-answer-telemetry-metadata.ts b/src/lib/rag/rag-answer-telemetry-metadata.ts
index f648409407..4e240ae4ba 100644
--- a/src/lib/rag/rag-answer-telemetry-metadata.ts
+++ b/src/lib/rag/rag-answer-telemetry-metadata.ts
@@ -4,11 +4,14 @@ import { buildCrossDocumentFusionBrief, buildCrossDocumentSynthesisPlan } from "
import { buildAnswerScoreExplanations, buildIndexingQuality, collectMemoryCards } from "@/lib/rag/rag-answer-support";
import { memoryCardChunkScore } from "@/lib/rag/rag-candidate-sources";
import {
+ generationDegradationLogMetadata,
readObservedGenerationDegradation,
type RagGenerationDegradationReason,
} from "@/lib/rag/rag-generation-degradation";
import type { RagAnswer, RagQueryClass } from "@/lib/types";
+export { generationDegradationLogMetadata };
+
type AnswerLatencyTimings = NonNullable;
/**
diff --git a/src/lib/rag/rag-document-support-list.ts b/src/lib/rag/rag-document-support-list.ts
new file mode 100644
index 0000000000..96eb8b4421
--- /dev/null
+++ b/src/lib/rag/rag-document-support-list.ts
@@ -0,0 +1,131 @@
+import { normalizedClinicalSearchTokens } from "@/lib/clinical-search";
+import { buildDocumentBreakdown, extractQuoteCards } from "@/lib/evidence";
+import { evidenceTextForGate } from "@/lib/rag/rag-answer-support";
+import { normalizeSectionText } from "@/lib/rag/rag-answer-text";
+import { finalQualityGapAnswer } from "@/lib/rag/rag-answer-shape-checks";
+import { queryTokenMatchesText } from "@/lib/rag/rag-extractive-signals";
+import type { AnswerSection, SearchResult } from "@/lib/types";
+
+const documentLookupMetaTokenPattern =
+ /^(?:which|what|list|show|name|where|find|provide|give|gives|support|supports|supporting|sources?|documents?|guidelines?|polic(?:y|ies|ie)|protocols?|procedures?|manuals?|guidance|indexed|reviews?|sets?|states?|says?|describes?|mentions?|mentioned|covers?|contains?|discuss(?:e|es|ed|ing)?|address(?:e|es|ed|ing)?|outlines?|outlined|recommendations?|recommends?|recommended|explains?|explained|relevant|any|there|have|has|our|about|on|for|with|from|that|this|the|and)$/i;
+
+/** Build document support list answer. */
+export function buildDocumentSupportListAnswer(args: {
+ query: string;
+ results: SearchResult[];
+ filterToClaimBearingDocuments?: boolean;
+}) {
+ let scopedResults = args.results;
+ let qualifierTokens: string[] = [];
+ const matchesByResult = new Map();
+ if (args.filterToClaimBearingDocuments) {
+ qualifierTokens = Array.from(
+ new Set(
+ normalizedClinicalSearchTokens(args.query).filter((token) => !documentLookupMetaTokenPattern.test(token)),
+ ),
+ );
+ if (qualifierTokens.length > 0) {
+ const matchesByDocument = new Map>();
+ for (const result of args.results) {
+ const evidence = evidenceTextForGate(result);
+ const matches = qualifierTokens.filter((token) => queryTokenMatchesText(token, evidence));
+ matchesByResult.set(result.id, matches);
+ const documentMatches = matchesByDocument.get(result.document_id) ?? new Set();
+ matches.forEach((token) => documentMatches.add(token));
+ matchesByDocument.set(result.document_id, documentMatches);
+ }
+ // A partial hit does not support a compound query. Accumulate qualifiers across
+ // a document's chunks, then retain the chunks that supply its matching evidence.
+ scopedResults = args.results.filter(
+ (result) =>
+ matchesByDocument.get(result.document_id)?.size === qualifierTokens.length &&
+ (matchesByResult.get(result.id)?.length ?? 0) > 0,
+ );
+ }
+ }
+ // Each named document is cited by the chunks that together cover the qualifiers
+ // (one chunk when no qualifier filter applies). A document is named only when its
+ // whole covering set fits the five-citation budget, never with partial support.
+ const citationBudget = 5;
+ const coveringChunkIds = (documentId: string) => {
+ const chunks = scopedResults.filter((result) => result.document_id === documentId);
+ if (qualifierTokens.length === 0 || matchesByResult.size === 0) return chunks.slice(0, 1).map((r) => r.id);
+ // Smallest covering set within the citation budget (earliest chunks on ties), so a
+ // document never spends citations another named document needs. Chunks whose matches
+ // are a subset of an earlier chunk's add nothing, which keeps the search tiny.
+ const candidates: { id: string; matches: string[] }[] = [];
+ for (const chunk of chunks) {
+ const matches = matchesByResult.get(chunk.id) ?? [];
+ if (matches.length === 0) continue;
+ if (candidates.some((c) => matches.every((token) => c.matches.includes(token)))) continue;
+ candidates.push({ id: chunk.id, matches });
+ }
+ let ids: string[] = [];
+ const search = (from: number, picked: number[], size: number): boolean => {
+ if (picked.length === size) {
+ const covered = new Set(picked.flatMap((i) => candidates[i].matches));
+ if (covered.size < qualifierTokens.length) return false;
+ ids = picked.map((i) => candidates[i].id);
+ return true;
+ }
+ for (let i = from; i < candidates.length; i += 1) if (search(i + 1, [...picked, i], size)) return true;
+ return false;
+ };
+ for (let size = 1; size <= Math.min(citationBudget, candidates.length); size += 1) if (search(0, [], size)) break;
+ // Keep the citation order aligned with the source order.
+ return chunks.filter((chunk) => ids.includes(chunk.id)).map((chunk) => chunk.id);
+ };
+ const citationChunkIds: string[] = [];
+ const documents = buildDocumentBreakdown(scopedResults, extractQuoteCards(scopedResults, args.query))
+ .filter((document) => {
+ const ids = coveringChunkIds(document.document_id);
+ if (ids.length === 0 || citationChunkIds.length + ids.length > citationBudget) return false;
+ citationChunkIds.push(...ids);
+ return true;
+ })
+ .slice(0, citationBudget);
+ // Remaining budget goes to the named documents' other matching chunks, as before.
+ for (const result of scopedResults) {
+ if (citationChunkIds.length >= citationBudget) break;
+ if (citationChunkIds.includes(result.id)) continue;
+ if (documents.some((document) => document.document_id === result.document_id)) citationChunkIds.push(result.id);
+ }
+ if (!documents.length) {
+ const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup");
+ return {
+ answer: gapAnswer,
+ citationChunkIds: [] as string[],
+ supportListCitationResultIds: [] as string[],
+ answerSections: [] as AnswerSection[],
+ };
+ }
+ const names = documents
+ .map((document) =>
+ normalizeSectionText(document.title || document.file_name)
+ .replace(/([a-zA-Z])\(/g, "$1 (")
+ .replace(/\s{2,}/g, " ")
+ .trim(),
+ )
+ .filter(Boolean);
+ const answer =
+ names.length === 1
+ ? `I found one indexed document that supports this query: ${names[0]}.`
+ : `I found ${names.length} indexed documents that support this query: ${names.slice(0, -1).join("; ")}; and ${names.at(-1)}.`;
+ return {
+ answer,
+ preformatted: true,
+ citationChunkIds,
+ supportListCitationResultIds: scopedResults
+ .filter((result) => documents.some((document) => document.document_id === result.document_id))
+ .map((result) => result.id),
+ answerSections: [
+ {
+ heading: "Document matches",
+ kind: "documentation",
+ supportLevel: "direct",
+ body: names.join("; "),
+ citation_chunk_ids: citationChunkIds,
+ },
+ ] satisfies AnswerSection[],
+ };
+}
diff --git a/src/lib/rag/rag-extractive-builder.ts b/src/lib/rag/rag-extractive-builder.ts
index db96fafb33..ed7c55c34e 100644
--- a/src/lib/rag/rag-extractive-builder.ts
+++ b/src/lib/rag/rag-extractive-builder.ts
@@ -17,6 +17,7 @@ import {
isAtomicNmhsClozapineRedRangeSource,
buildClozapineBloodActionThresholdAnswer,
} from "@/lib/rag/rag-source-bound-policy-procedures";
+import { buildDocumentSupportListAnswer } from "@/lib/rag/rag-document-support-list";
import { boldHighYieldClinicalText } from "@/lib/answer-ranking";
import {
adjacentLabelledNumericBandConflicts,
@@ -1770,9 +1771,11 @@ function buildFactSynthesizedAnswer(args: {
sourceBackedDocumentFallbackIntent(args.query, args.queryClass, args.intent, fallbackResults) ||
sourceBackedManagementReviewIntent(args.query, args.queryClass, args.intent, fallbackResults)
) {
+ const supportListAnswer = buildDocumentSupportListAnswer({ query: args.query, results: fallbackResults });
return {
- ...buildDocumentSupportListAnswer({ query: args.query, results: fallbackResults }),
- supportListCitationResultIds: fallbackResults.map((result) => result.id),
+ ...supportListAnswer,
+ supportListCitationResultIds:
+ supportListAnswer.supportListCitationResultIds ?? fallbackResults.map((result) => result.id),
};
}
const gapAnswer = finalQualityGapAnswer(args.query, args.queryClass, args.intent);
@@ -1966,59 +1969,6 @@ function buildTableOrVisualSourceLookupAnswer(args: { query: string; results: Se
};
}
-/** Build document support list answer. */
-function buildDocumentSupportListAnswer(args: { query: string; results: SearchResult[] }) {
- const documents = buildDocumentBreakdown(args.results, extractQuoteCards(args.results, args.query)).slice(0, 5);
- if (!documents.length) {
- const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup");
- return { answer: gapAnswer, citationChunkIds: [] as string[], answerSections: [] as AnswerSection[] };
- }
- const names = documents
- .map((document) =>
- normalizeSectionText(document.title || document.file_name)
- .replace(/([a-zA-Z])\(/g, "$1 (")
- .replace(/\s{2,}/g, " ")
- .trim(),
- )
- .filter(Boolean);
- const answer =
- names.length === 1
- ? `I found one indexed document that supports this query: ${names[0]}.`
- : `I found ${names.length} indexed documents that support this query: ${names.slice(0, -1).join("; ")}; and ${names.at(-1)}.`;
- return {
- answer,
- preformatted: true,
- citationChunkIds: Array.from(
- new Set(
- documents.flatMap((document) =>
- args.results
- .filter((result) => result.document_id === document.document_id)
- .slice(0, 1)
- .map((result) => result.id),
- ),
- ),
- ),
- answerSections: [
- {
- heading: "Document matches",
- kind: "documentation",
- supportLevel: "direct",
- body: names.join("; "),
- citation_chunk_ids: Array.from(
- new Set(
- documents.flatMap((document) =>
- args.results
- .filter((result) => result.document_id === document.document_id)
- .slice(0, 1)
- .map((result) => result.id),
- ),
- ),
- ),
- },
- ] satisfies AnswerSection[],
- };
-}
-
/**
* Keep the citations that support delivered extractive answer text ahead of
* already-ranked supporting citations, without admitting unknown sources.
@@ -2083,7 +2033,7 @@ export function buildExtractiveAnswer(args: {
}) {
const results = args.results.filter((result) => !resultContainsProceduralFlowEdgeArtifact(result));
const removedProceduralArtifact = results.length !== args.results.length;
- const rebuildDerivedArtifacts =
+ let rebuildDerivedArtifacts =
removedProceduralArtifact ||
derivedArtifactsContainProceduralFlowEdge({
quoteCards: args.quoteCards,
@@ -2135,7 +2085,7 @@ export function buildExtractiveAnswer(args: {
const answerIntent = classifyAnswerIntent(args.query, args.queryClass);
const naturalAnswer = documentSupportListIntent(args.query, args.queryClass)
- ? buildDocumentSupportListAnswer({ query: args.query, results })
+ ? buildDocumentSupportListAnswer({ query: args.query, results, filterToClaimBearingDocuments: true })
: tableOrVisualSourceLookupIntent(args.query, args.queryClass, answerIntent)
? buildTableOrVisualSourceLookupAnswer({ query: args.query, results })
: buildFactSynthesizedAnswer({
@@ -2213,6 +2163,7 @@ export function buildExtractiveAnswer(args: {
(card.source_chunk_ids ?? []).some((chunkId) => supportListCitationIdSet.has(chunkId)),
)
: memoryCards;
+ rebuildDerivedArtifacts ||= answerSources.length !== results.length;
const rebuiltDocumentBreakdown = rebuildDerivedArtifacts
? buildDocumentBreakdown(answerSources, finalQuoteCards)
: args.documentBreakdown;
diff --git a/src/lib/rag/rag-generation-degradation.ts b/src/lib/rag/rag-generation-degradation.ts
index c1887e4d2f..4fb957951a 100644
--- a/src/lib/rag/rag-generation-degradation.ts
+++ b/src/lib/rag/rag-generation-degradation.ts
@@ -152,11 +152,39 @@ export function readObservedGenerationDegradation(answer: unknown): RagGeneratio
);
}
+export function generationDegradationLogMetadata(
+ degradation: RagGenerationDegradationRecord | null | undefined,
+ remainingRouteBudgetMs?: number,
+): Record {
+ const projected = projectGenerationDegradation(degradation ?? undefined);
+ if (!projected || projected.attempts.length === 0) return {};
+ return {
+ generation_degradation_reason: projected.reason,
+ generation_degradation_attempt_count: projected.attempts.length,
+ generation_degradation_completed_response_count: projected.completedResponseCount,
+ generation_degradation_total_attempt_latency_ms: projected.totalAttemptLatencyMs,
+ ...(typeof remainingRouteBudgetMs === "number" && Number.isFinite(remainingRouteBudgetMs)
+ ? { generation_degradation_remaining_route_budget_ms: count(remainingRouteBudgetMs, 35000) }
+ : {}),
+ provider_initial_attempt_timeout: projected.reason === "provider_initial_attempt_timeout",
+ provider_quality_retry_exhausted: projected.reason === "provider_quality_retry_exhausted",
+ };
+}
+
export function createGenerationDegradationRecorder(options: {
- enabled: boolean;
+ enabled?: boolean;
+ args?: { ragProgrammeRollout?: { servedMode?: string } | null; ragQueryPlanMode?: string | null };
+ remainingRouteBudgetMs?: () => number;
routeBudgetMs: number;
contract?: RagAnswerGenerationContract;
}) {
+ const enabled =
+ options.enabled ??
+ (options.args?.ragProgrammeRollout?.servedMode === "candidate" ||
+ options.args?.ragQueryPlanMode === "shadow" ||
+ options.args?.ragQueryPlanMode === "canary");
+ let lastRecord: RagGenerationDegradationRecord | undefined;
+ let remainingRouteBudgetMs: number | undefined;
const contract = options.contract ?? legacyAnswerGenerationContract;
const attempts: GenerationAttempt[] = [];
let nextStage: GenerationAttempt["stage"] = "initial";
@@ -165,7 +193,7 @@ export function createGenerationDegradationRecorder(options: {
let observationComplete = true;
const current = () => (observationComplete ? attempts.at(-1) : undefined);
return {
- enabled: options.enabled,
+ enabled,
start(
input: Pick<
GenerationAttempt,
@@ -255,10 +283,15 @@ export function createGenerationDegradationRecorder(options: {
totalAttemptLatencyMs: attempts.reduce((sum, a) => sum + a.latencyMs, 0),
completedOutput: generationCompletedOutput(answer),
}) ?? undefined;
+ lastRecord = record;
+ remainingRouteBudgetMs = options.remainingRouteBudgetMs?.();
if (record && answer && typeof answer === "object") {
observedGenerationDegradationByAnswer.set(answer, record);
}
- return options.enabled ? record : undefined;
+ return enabled ? record : undefined;
+ },
+ metadata(): Record {
+ return generationDegradationLogMetadata(lastRecord, remainingRouteBudgetMs);
},
};
}
diff --git a/src/lib/rag/rag-original-section-delivery.ts b/src/lib/rag/rag-original-section-delivery.ts
index 5c20aa6509..d5c92d2a45 100644
--- a/src/lib/rag/rag-original-section-delivery.ts
+++ b/src/lib/rag/rag-original-section-delivery.ts
@@ -594,10 +594,10 @@ function legacyOriginalSectionSentenceSupported(
return true;
const adultAge = source.eligibility?.match(/aged (\d+) years and over/),
limits = source.recommendation?.match(
- /no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day/,
+ /(?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day/,
);
const original = text.match(
- /^for healthy adults aged (\d+) years and over, drink no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day(?:; less is lower risk, and these limits are not risk-free)?\.$/,
+ /^for healthy adults aged (\d+) years and over, drink (?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day(?:; less is lower risk, and these limits are not risk-free)?\.$/,
);
if (
original &&
@@ -647,16 +647,16 @@ function originalSectionSentenceSupported(
): VerifiedOriginalSectionSentence => ({ sourceId: row.id, role, renderedText, pregnancyPurposeAdded });
const age = source.eligibility?.match(/aged (\d+) years and over/)?.[1];
const limits = source.recommendation?.match(
- /no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day/,
+ /(?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day/,
);
const genuineAdult = text.match(
- /^for healthy adults aged (\d+) years and over, the recommendation is no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day; (?:drinking less, or not drinking, further reduces harm|less drinking further lowers risk)\.$/,
+ /^for healthy adults aged (\d+) years and over, the recommendation is (?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day; (?:drinking less, or not drinking, further reduces harm|less drinking further lowers risk)\.$/,
);
const qualifiedAdult = text.match(
- /^for healthy adults aged (\d+) years and over, drink no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day; less alcohol further reduces harm, and no drinking is best for reducing risk\.$/,
+ /^for healthy adults aged (\d+) years and over, drink (?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day; less alcohol further reduces harm, and no drinking is best for reducing risk\.$/,
);
const sharedLimiterAdult = text.match(
- /^for healthy adults aged (\d+) years and over, drink no more than (\d+) standard drinks a week and (\d+) standard drinks on any one day; drinking less further reduces risk\.$/,
+ /^for healthy adults aged (\d+) years and over, drink (?:no more than|at most) (\d+) standard drinks a week and (\d+) standard drinks on any one day; drinking less further reduces risk\.$/,
);
const legacySupported = legacyOriginalSectionSentenceSupported(sentence, ids, source);
const legacyAdult = legacySupported && /^for healthy adults aged\b/.test(text);
@@ -800,7 +800,7 @@ function sectionDeliveryObligations(
: [];
const a = construction;
const adultValues = a.recommendation?.match(
- /no more than (\d+) standard drinks a week and no more than (\d+) standard drinks on any one day/,
+ /(?:no more than|at most) (\d+) standard drinks a week and (?:no more than|at most) (\d+) standard drinks on any one day/,
);
const adultAge = a.eligibility?.match(/aged (\d+) years and over/);
const youngAge = a.youngRecommendation?.match(/under (\d+) years of age/);
@@ -814,7 +814,7 @@ function sectionDeliveryObligations(
Boolean(
adultValues &&
new RegExp(
- `healthy (?:men and women|adults).*no more than ${adultValues[1]} standard drinks a week and no more than ${adultValues[2]} standard drinks on any one day`,
+ `healthy (?:men and women|adults).*(?:no more than|at most) ${adultValues[1]} standard drinks (?:a|per|each) week and (?:no more than|at most) ${adultValues[2]} standard drinks (?:on any one day|in any (?:one|single) day|per day)`,
"i",
).test(sectionProse(text)),
),
diff --git a/src/lib/rag/rag-source-bound-alcohol-summary.ts b/src/lib/rag/rag-source-bound-alcohol-summary.ts
index 2e8a45f56b..8ba5d018ad 100644
--- a/src/lib/rag/rag-source-bound-alcohol-summary.ts
+++ b/src/lib/rag/rag-source-bound-alcohol-summary.ts
@@ -163,7 +163,11 @@ export function renderSourceBoundAlcoholSummary(
week.claim.match(/\d+/)?.[0] !== weekly ||
day.claim.match(/\d+/)?.[0] !== daily ||
!/^(?:(?:for|should|drink|and)\b|[\s,;:.])*$/i.test(attribution ? attribution.text : adultRemainder) ||
- !sourceDirectlySupportsAnswerText(adultClause, adult)
+ !(
+ sourceDirectlySupportsAnswerText(adultClause, adult) ||
+ (sourceDirectlySupportsAnswerText(scope.claim, adult) &&
+ sourceDirectlySupportsAnswerText(`${week.claim} and ${day.claim}`, adult))
+ )
)
return reject();
let remaining = lead.replace(adultClause, " ");
@@ -307,7 +311,10 @@ export function renderSourceBoundAlcoholSummary(
},
};
}
- const expectedLead = `For healthy adults aged ${age} years and over, drink no more than ${weekly} standard drinks a week and no more than ${daily} standard drinks on any one day; less drinking further lowers risk. People under ${age} years should not drink alcohol. Women who are pregnant or planning pregnancy should not drink alcohol, and for women who are breastfeeding, not drinking alcohol is safest for the baby. These recommendations do not imply a safe or no-risk level of alcohol use.`;
+ const expectedLeadPattern = new RegExp(
+ `^for healthy adults aged ${age} years and over, drink (?:no more than|at most) ${weekly} standard drinks a week and (?:no more than|at most) ${daily} standard drinks on any one day; less drinking further lowers risk\\. people under ${age} years should not drink alcohol\\. women who are pregnant or planning pregnancy should not drink alcohol, and for women who are breastfeeding, not drinking alcohol is safest for the baby\\. these recommendations do not imply a safe or no-risk level of alcohol use\\.$`,
+ "i",
+ );
const expectedRisk =
"For healthy adults, the recommended limits reduce risk but do not eliminate it; not drinking is the best way to reduce alcohol-related harm. No safe level has been identified during pregnancy, and the evidence does not indicate a safe amount for pregnant or breastfeeding mothers.";
const risk = answer.answerSections?.find(
@@ -317,7 +324,7 @@ export function renderSourceBoundAlcoholSummary(
section.citation_chunk_ids.includes(maternal.id) &&
section.citation_chunk_ids.every((id) => id === adult.id || id === maternal.id),
);
- if (!equal(answer.answer, expectedLead) || !risk) return null;
+ if (!expectedLeadPattern.test(prose(answer.answer)) || !risk) return null;
const remainingLead = `People under ${age} years should not drink alcohol. Women who are pregnant or planning pregnancy should not drink alcohol, and for women who are breastfeeding, not drinking alcohol is safest for the baby.`;
if (
!sourceDirectlySupportsAnswerText(recommendation, adult) ||
diff --git a/src/lib/rag/rag.ts b/src/lib/rag/rag.ts
index c18cb01aea..482819452a 100644
--- a/src/lib/rag/rag.ts
+++ b/src/lib/rag/rag.ts
@@ -3193,11 +3193,9 @@ ${buildContextSourceBlock(contextResults, { query: answerFocusQuery, queryClass
});
const generationDegradation = createGenerationDegradationRecorder({
- enabled:
- args.ragProgrammeRollout?.servedMode === "candidate" ||
- args.ragQueryPlanMode === "shadow" ||
- args.ragQueryPlanMode === "canary",
+ args,
routeBudgetMs: routeDeadline.budgetMs,
+ remainingRouteBudgetMs: routeDeadline.remainingMs,
contract: answerContract,
});
function parseCheckedContextAnswerWithoutPilotDiagnostic(raw: string, contextResults: SearchResult[]) {
@@ -3872,6 +3870,7 @@ ${buildContextSourceBlock(contextResults, { query: answerFocusQuery, queryClass
weighted_top_score: search.telemetry.weighted_top_score,
rrf_top_score: search.telemetry.rrf_top_score,
...answerLatencyMetadata(searchLatencyMs, generationLatencyMs, answer.latencyTimings, startedAt),
+ ...generationDegradation.metadata(),
openai_request_ids: openAIRequestIds,
openai_usage: answer.openAIUsage ?? null,
evidence_summary: answer.evidenceSummary,
@@ -4257,6 +4256,7 @@ ${buildContextSourceBlock(contextResults, { query: answerFocusQuery, queryClass
weighted_top_score: search.telemetry.weighted_top_score,
rrf_top_score: search.telemetry.rrf_top_score,
...answerLatencyMetadata(searchLatencyMs, generationLatencyMs, fallbackAnswer.latencyTimings, startedAt),
+ ...generationDegradation.metadata(),
openai_request_ids: fallbackAnswer.openAIRequestIds,
openai_usage: fallbackAnswer.openAIUsage,
evidence_summary: fallbackAnswer.evidenceSummary,
diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts
index 0f9c5222c2..d8a9a20d2a 100644
--- a/src/lib/supabase/database.types.ts
+++ b/src/lib/supabase/database.types.ts
@@ -5178,6 +5178,8 @@ export type Database = {
};
claim_ingestion_jobs: {
Args: {
+ p_allowed_document_ids?: string[] | null;
+ p_allowed_file_types?: string[] | null;
p_claim_limit?: number;
p_stale_after_minutes?: number;
p_worker_id: string;
diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json
index 6b0a48b3f9..35e7425b87 100644
--- a/supabase/applied-migration-hashes.json
+++ b/supabase/applied-migration-hashes.json
@@ -1,7 +1,7 @@
{
"note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.",
"generator": "scripts/check-migration-immutability.mjs",
- "sealed_at": "2026-10-17T12:37:59.701Z",
+ "sealed_at": "2026-10-17T18:01:56.227Z",
"versions": {
"20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c",
"20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48",
@@ -320,6 +320,7 @@
"20261015200000_fenced_ingestion_followups.sql": "21bee7afaf86007b24890ff7abd553ea9cea24d9889cfa12589cccf1dc59357a",
"20261016134500_teaching_admin_attendance_export.sql": "a9fc28336706aee579e18ec782213627e26eb6ca1cd60ab46f4625cf5efee94e",
"20261016210000_adversarial_review_rpc_and_ttl_hardening.sql": "8d58d892eec74bc19561065beb01ec46c98c767519295337a61d7b32e0adda41",
- "20261017120000_teaching_display_revoke_null_creator_guard.sql": "3b92e22983206cbaa8950c07a6b3e6910b3701fa9c49a9f9e5504c232b4397e4"
+ "20261017120000_teaching_display_revoke_null_creator_guard.sql": "3b92e22983206cbaa8950c07a6b3e6910b3701fa9c49a9f9e5504c232b4397e4",
+ "20261017175448_scope_ingestion_claim_atomically.sql": "af192755dd24aa8a6e6146013e1b7abd1b2954a53410c301dedfcb7576c88eba"
}
}
diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json
index aa478d6ae9..da6a4f1b1f 100644
--- a/supabase/drift-manifest.json
+++ b/supabase/drift-manifest.json
@@ -1,9 +1,9 @@
{
- "generated_at": "2026-10-17T12:36:45.338Z",
+ "generated_at": "2026-10-17T18:37:12.668Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
- "schema_sha256": "0f466b3c08396bd4568810c8d493f53ff29cd37b30e3b850f71acd94db9db4e0",
- "replay_seconds": 43,
+ "schema_sha256": "6e5913909f23c2e627413a4fcce0508e2002f129b958eafd27868ed5ab0f8b6a",
+ "replay_seconds": 18,
"snapshot": {
"views": [
{
@@ -18636,6 +18636,14 @@
"def_hash": "0d1f396d2b8eabcfd5902f0c326b6559",
"signature": "public.claim_indexing_v3_agent_jobs(text,integer,integer)"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "8d0a15ae0b00bcfd8b0d9701287d48d2",
+ "signature": "public.claim_ingestion_jobs(text,integer,integer,uuid[],text[])"
+ },
{
"acl": [
"postgres=X/postgres",
diff --git a/supabase/migrations/20261017175448_scope_ingestion_claim_atomically.sql b/supabase/migrations/20261017175448_scope_ingestion_claim_atomically.sql
new file mode 100644
index 0000000000..86271f4a0f
--- /dev/null
+++ b/supabase/migrations/20261017175448_scope_ingestion_claim_atomically.sql
@@ -0,0 +1,54 @@
+-- Scope is enforced inside the fenced claim transaction. The three-argument
+-- function remains available for older, unscoped workers during rollout.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+create function public.claim_ingestion_jobs(p_worker_id text, p_claim_limit integer, p_stale_after_minutes integer, p_allowed_document_ids uuid[], p_allowed_file_types text[])
+returns setof jsonb language plpgsql security definer set search_path = '' as $$
+declare v_candidate record; v_locked jsonb; v_job public.ingestion_jobs%rowtype; v_count integer := 0; v_now timestamptz; v_generation uuid;
+begin
+ if p_worker_id is null or btrim(p_worker_id) = '' or p_claim_limit is null or p_claim_limit not between 1 and 100
+ or p_stale_after_minutes not between 1 and 1440 or p_stale_after_minutes is null then raise exception 'invalid_ingestion_claim_configuration'; end if;
+ for v_candidate in select j.id, j.document_id from public.ingestion_jobs j
+ where (p_allowed_document_ids is null or j.document_id = any(p_allowed_document_ids))
+ and (p_allowed_file_types is null or exists (select 1 from public.documents d where d.id = j.document_id
+ and lower(btrim(split_part(d.file_type, ';', 1))) = any(p_allowed_file_types)))
+ and j.attempt_count < j.max_attempts
+ and (j.status = 'pending' and coalesce(j.next_run_at, pg_catalog.clock_timestamp()) <= pg_catalog.clock_timestamp()
+ or j.status = 'processing' and j.lease_expires_at <= pg_catalog.clock_timestamp())
+ order by j.created_at, j.id
+ loop
+ exit when v_count >= p_claim_limit;
+ v_locked := public.lock_ingestion_document_authority(v_candidate.document_id, true);
+ if v_locked is null then continue; end if;
+ -- File type can change while authority is acquired. Recheck the locked document
+ -- before minting a lease or incrementing attempts; the client snapshot is not authority.
+ if p_allowed_file_types is not null and not coalesce(
+ lower(btrim(split_part(v_locked->'document'->>'file_type', ';', 1))) = any(p_allowed_file_types), false)
+ then continue; end if;
+ perform 1 from public.ingestion_jobs where document_id = v_candidate.document_id order by id for update skip locked;
+ perform 1 from public.indexing_v3_agent_jobs where document_id = v_candidate.document_id order by id for update skip locked;
+ v_now := pg_catalog.clock_timestamp();
+ if exists (select 1 from public.ingestion_jobs where document_id = v_candidate.document_id and status = 'processing' and lease_expires_at > v_now)
+ or exists (select 1 from public.indexing_v3_agent_jobs where document_id = v_candidate.document_id and status = 'processing' and lease_expires_at > v_now) then continue; end if;
+ select * into v_job from public.ingestion_jobs where id = v_candidate.id for update skip locked;
+ if not found or v_job.attempt_count >= v_job.max_attempts or not (
+ v_job.status = 'pending' and coalesce(v_job.next_run_at, v_now) <= v_now
+ or v_job.status = 'processing' and v_job.lease_expires_at <= v_now) then continue; end if;
+ v_generation := (v_locked->'document'->>'index_generation_id')::uuid;
+ if v_generation is null and (v_locked->'document'->'metadata' ? 'index_generation_id') then continue; end if;
+
+ update public.ingestion_jobs set status = 'processing', locked_by = p_worker_id, locked_at = v_now,
+ lease_token = gen_random_uuid(), lease_expected_generation_id = v_generation,
+ lease_write_generation_id = gen_random_uuid(),
+ lease_authority_digest = v_locked->>'authority_digest', lease_expires_at = v_now + make_interval(secs => p_stale_after_minutes * 60),
+ lease_duration_seconds = p_stale_after_minutes * 60, attempt_count = attempt_count + 1, next_run_at = null
+ where id = v_job.id returning * into v_job;
+ return next to_jsonb(v_job) || jsonb_build_object('job_kind','node', 'documents',v_locked->'document');
+ v_count := v_count + 1;
+ end loop;
+end $$;
+
+alter function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) owner to postgres;
+revoke all on function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) from public, anon, authenticated;
+grant execute on function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) to service_role;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index ac7d50e162..635fcfadc3 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -34744,3 +34744,58 @@ alter table public.roster_open_post_alerts owner to postgres;
alter table public.roster_open_post_alerts enable row level security;
revoke all on table public.roster_open_post_alerts from public, anon, authenticated;
grant all on table public.roster_open_post_alerts to service_role;
+
+-- Scope is enforced inside the fenced claim transaction. The three-argument
+-- function remains available for older, unscoped workers during rollout.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+create function public.claim_ingestion_jobs(p_worker_id text, p_claim_limit integer, p_stale_after_minutes integer, p_allowed_document_ids uuid[], p_allowed_file_types text[])
+returns setof jsonb language plpgsql security definer set search_path = '' as $$
+declare v_candidate record; v_locked jsonb; v_job public.ingestion_jobs%rowtype; v_count integer := 0; v_now timestamptz; v_generation uuid;
+begin
+ if p_worker_id is null or btrim(p_worker_id) = '' or p_claim_limit is null or p_claim_limit not between 1 and 100
+ or p_stale_after_minutes not between 1 and 1440 or p_stale_after_minutes is null then raise exception 'invalid_ingestion_claim_configuration'; end if;
+ for v_candidate in select j.id, j.document_id from public.ingestion_jobs j
+ where (p_allowed_document_ids is null or j.document_id = any(p_allowed_document_ids))
+ and (p_allowed_file_types is null or exists (select 1 from public.documents d where d.id = j.document_id
+ and lower(btrim(split_part(d.file_type, ';', 1))) = any(p_allowed_file_types)))
+ and j.attempt_count < j.max_attempts
+ and (j.status = 'pending' and coalesce(j.next_run_at, pg_catalog.clock_timestamp()) <= pg_catalog.clock_timestamp()
+ or j.status = 'processing' and j.lease_expires_at <= pg_catalog.clock_timestamp())
+ order by j.created_at, j.id
+ loop
+ exit when v_count >= p_claim_limit;
+ v_locked := public.lock_ingestion_document_authority(v_candidate.document_id, true);
+ if v_locked is null then continue; end if;
+ -- File type can change while authority is acquired. Recheck the locked document
+ -- before minting a lease or incrementing attempts; the client snapshot is not authority.
+ if p_allowed_file_types is not null and not coalesce(
+ lower(btrim(split_part(v_locked->'document'->>'file_type', ';', 1))) = any(p_allowed_file_types), false)
+ then continue; end if;
+ perform 1 from public.ingestion_jobs where document_id = v_candidate.document_id order by id for update skip locked;
+ perform 1 from public.indexing_v3_agent_jobs where document_id = v_candidate.document_id order by id for update skip locked;
+ v_now := pg_catalog.clock_timestamp();
+ if exists (select 1 from public.ingestion_jobs where document_id = v_candidate.document_id and status = 'processing' and lease_expires_at > v_now)
+ or exists (select 1 from public.indexing_v3_agent_jobs where document_id = v_candidate.document_id and status = 'processing' and lease_expires_at > v_now) then continue; end if;
+ select * into v_job from public.ingestion_jobs where id = v_candidate.id for update skip locked;
+ if not found or v_job.attempt_count >= v_job.max_attempts or not (
+ v_job.status = 'pending' and coalesce(v_job.next_run_at, v_now) <= v_now
+ or v_job.status = 'processing' and v_job.lease_expires_at <= v_now) then continue; end if;
+ v_generation := (v_locked->'document'->>'index_generation_id')::uuid;
+ if v_generation is null and (v_locked->'document'->'metadata' ? 'index_generation_id') then continue; end if;
+
+ update public.ingestion_jobs set status = 'processing', locked_by = p_worker_id, locked_at = v_now,
+ lease_token = gen_random_uuid(), lease_expected_generation_id = v_generation,
+ lease_write_generation_id = gen_random_uuid(),
+ lease_authority_digest = v_locked->>'authority_digest', lease_expires_at = v_now + make_interval(secs => p_stale_after_minutes * 60),
+ lease_duration_seconds = p_stale_after_minutes * 60, attempt_count = attempt_count + 1, next_run_at = null
+ where id = v_job.id returning * into v_job;
+ return next to_jsonb(v_job) || jsonb_build_object('job_kind','node', 'documents',v_locked->'document');
+ v_count := v_count + 1;
+ end loop;
+end $$;
+
+alter function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) owner to postgres;
+revoke all on function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) from public, anon, authenticated;
+grant execute on function public.claim_ingestion_jobs(text, integer, integer, uuid[], text[]) to service_role;
diff --git a/tests/answer-source-rows.test.ts b/tests/answer-source-rows.test.ts
index d3ee680149..8ce1f5fff1 100644
--- a/tests/answer-source-rows.test.ts
+++ b/tests/answer-source-rows.test.ts
@@ -9,7 +9,11 @@ import {
sourceSupportSentence,
type AnswerSourceRow,
} from "@/components/clinical-dashboard/answer-source-rows";
-import { documentFileKind, documentSupportBadgeProps } from "@/components/clinical-dashboard/document-ui";
+import {
+ documentFileKind,
+ documentFileKindFromType,
+ documentSupportBadgeProps,
+} from "@/components/clinical-dashboard/document-ui";
import { normalizeSourceMetadata } from "@/lib/source-metadata";
import type { SearchResult } from "@/lib/types";
@@ -126,5 +130,11 @@ describe("answer-source-rows uncited / em-dash presentation labels (R17)", () =>
expect(rows[0]?.snippet).toBe("Monitor CIWA-Ar every 1 to 4 hours during acute alcohol withdrawal.");
expect(documentFileKind(rows[0]?.fileName)).toBe("TXT");
expect(documentFileKind("guideline-without-extension")).toBe("PDF");
+ expect(documentFileKindFromType("application/pdf", "guideline.docx")).toBe("PDF");
+ expect(documentFileKindFromType("Text/Plain; charset=utf-8", "notes.pdf")).toBe("TXT");
+ expect(documentFileKindFromType("application/octet-stream", "notes.docx")).toBe("DOCX");
+ expect(documentFileKindFromType(null, "no-extension")).toBe("PDF");
+ expect(documentFileKindFromType("image/png", "scan")).toBe("PNG");
+ expect(documentFileKindFromType("image/tiff", "scan")).toBe("IMAGE");
});
});
diff --git a/tests/answer-stream-preview-order.test.ts b/tests/answer-stream-preview-order.test.ts
index 10bebf98d0..1971a9e47f 100644
--- a/tests/answer-stream-preview-order.test.ts
+++ b/tests/answer-stream-preview-order.test.ts
@@ -459,4 +459,79 @@ describe("answer stream verified preview ordering", () => {
expect(rankingFrame?.data.verifiedUnit).toBeUndefined();
expect(readLastEvidencePreviewReason()?.reason).toBe("contract_rejected");
});
+
+ it("filters out danger-level sources and emits only clean sources in the SSE evidence_preview unit", async () => {
+ const { buildEvidencePreviewProgress, readLastEvidencePreviewReason } = await import("../src/lib/answer-preview");
+ const dangerSource = {
+ id: "danger-chunk-1",
+ document_id: "danger-doc-1",
+ title: "Superseded Clozapine Protocol",
+ file_name: "superseded-clozapine.pdf",
+ page_number: 1,
+ chunk_index: 0,
+ section_heading: "Monitoring",
+ content: "Outdated ANC monitoring interval from a retired guideline.",
+ image_ids: [],
+ images: [],
+ similarity: 0.91,
+ source_metadata: {
+ document_status: "outdated",
+ clinical_validation_status: "unverified",
+ extraction_quality: "poor",
+ } as unknown as import("../src/lib/types").SearchResult["source_metadata"],
+ };
+ const cleanSource = {
+ id: "clean-chunk-1",
+ document_id: "clean-doc-1",
+ title: "Approved Clozapine Guideline",
+ file_name: "approved-clozapine.pdf",
+ page_number: 2,
+ chunk_index: 1,
+ section_heading: "Monitoring",
+ content: "Standard weekly ANC monitoring requirements for clozapine initiation.",
+ image_ids: [],
+ images: [],
+ similarity: 0.85,
+ };
+
+ answerQuestionWithScope.mockImplementationOnce(
+ async ({ onProgress }: { onProgress?: (event: unknown) => void }) => {
+ onProgress?.({
+ stage: "ranking",
+ ...buildEvidencePreviewProgress({
+ normalResults: [dangerSource, cleanSource],
+ fallbackResults: [dangerSource, cleanSource],
+ }),
+ });
+ onProgress?.({ stage: "generating" });
+ return {
+ answer: "Standard weekly ANC monitoring is required.",
+ grounded: true,
+ confidence: "high",
+ citations: [],
+ sources: [cleanSource],
+ };
+ },
+ );
+
+ const { POST } = await import("../src/app/api/answer/stream/route");
+ const response = await POST(
+ new Request("http://localhost/api/answer/stream", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ query: "clozapine monitoring interval" }),
+ }),
+ );
+ const frames = parseSseFrames(await response.text());
+
+ const rankingFrame = frames.find((frame) => frame.event === "progress" && frame.data.stage === "ranking");
+ expect(rankingFrame).toBeDefined();
+ expect(rankingFrame?.data.previewReason).toBe("ok");
+ const verifiedUnit = rankingFrame?.data.verifiedUnit as
+ { kind?: string; selectedContextCount?: number; sources?: Array<{ id: string }> } | undefined;
+ expect(verifiedUnit?.kind).toBe("evidence_preview");
+ expect(verifiedUnit?.selectedContextCount).toBe(1);
+ expect(verifiedUnit?.sources?.map((source) => source.id)).toEqual(["clean-chunk-1"]);
+ expect(readLastEvidencePreviewReason()?.reason).toBe("ok");
+ });
});
diff --git a/tests/answer-verification.test.ts b/tests/answer-verification.test.ts
index 2f2159fa74..71d8b5d4be 100644
--- a/tests/answer-verification.test.ts
+++ b/tests/answer-verification.test.ts
@@ -1655,4 +1655,104 @@ describe("dead stem regex fixes (#HD1C6W)", () => {
expect(verified.confidence).toBe("unsupported");
expect(verified.responseMode).toBe("evidence_gap");
});
+
+ it("maps 'no more than' and 'at most' to the same at_most comparator across numeric verification, claim support, and alcohol summary delivery", async () => {
+ const { sourceDirectlySupportsAnswerText } = await import("../src/lib/rag/rag-claim-support");
+ const { renderSourceBoundAlcoholSummary } = await import("../src/lib/rag/rag-source-bound-alcohol-summary");
+ const { hasCompleteOpeningSentence } = await import("../src/lib/rag/rag-answer-shape-checks");
+
+ const singleSentenceAdultSource = source({
+ id: "nhmrc-adult-single",
+ content:
+ "To reduce the risk of harm from alcohol-related disease or injury, healthy men and women aged 18 years and over should drink no more than 10 standard drinks a week and no more than 4 standard drinks on any one day.",
+ });
+ const candidateClause =
+ "For healthy adults aged 18 years and over, the NHMRC advises limiting alcohol intake to at most 10 standard drinks per week and at most 4 standard drinks on any single day.";
+ expect(sourceDirectlySupportsAnswerText(candidateClause, singleSentenceAdultSource)).toBe(true);
+ const verified = verifyAnswerNumbers(
+ candidateClause,
+ [{ chunk_id: "nhmrc-adult-single" }],
+ [singleSentenceAdultSource],
+ );
+ expect(verified.hasUnverifiedNumbers).toBe(false);
+ expect(verified.unverifiedTokens).toEqual([]);
+ expect(
+ hasCompleteOpeningSentence(
+ "For healthy adults aged 18 years and over, drink at most 10 standard drinks a week and at most 4 standard drinks on any one day.",
+ ),
+ ).toBe(true);
+
+ const nhmrcMeta = {
+ publisher_code: "NHMRC",
+ publisher: "National Health and Medical Research Council",
+ } as unknown as NonNullable;
+ const nhmrcResults = [
+ source({
+ id: "nhmrc-1",
+ document_id: "doc-nhmrc",
+ source_metadata: nhmrcMeta,
+ content:
+ "Guideline 1: Adults To reduce the risk of harm from alcohol-related disease or injury, healthy men and women should drink no more than 10 standard drinks a week and no more than 4 standard drinks on any one day. The less you drink, the lower your risk of harm from alcohol. This guideline applies to healthy men and women aged 18 years and over. The guideline does not represent a 'safe' or 'no-risk' drinking level, nor does it set an absolute upper limit of intake. This guideline provides recommendations to reduce the risk of harm from alcohol, but these recommendations do not completely eliminate all risk from drinking alcohol. Not drinking at all is the best way to reduce the risk of harm from alcohol.",
+ }),
+ source({
+ id: "nhmrc-2",
+ document_id: "doc-nhmrc",
+ source_metadata: nhmrcMeta,
+ content:
+ "Guideline 2: Children and people under 18 years of age To reduce the risk of injury and other harms to health, children and people under 18 years of age should not drink alcohol. There is no clear 'safe' level of alcohol consumption for children and people under 18 years of age.",
+ }),
+ source({
+ id: "nhmrc-3",
+ document_id: "doc-nhmrc",
+ source_metadata: nhmrcMeta,
+ content:
+ "Guideline 3: Women who are pregnant or breastfeeding A. To prevent harm from alcohol to their unborn child, women who are pregnant or planning a pregnancy should not drink alcohol. B. For women who are breastfeeding, not drinking alcohol is safest for their baby. The evidence does not indicate a safe amount of alcohol that pregnant women and breastfeeding mothers can drink.",
+ }),
+ ];
+ const summaryText =
+ "For healthy adults aged 18 years and over, drink at most 10 standard drinks a week and at most 4 standard drinks on any one day; less drinking further lowers risk. People under 18 years should not drink alcohol. Women who are pregnant or planning pregnancy should not drink alcohol, and for women who are breastfeeding, not drinking alcohol is safest for the baby. These recommendations do not imply a safe or no-risk level of alcohol use.";
+ const rendered = renderSourceBoundAlcoholSummary(
+ {
+ answer: summaryText,
+ grounded: true,
+ confidence: "high",
+ citations: nhmrcResults.map((r) => citationFromResult(r, "model_selected")),
+ sources: nhmrcResults,
+ answerSections: [
+ {
+ heading: "Important qualification",
+ kind: "contraindications_cautions",
+ supportLevel: "direct",
+ body: "For healthy adults, the recommended limits reduce risk but do not eliminate it; not drinking is the best way to reduce alcohol-related harm. No safe level has been identified during pregnancy, and the evidence does not indicate a safe amount for pregnant or breastfeeding mothers.",
+ citation_chunk_ids: ["nhmrc-1", "nhmrc-3"],
+ },
+ ],
+ },
+ nhmrcResults,
+ "Summarise the NHMRC alcohol guidelines.",
+ "broad_summary",
+ );
+ expect(rendered).not.toBeNull();
+ expect(rendered?.answer.grounded).toBe(true);
+ });
+});
+
+describe("upper-limit comparator with clinical units", () => {
+ it.each(["5 mg", "1.5 × 10^9/L"])("keeps no more than and its unit together: %s", (quantity) => {
+ for (const [answerComparator, sourceComparator] of [
+ ["no more than", "at most"],
+ ["at most", "no more than"],
+ ]) {
+ const evidence = source({ content: `Use ${sourceComparator} ${quantity}.` });
+ const verified = verifyAnswerNumbers(
+ `Use ${answerComparator} ${quantity}.`,
+ [{ chunk_id: evidence.id }],
+ [evidence],
+ );
+ expect(verified.unverifiedTokens).toEqual([]);
+ expect(
+ verifyAnswerNumbers(`Use at least ${quantity}.`, [{ chunk_id: evidence.id }], [evidence]).hasUnverifiedNumbers,
+ ).toBe(true);
+ }
+ });
});
diff --git a/tests/claude-cloud-lifecycle.test.ts b/tests/claude-cloud-lifecycle.test.ts
index e94a77503c..72189a698d 100644
--- a/tests/claude-cloud-lifecycle.test.ts
+++ b/tests/claude-cloud-lifecycle.test.ts
@@ -456,6 +456,7 @@ describe.skipIf(process.platform === "win32")("sequential Claude SessionStart",
pending = false,
remote = true,
runnerFailure: "none" | "process" | "unavailable" = "none",
+ payload = '{"hook_event_name":"SessionStart"}',
) {
const source = sandbox();
const home = sandbox();
@@ -496,7 +497,7 @@ describe.skipIf(process.platform === "win32")("sequential Claude SessionStart",
}
const result = spawnSync("bash", [join(source, "scripts", "claude-session-start.sh")], {
encoding: "utf8",
- input: '{"hook_event_name":"SessionStart"}',
+ input: payload,
env: {
...process.env,
HOME: home,
@@ -529,6 +530,14 @@ describe.skipIf(process.platform === "win32")("sequential Claude SessionStart",
const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
expect(context).toBe("setup\n\nfreshness\n\nissues\n\npolicy\n\norganisation");
});
+ it("treats a dependent that exits 0 without reading the payload (EPIPE) as complete", () => {
+ // Larger than a pipe buffer, so writing it to dependents that never read stdin always hits EPIPE.
+ const payload = JSON.stringify({ hook_event_name: "SessionStart", padding: "x".repeat(256 * 1024) });
+ const { result } = fixture("none", false, true, "none", payload);
+ expect(result.status, result.stderr).toBe(0);
+ const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
+ expect(context).toBe("setup\n\nfreshness\n\nissues\n\npolicy\n\norganisation");
+ });
it.each(["exit", "command"] as const)(
"keeps bootstrap %s failure nonfatal without suppressing errexit",
(failure) => {
diff --git a/tests/rag-adversarial-harness.test.ts b/tests/rag-adversarial-harness.test.ts
index aa1da48711..88a816d980 100644
--- a/tests/rag-adversarial-harness.test.ts
+++ b/tests/rag-adversarial-harness.test.ts
@@ -321,14 +321,6 @@ function pinsSourceBackedReviewFallback(run: CaseRun, citedChunkId: string) {
}
const KNOWN_DIVERGENCES: Record void }> = {
- "cite-mismatched-attribution": {
- note: "offline document-match listing cites every retrieved document, not only the claim-bearing one",
- pin: (run) => {
- expect(citedChunkIds(run.answer).sort()).toEqual(["syn-cite-attrib-a", "syn-cite-attrib-b"]);
- expect(run.answer.grounded).toBe(true);
- expect(run.answer.answerQualityTier).toBe("source_only");
- },
- },
"inj-canary-exfiltration": {
note: "no written answer offline: the review fallback points at the in-scope excerpt instead of answering from it",
pin: (run) => pinsSourceBackedReviewFallback(run, "syn-inj-canary-a"),
diff --git a/tests/rag-document-support-list.test.ts b/tests/rag-document-support-list.test.ts
new file mode 100644
index 0000000000..278699b670
--- /dev/null
+++ b/tests/rag-document-support-list.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from "vitest";
+import { buildExtractiveAnswer } from "../src/lib/rag/rag-extractive-builder";
+import {
+ buildDocumentBreakdown,
+ buildEvidenceSummary,
+ buildSourceCoverage,
+ buildSmartPanel,
+ selectBestSourceRecommendation,
+} from "../src/lib/evidence";
+import type { SearchResult } from "../src/lib/types";
+
+function source(id: string, document_id: string, content: string): SearchResult {
+ return {
+ id,
+ document_id,
+ content,
+ title: `Synthetic ${document_id}`,
+ file_name: `${document_id}.pdf`,
+ page_number: 1,
+ chunk_index: 0,
+ section_heading: "",
+ image_ids: [],
+ images: [],
+ similarity: 0.9,
+ hybrid_score: 0.9,
+ } as SearchResult;
+}
+function answer(query: string, results: SearchResult[]) {
+ return buildExtractiveAnswer({
+ query,
+ queryClass: "document_lookup",
+ results,
+ quoteCards: [],
+ documentBreakdown: buildDocumentBreakdown(results, []),
+ evidenceSummary: buildEvidenceSummary(results, []),
+ sourceCoverage: buildSourceCoverage(results),
+ conflictsOrGaps: [],
+ visualEvidence: [],
+ bestSource: selectBestSourceRecommendation(results),
+ smartPanel: buildSmartPanel(query, results),
+ relatedDocuments: [],
+ routeReason: "test",
+ timings: undefined,
+ });
+}
+
+describe("claim-bearing document lookup", () => {
+ const partial = source("partial", "partial-care", "Pregnancy care includes routine appointments.");
+ const full = source("full", "complete-guideline", "Anticoagulation in pregnancy requires specialist planning.");
+ const unrelated = source("unrelated", "other-guideline", "Manage asthma using an individual care plan.");
+ it.each([
+ "Which document supports anticoagulation in pregnancy?",
+ "Which documents support anticoagulation in pregnancy?",
+ ])("retains complete support and rebuilds derived source artifacts: %s", (query) => {
+ const result = answer(query, [partial, full, unrelated]);
+ expect(result.sources.map((r) => r.id)).toEqual([full.id]);
+ expect(result.citations.map((r) => r.chunk_id)).toEqual([full.id]);
+ expect(result.bestSource?.document_id).toBe(full.document_id);
+ expect(result.documentBreakdown?.map((d) => d.document_id)).toEqual([full.document_id]);
+ expect(result.smartPanel?.documents.map((d) => d.document_id)).toEqual([full.document_id]);
+ expect(result.answer).not.toContain(partial.title);
+ });
+ it("accumulates matching qualifiers across chunks before choosing documents", () => {
+ const split = [
+ source("split-a", "split-guideline", "Anticoagulation requires specialist planning."),
+ source("split-b", "split-guideline", "Pregnancy care is addressed in this guidance."),
+ ];
+ const result = answer("Which document supports anticoagulation in pregnancy?", [full, ...split, unrelated]);
+ expect(new Set(result.sources.map((r) => r.document_id))).toEqual(new Set([full.document_id, "split-guideline"]));
+ expect(result.citations.map((r) => r.chunk_id)).toEqual(expect.arrayContaining(split.map((r) => r.id)));
+ });
+ it("reserves citations for every named document before extra chunks", () => {
+ const chunks = Array.from({ length: 5 }, (_, i) => source(`crowded-${i}`, "crowded-guideline", full.content));
+ const result = answer("Which indexed documents support anticoagulation in pregnancy?", [...chunks, full]);
+ expect(result.citations.map((c) => c.document_id)).toContain(full.document_id);
+ expect(result.citations.map((c) => c.document_id)).toContain("crowded-guideline");
+ expect(result.citations.length).toBeLessThanOrEqual(5);
+ });
+ it("keeps source metadata and citations within the five delivered documents", () => {
+ const results = Array.from({ length: 6 }, (_, i) => source(`chunk-${i}`, `guideline-${i}`, full.content));
+ const result = answer("Which documents support anticoagulation in pregnancy?", results);
+ expect(result.sources).toHaveLength(5);
+ expect(new Set(result.citations.map((c) => c.document_id)).size).toBe(5);
+ expect(new Set(result.sources.map((r) => r.document_id))).toEqual(
+ new Set(result.documentBreakdown?.map((d) => d.document_id)),
+ );
+ });
+ it.each([
+ "What sources are there for anticoagulation in pregnancy?",
+ "Which documents support and discuss anticoagulation in pregnancy?",
+ "Which documents support anticoagulation in pregnancy and explain it?",
+ ])("ignores everyday lookup wording when matching qualifiers: %s", (query) => {
+ const result = answer(query, [partial, full, unrelated]);
+ expect(result.grounded).toBe(true);
+ expect(result.sources.map((r) => r.id)).toEqual([full.id]);
+ });
+ it("cites every covering chunk of a split match even when five documents compete for citations", () => {
+ const whole = Array.from({ length: 4 }, (_, i) => source(`whole-${i}`, `whole-guideline-${i}`, full.content));
+ const split = [
+ source("split-a", "split-guideline", "Anticoagulation requires specialist planning."),
+ source("split-b", "split-guideline", "Pregnancy care is addressed in this guidance."),
+ ];
+ const result = answer("Which documents support anticoagulation in pregnancy?", [...whole, ...split]);
+ const cited = result.citations.map((c) => c.chunk_id);
+ expect(cited.length).toBeLessThanOrEqual(5);
+ const namedSplit = result.answer.includes("Synthetic split-guideline");
+ if (namedSplit) expect(cited).toEqual(expect.arrayContaining(["split-a", "split-b"]));
+ for (const id of new Set(result.citations.map((c) => c.document_id))) {
+ expect(result.answer).toContain(`Synthetic ${id}`);
+ }
+ const splitResult = answer("Which documents support anticoagulation in pregnancy?", [...split, ...whole]);
+ expect(splitResult.answer).toContain("Synthetic split-guideline");
+ expect(splitResult.citations.map((c) => c.chunk_id)).toEqual(expect.arrayContaining(["split-a", "split-b"]));
+ expect(splitResult.citations.length).toBeLessThanOrEqual(5);
+ });
+ it.each([
+ "Which guidelines support recommendations on anticoagulation in pregnancy?",
+ "Which documents support and give recommendations on anticoagulation in pregnancy?",
+ ])("treats 'give recommendations on' as lookup wording: %s", (query) => {
+ const result = answer(query, [partial, full, unrelated]);
+ expect(result.grounded).toBe(true);
+ expect(result.sources.map((r) => r.id)).toEqual([full.id]);
+ });
+ it("covers a document with its single complete chunk rather than earlier partial chunks", () => {
+ const lateFull = [
+ source("late-a", "late-full", "Anticoagulation requires specialist planning."),
+ source("late-p", "late-full", "Pregnancy care is addressed in this guidance."),
+ source("late-full-chunk", "late-full", full.content),
+ ];
+ const whole = Array.from({ length: 4 }, (_, i) => source(`whole-${i}`, `whole-guideline-${i}`, full.content));
+ const result = answer("Which documents support anticoagulation in pregnancy?", [...lateFull, ...whole]);
+ const citedDocuments = new Set(result.citations.map((c) => c.document_id));
+ expect(citedDocuments).toEqual(new Set(["late-full", ...whole.map((r) => r.document_id)]));
+ expect(result.citations.map((c) => c.chunk_id)).toContain("late-full-chunk");
+ expect(result.citations.length).toBeLessThanOrEqual(5);
+ });
+ it("cites the smallest covering chunk set so a second split document still fits five citations", () => {
+ const query = "Which documents support anticoagulation, pregnancy, postpartum, epilepsy, warfarin and lithium?";
+ const greedyTrap = [
+ source("trap-abcd", "trap", "Anticoagulation, pregnancy, postpartum and epilepsy care."),
+ source("trap-abe", "trap", "Anticoagulation in pregnancy with warfarin."),
+ source("trap-cdf", "trap", "Postpartum epilepsy care with lithium."),
+ ];
+ const other = [
+ source("other-1", "other", "Anticoagulation and pregnancy."),
+ source("other-2", "other", "Postpartum and epilepsy."),
+ source("other-3", "other", "Warfarin and lithium."),
+ ];
+ const result = answer(query, [...greedyTrap, ...other]);
+ expect(result.answer).toContain("Synthetic trap");
+ expect(result.answer).toContain("Synthetic other");
+ const cited = result.citations.map((c) => c.chunk_id);
+ expect(cited).toEqual(expect.arrayContaining(["trap-abe", "trap-cdf", "other-1", "other-2", "other-3"]));
+ expect(cited).toHaveLength(5);
+ });
+ it("returns a gap without attributing support when no document matches", () => {
+ const result = answer("Which documents support anticoagulation in pregnancy?", [unrelated]);
+ expect(result.grounded).toBe(false);
+ expect(result.sources).toEqual([]);
+ expect(result.citations).toEqual([]);
+ expect(result.bestSource).toBeFalsy();
+ });
+});
diff --git a/tests/rag-generation-degradation.test.ts b/tests/rag-generation-degradation.test.ts
index 147dd7e7b1..76a865e31a 100644
--- a/tests/rag-generation-degradation.test.ts
+++ b/tests/rag-generation-degradation.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
createGenerationDegradationRecorder,
compareGenerationAlternatives,
@@ -463,3 +463,128 @@ describe("#231 generation degradation telemetry switch and wire", () => {
});
});
});
+
+describe("generationDegradationLogMetadata (#231) and worker pre-claim scope filter", () => {
+ it("emits scalar telemetry metadata even when public generationDegradation rollout is off", async () => {
+ const { generationDegradationLogMetadata } = await import("../src/lib/rag/rag-generation-degradation");
+ const r = createGenerationDegradationRecorder({
+ enabled: false,
+ routeBudgetMs: 35000,
+ remainingRouteBudgetMs: () => 2000,
+ });
+ r.start({
+ route: "strong",
+ timeoutMs: 30000,
+ outputBudget: "standard",
+ retrievalHealthy: true,
+ coverage: "complete",
+ contextCount: 2,
+ });
+ r.fail("timeout", 30000);
+ expect(r.finish(delivered, true)).toBeUndefined();
+ expect(r.metadata()).toEqual({
+ generation_degradation_reason: "provider_initial_attempt_timeout",
+ generation_degradation_attempt_count: 1,
+ generation_degradation_completed_response_count: 0,
+ generation_degradation_total_attempt_latency_ms: 30000,
+ generation_degradation_remaining_route_budget_ms: 2000,
+ provider_initial_attempt_timeout: true,
+ provider_quality_retry_exhausted: false,
+ });
+ expect(generationDegradationLogMetadata(undefined)).toEqual({});
+ });
+
+ it("enforces WORKER_ALLOWED_DOCUMENT_IDS, WORKER_ALLOWED_FILE_TYPES, and claim_ingestion_jobs candidate eligibility", async () => {
+ const { resolveWorkerClaimScopeFilter, isPreClaimCandidateAllowed, isClaimableIngestionJobCandidate } =
+ await import("../worker/behavior");
+ const inactive = resolveWorkerClaimScopeFilter({});
+ expect(inactive.active).toBe(false);
+ expect(isPreClaimCandidateAllowed({ document_id: "doc-1", file_type: "application/pdf" }, inactive)).toBe(true);
+
+ const active = resolveWorkerClaimScopeFilter({
+ WORKER_ALLOWED_DOCUMENT_IDS: "7D0C4A52-1B9E-4F6A-9C2D-3E4F5A6B7C81, 7d0c4a52-1b9e-4f6a-9c2d-3e4f5a6b7c82",
+ WORKER_ALLOWED_FILE_TYPES: "TEXT/PLAIN; charset=utf-8",
+ });
+ expect(active.active).toBe(true);
+ expect(
+ isPreClaimCandidateAllowed(
+ { document_id: "7d0c4a52-1b9e-4f6a-9c2d-3e4f5a6b7c81", file_type: "text/plain; charset=utf-8" },
+ active,
+ ),
+ ).toBe(true);
+ expect(
+ isPreClaimCandidateAllowed(
+ { document_id: "7d0c4a52-1b9e-4f6a-9c2d-3e4f5a6b7c81", file_type: "application/pdf" },
+ active,
+ ),
+ ).toBe(false);
+ expect(isPreClaimCandidateAllowed({ document_id: "doc-other", file_type: "text/plain" }, active)).toBe(false);
+
+ const nowMs = Date.parse("2026-10-17T12:00:00.000Z");
+ expect(isClaimableIngestionJobCandidate({ status: "pending", attempt_count: 0, max_attempts: 3 }, nowMs)).toBe(
+ true,
+ );
+ expect(
+ isClaimableIngestionJobCandidate(
+ { status: "pending", attempt_count: 1, max_attempts: 3, next_run_at: "2026-10-17T12:05:00.000Z" },
+ nowMs,
+ ),
+ ).toBe(false);
+ expect(
+ isClaimableIngestionJobCandidate(
+ { status: "processing", attempt_count: 1, max_attempts: 3, lease_expires_at: "2026-10-17T11:59:00.000Z" },
+ nowMs,
+ ),
+ ).toBe(true);
+ expect(
+ isClaimableIngestionJobCandidate(
+ { status: "processing", attempt_count: 3, max_attempts: 3, lease_expires_at: "2026-10-17T11:59:00.000Z" },
+ nowMs,
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("unserved production generator observations", () => {
+ it("records a real observed-generator timeout and captures the deadline at completion", async () => {
+ const { createObservedAnswerGenerator } = await import("../src/lib/rag/rag-provider");
+ const { createAnswerRouteDeadline } = await import("../src/lib/rag/rag-route-budget");
+ let elapsed = 8000;
+ const deadline = createAnswerRouteDeadline({ routeMode: "strong", startedAt: 0, now: () => elapsed });
+ const r = createGenerationDegradationRecorder({
+ enabled: false,
+ routeBudgetMs: deadline.budgetMs,
+ remainingRouteBudgetMs: deadline.remainingMs,
+ });
+ const generate = vi.fn(async () => {
+ elapsed += 25000;
+ throw new Error("Request timed out");
+ });
+ const observed = createObservedAnswerGenerator({
+ generate,
+ buildInput: () => "synthetic",
+ schemaFor: () => ({}),
+ deadline,
+ recorder: r,
+ contextObservation: () => ({ retrievalHealthy: true, coverage: "complete", contextCount: 2 }),
+ instructions: "synthetic",
+ promptCacheKey: "synthetic",
+ fastReasoningEffort: "low",
+ strongReasoningEffort: "low",
+ onResult: () => {},
+ onLatency: () => {},
+ });
+ try {
+ await expect(observed("synthetic-model", {})).rejects.toThrow("timed out");
+ expect(r.finish(delivered, true)).toBeUndefined();
+ elapsed += 1000;
+ expect(r.metadata()).toMatchObject({
+ generation_degradation_attempt_count: 1,
+ generation_degradation_remaining_route_budget_ms: 2000,
+ provider_initial_attempt_timeout: true,
+ });
+ } finally {
+ deadline.dispose();
+ }
+ });
+});
diff --git a/tests/rag-telemetry-canary-absence.test.ts b/tests/rag-telemetry-canary-absence.test.ts
index c174d342a1..d7eef9891e 100644
--- a/tests/rag-telemetry-canary-absence.test.ts
+++ b/tests/rag-telemetry-canary-absence.test.ts
@@ -171,3 +171,36 @@ describe("B1 telemetry canary absence", () => {
expect(findCanaryLeaks(JSON.stringify(emitted), canaryTokens)).toEqual([]);
});
});
+
+describe("scalar degradation metadata", () => {
+ it("generationDegradationLogMetadata (#231) drops contaminated reason/attempt fields and never leaks canaries", async () => {
+ const { generationDegradationLogMetadata } = await import("../src/lib/rag/rag-generation-degradation");
+ const contaminated = {
+ schemaVersion: "rag-generation-degradation-v1",
+ promptVersion: "clinical-rag-answer-v6",
+ answerSchemaVersion: "clinical-rag-answer-schema-v5",
+ routeBudgetMs: 35000,
+ totalAttemptLatencyMs: 30000,
+ completedResponseCount: 0,
+ degraded: true,
+ reason: canaryTokens[0],
+ attempts: [
+ {
+ attempt: 1,
+ route: "strong",
+ timeoutMs: 30000,
+ outputBudget: "standard",
+ retrievalHealthy: true,
+ coverage: "complete",
+ contextCount: 2,
+ latencyMs: 30000,
+ outcome: "timeout",
+ failureReason: canaryTokens[1] ?? canaryTokens[0],
+ },
+ ],
+ };
+ const emitted = generationDegradationLogMetadata(contaminated as never);
+ expect(emitted).toEqual({});
+ expect(findCanaryLeaks(JSON.stringify(emitted), canaryTokens)).toEqual([]);
+ });
+});
diff --git a/tests/sql/ingestion-claim-scope.sql b/tests/sql/ingestion-claim-scope.sql
new file mode 100644
index 0000000000..1b0466be46
--- /dev/null
+++ b/tests/sql/ingestion-claim-scope.sql
@@ -0,0 +1,53 @@
+-- Disposable database only. Exercises the real claim function and authority locks.
+\set ON_ERROR_STOP on
+begin;
+set local statement_timeout = '60s';
+create function pg_temp.assert_true(ok boolean, message text) returns void
+language plpgsql as $$ begin
+ if ok is distinct from true then raise exception '%', message; end if;
+end $$;
+select pg_temp.assert_true(not exists (select 1 from public.documents), 'requires an empty disposable database');
+insert into auth.users(id) values ('11111111-1111-4111-8111-111111111111');
+insert into public.documents(id, owner_id, title, file_name, file_type, storage_path, status, metadata)
+values
+ ('00000000-0000-4000-8000-000000000001', '11111111-1111-4111-8111-111111111111',
+ 'Synthetic PDF', 'fixture.pdf', 'application/pdf', 'synthetic/scope-pdf', 'queued', '{}'),
+ ('00000000-0000-4000-8000-000000000002', '11111111-1111-4111-8111-111111111111',
+ 'Synthetic TXT', 'fixture.txt', 'TEXT/PLAIN; charset=utf-8', 'synthetic/scope-txt', 'queued', '{}');
+
+create function pg_temp.reset_queue() returns void language plpgsql as $$ begin
+ delete from public.ingestion_jobs;
+ insert into public.ingestion_jobs(id, document_id, status, created_at)
+ select id, id, 'pending', now() + (case file_name when 'fixture.pdf' then interval '-1 day' else interval '0' end)
+ from public.documents;
+end $$;
+select pg_temp.reset_queue();
+create temp table claims as
+select job from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ array['00000000-0000-4000-8000-000000000002']::uuid[], array['text/plain']) job;
+select pg_temp.assert_true((select count(*) = 1 and bool_and(job->>'document_id' =
+ '00000000-0000-4000-8000-000000000002') from claims), 'mixed queue must claim the allowed TXT job');
+select pg_temp.assert_true((select status = 'pending' and attempt_count = 0 from public.ingestion_jobs
+ where document_id = '00000000-0000-4000-8000-000000000001'), 'excluded PDF must not acquire a lease or consume attempts');
+
+select pg_temp.reset_queue();
+select pg_temp.assert_true(not exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ array['00000000-0000-4000-8000-000000000001']::uuid[], array['text/plain'])),
+ 'both allowlists must match');
+select pg_temp.assert_true(not exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ '{}'::uuid[], null::text[])), 'an explicit empty SQL allowlist must match nothing');
+
+select pg_temp.reset_queue();
+update public.ingestion_jobs set next_run_at = now() + interval '1 day'
+where document_id = '00000000-0000-4000-8000-000000000002';
+select pg_temp.assert_true(not exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ null::uuid[], array['text/plain'])), 'future jobs must remain unclaimed');
+update public.ingestion_jobs set next_run_at = now() - interval '1 second'
+where document_id = '00000000-0000-4000-8000-000000000002';
+select pg_temp.assert_true(exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45,
+ null::uuid[], array['text/plain'])), 'newly due scoped jobs must become claimable');
+
+select pg_temp.reset_queue();
+select pg_temp.assert_true(exists (select 1 from public.claim_ingestion_jobs('synthetic-worker', 1, 45)),
+ 'the legacy three-argument claim remains available');
+rollback;
diff --git a/tests/worker-row-contract.test.ts b/tests/worker-row-contract.test.ts
index 83a6939dd8..d0e1e43098 100644
--- a/tests/worker-row-contract.test.ts
+++ b/tests/worker-row-contract.test.ts
@@ -324,6 +324,95 @@ describe("rejected claim terminal transition", () => {
});
describe("worker claim rejection isolation", () => {
+ it("passes scope into the atomic claim without a preflight queue read", async () => {
+ const { createWorkerRuntime } = await import("../worker/main");
+ const good = claimedRow();
+ vi.stubEnv("WORKER_ALLOWED_DOCUMENT_IDS", ` ${good.document_id.toUpperCase()} `);
+ vi.stubEnv("WORKER_ALLOWED_FILE_TYPES", " TEXT/PLAIN; charset=utf-8 ");
+ const from = vi.fn(() => {
+ throw new Error("scope must not use a queue snapshot");
+ });
+ const rpc = vi.fn(async (_name: string, args: Record) => {
+ good.locked_by = String(args.p_worker_id);
+ good.documents.file_type = "text/plain";
+ return { data: [good], error: null };
+ });
+ try {
+ const runtime = createWorkerRuntime({ client: { rpc, from } as unknown as SupabaseClient });
+ expect(await runtime.claimJobs()).toEqual([good]);
+ expect(from).not.toHaveBeenCalled();
+ expect(rpc).toHaveBeenCalledWith(
+ "claim_ingestion_jobs",
+ expect.objectContaining({
+ p_allowed_document_ids: [good.document_id],
+ p_allowed_file_types: ["text/plain"],
+ }),
+ );
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
+ it.each([
+ { WORKER_ALLOWED_DOCUMENT_IDS: "," },
+ { WORKER_ALLOWED_DOCUMENT_IDS: " , , " },
+ { WORKER_ALLOWED_FILE_TYPES: ",," },
+ { WORKER_ALLOWED_DOCUMENT_IDS: "doc-1", WORKER_ALLOWED_FILE_TYPES: " , " },
+ ])("claims nothing, rather than every job, for a malformed allowlist: %j", async (scope) => {
+ const { createWorkerRuntime } = await import("../worker/main");
+ const { resolveWorkerClaimScopeFilter, isPreClaimCandidateAllowed } = await import("../worker/behavior");
+ const filter = resolveWorkerClaimScopeFilter(scope);
+ expect(filter).toMatchObject({ active: true, claimsNothing: true });
+ expect(isPreClaimCandidateAllowed({ document_id: "doc-1", file_type: "text/plain" }, filter)).toBe(false);
+ for (const [name, value] of Object.entries(scope)) vi.stubEnv(name, value);
+ const rpc = vi.fn(async () => ({ data: [claimedRow()], error: null }));
+ try {
+ const runtime = createWorkerRuntime({ client: { rpc } as unknown as SupabaseClient });
+ expect(await runtime.claimJobs()).toEqual([]);
+ expect(rpc).not.toHaveBeenCalled();
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
+ it("rejects a non-UUID document ID before the uuid[] claim RPC and reports it once", async () => {
+ const { createWorkerRuntime } = await import("../worker/main");
+ const { resolveWorkerClaimScopeFilter } = await import("../worker/behavior");
+ const valid = claimedRow().document_id;
+ for (const value of ["not-a-uuid", `${valid}, doc-1`]) {
+ expect(resolveWorkerClaimScopeFilter({ WORKER_ALLOWED_DOCUMENT_IDS: value })).toMatchObject({
+ active: true,
+ claimsNothing: true,
+ configurationError: expect.stringContaining("non-UUID"),
+ });
+ }
+ expect(resolveWorkerClaimScopeFilter({ WORKER_ALLOWED_DOCUMENT_IDS: valid.toUpperCase() })).toMatchObject({
+ claimsNothing: false,
+ configurationError: null,
+ });
+ vi.stubEnv("WORKER_ALLOWED_DOCUMENT_IDS", `${valid}, doc-1`);
+ const rpc = vi.fn(async () => ({ data: [claimedRow()], error: null }));
+ const report = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ const runtime = createWorkerRuntime({ client: { rpc } as unknown as SupabaseClient });
+ expect(await runtime.claimJobs()).toEqual([]);
+ expect(await runtime.claimJobs()).toEqual([]);
+ expect(rpc).not.toHaveBeenCalled();
+ expect(report).toHaveBeenCalledTimes(1);
+ expect(String(report.mock.calls[0]?.[0])).toContain("doc-1");
+ } finally {
+ report.mockRestore();
+ vi.unstubAllEnvs();
+ }
+ });
+
+ it("treats blank allowlists as unset", async () => {
+ const { resolveWorkerClaimScopeFilter } = await import("../worker/behavior");
+ expect(
+ resolveWorkerClaimScopeFilter({ WORKER_ALLOWED_DOCUMENT_IDS: "", WORKER_ALLOWED_FILE_TYPES: " " }),
+ ).toMatchObject({ active: false, claimsNothing: false });
+ });
+
it.each([
{ data: { ok: false, reason: "lease_lost" }, error: null },
{ data: null, error: { message: "synthetic transport failure" } },
diff --git a/worker/behavior.ts b/worker/behavior.ts
index f891bf4f53..6fb37f8a06 100644
--- a/worker/behavior.ts
+++ b/worker/behavior.ts
@@ -684,3 +684,103 @@ export function serializeDocumentPageRowsForCommit(
}
return rows;
}
+
+export type WorkerClaimScopeFilter = {
+ allowedDocumentIds: ReadonlySet;
+ allowedFileTypes: ReadonlySet;
+ active: boolean;
+ /** A malformed allowlist (no valid entries, or a non-UUID document ID): claim nothing rather than everything. */
+ claimsNothing: boolean;
+ /** Why the configured scope was rejected, for one operator-facing warning. */
+ configurationError: string | null;
+};
+
+const claimDocumentIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+
+function normalizeClaimDocumentId(value?: string | null): string {
+ return (value ?? "").trim().toLowerCase();
+}
+
+function normalizeClaimMimeType(value?: string | null): string {
+ return (value ?? "").split(";")[0]!.trim().toLowerCase();
+}
+
+export function resolveWorkerClaimScopeFilter(
+ environment: Record = process.env,
+): WorkerClaimScopeFilter {
+ const parseList = (raw: string | undefined, normalize: (item: string) => string) =>
+ new Set(
+ (raw ?? "")
+ .split(",")
+ .map((item) => normalize(item))
+ .filter(Boolean),
+ );
+ const allowedDocumentIds = parseList(environment.WORKER_ALLOWED_DOCUMENT_IDS, normalizeClaimDocumentId);
+ const allowedFileTypes = parseList(environment.WORKER_ALLOWED_FILE_TYPES, normalizeClaimMimeType);
+ // Blank means unset (the .env.example default). A configured list that parses to no
+ // entries is malformed; dropping it would silently widen the scope to every job.
+ const malformed = (raw: string | undefined, parsed: ReadonlySet) =>
+ (raw ?? "").trim() !== "" && parsed.size === 0;
+ // claim_ingestion_jobs takes uuid[]; one bad ID would fail every claim request, so
+ // reject the whole document scope instead of sending it or silently dropping the entry.
+ const invalidDocumentIds = [...allowedDocumentIds].filter((id) => !claimDocumentIdPattern.test(id));
+ const configurationError =
+ invalidDocumentIds.length > 0
+ ? `WORKER_ALLOWED_DOCUMENT_IDS contains non-UUID value(s): ${invalidDocumentIds.slice(0, 3).join(", ")}`
+ : malformed(environment.WORKER_ALLOWED_DOCUMENT_IDS, allowedDocumentIds)
+ ? "WORKER_ALLOWED_DOCUMENT_IDS is set but lists no document IDs"
+ : malformed(environment.WORKER_ALLOWED_FILE_TYPES, allowedFileTypes)
+ ? "WORKER_ALLOWED_FILE_TYPES is set but lists no MIME types"
+ : null;
+ const claimsNothing = configurationError !== null;
+ return {
+ allowedDocumentIds,
+ allowedFileTypes,
+ active: claimsNothing || allowedDocumentIds.size > 0 || allowedFileTypes.size > 0,
+ claimsNothing,
+ configurationError,
+ };
+}
+
+export function isPreClaimCandidateAllowed(
+ candidate: { document_id?: string | null; file_type?: string | null },
+ filter: WorkerClaimScopeFilter,
+): boolean {
+ if (!filter.active) return true;
+ if (filter.claimsNothing) return false;
+ const docId = normalizeClaimDocumentId(candidate.document_id);
+ const fileType = normalizeClaimMimeType(candidate.file_type);
+ if (filter.allowedDocumentIds.size > 0 && (!docId || !filter.allowedDocumentIds.has(docId))) {
+ return false;
+ }
+ if (filter.allowedFileTypes.size > 0 && (!fileType || !filter.allowedFileTypes.has(fileType))) {
+ return false;
+ }
+ return true;
+}
+
+export function isClaimableIngestionJobCandidate(
+ candidate: {
+ status?: string | null;
+ attempt_count?: number | null;
+ max_attempts?: number | null;
+ next_run_at?: string | null;
+ lease_expires_at?: string | null;
+ },
+ nowMs: number = Date.now(),
+): boolean {
+ const attemptCount = candidate.attempt_count ?? 0;
+ const maxAttempts = candidate.max_attempts ?? 3;
+ if (attemptCount >= maxAttempts) return false;
+ if (candidate.status === "pending") {
+ if (!candidate.next_run_at) return true;
+ const nextRunMs = Date.parse(candidate.next_run_at);
+ return Number.isFinite(nextRunMs) && nextRunMs <= nowMs;
+ }
+ if (candidate.status === "processing") {
+ if (!candidate.lease_expires_at) return false;
+ const leaseExpiresMs = Date.parse(candidate.lease_expires_at);
+ return Number.isFinite(leaseExpiresMs) && leaseExpiresMs <= nowMs;
+ }
+ return false;
+}
diff --git a/worker/main.ts b/worker/main.ts
index c8b78d2c08..0ed79af99c 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -88,6 +88,9 @@ import {
runIngestionPreparedWork,
writeIngestionTableFacts,
visionImageRejectionSkipReason,
+ resolveWorkerClaimScopeFilter,
+ isPreClaimCandidateAllowed,
+ isClaimableIngestionJobCandidate,
} from "./behavior";
import { WorkerRuntimeControl, WorkerAbortError } from "./runtime-control";
import { runWorkerLoop } from "./run-loop";
@@ -106,6 +109,9 @@ export {
dataPlaneAbortSignal,
withControlPlaneAbortClient,
isWorkerClaimLoopPaused,
+ resolveWorkerClaimScopeFilter,
+ isPreClaimCandidateAllowed,
+ isClaimableIngestionJobCandidate,
};
import {
captureGenerationImages,
@@ -391,15 +397,32 @@ export function createWorkerRuntime(options: WorkerRuntimeOptions) {
// The declared JobRow[] return is the compile-time proof that the claim contract's
// inferred row type stays assignable to JobRow — weakening the schema fails here.
+ let reportedClaimScopeError: string | null = null;
async function claimJobs(stopSignal?: AbortSignal): Promise {
if (isWorkerClaimLoopPaused(process.env)) {
return [];
}
+ const scopeFilter = resolveWorkerClaimScopeFilter(process.env);
+ // A malformed allowlist (e.g. "," or a non-UUID ID) must never fall through to the
+ // unscoped claim, nor reach the uuid[] RPC parameter and fail every poll.
+ if (scopeFilter.claimsNothing) {
+ if (scopeFilter.configurationError !== reportedClaimScopeError) {
+ reportedClaimScopeError = scopeFilter.configurationError;
+ console.error(`Worker claim scope rejected; claiming no jobs: ${scopeFilter.configurationError}`);
+ }
+ return [];
+ }
const { data, error } = await supabase
.rpc("claim_ingestion_jobs", {
p_worker_id: workerId,
p_claim_limit: env.WORKER_CONCURRENCY,
p_stale_after_minutes: env.WORKER_STALE_AFTER_MINUTES,
+ ...(scopeFilter.active
+ ? {
+ p_allowed_document_ids: scopeFilter.allowedDocumentIds.size ? [...scopeFilter.allowedDocumentIds] : null,
+ p_allowed_file_types: scopeFilter.allowedFileTypes.size ? [...scopeFilter.allowedFileTypes] : null,
+ }
+ : {}),
})
.abortSignal(controlPlaneAbortSignal(stopSignal ?? activeStopSignal));