diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md
index 7f7169cc..240c1533 100644
--- a/docs/branch-review-ledger.md
+++ b/docs/branch-review-ledger.md
@@ -681,3 +681,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD
| 2026-07-21 | claude/patient-profile-input-bounds-123366 (PR #1045: FV-03 fail-safe input bounds) | 75303e8b8 | Clinical-governance verification of FV-03 (patient-profile numeric fields → medication-safety alert engine). 4-agent adversarial workflow (consumer map + suppression audit + physiological bounds + synthesis). | ADJUST→implemented. Consumer map: evaluatePatientAlerts is the ONLY numeric consumer, no dose arithmetic, sanitize() is the sole guaranteed chokepoint. Suppression audit found naive null-routing UNSAFE via the bare-renal both-null hole (medication-patient-alerts.ts:286) — nulling one out-of-range renal input while the other is present-normal → false all-clear; fixed with &&→|| (0 bare-renal contraindication rows in corpus → no-op on current data). Bounds VALIDATED (age 0-130, egfr 0-250, crcl 0-400, qtc 240-800, scr µmol/L 15-3000 unit-aware): never reject a legitimate clinical extreme. Reject-to-null (never clamp). | typecheck, lint, format:check, full unit+jsdom 349 files/3120 passed/0 failed (incl. 38 new/updated FV-03 tests), design-system-contract (baselines unchanged), type-scale, icon-scale, check:production-readiness READY. verify:ui in CI. No provider calls. |
| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A wave verdict — no code change; #1040 merged as cde6c5c) | canary run 29827012719 (#61, main cde6c5c) vs banked #60 (29800029819) | OPTION A WAVE ADOPTED — FIRST FULLY-GREEN 44-CASE CANARY IN PROGRAM HISTORY (Blocking failures: None). (1) Option A payoff EXCEEDED: citation_failure_rate 0.0227→0; the neuroleptic-side-effect-escalation case flipped from wrong-doc→failed-generation→1-citation-fallback to **strong route, successful gpt-5.6-sol generation, passed in 15.4s with no fallback marker** — the rescued S3 retrieval fixed generation itself, not just the citation count; expected_source_hit 0.6364→0.6591. (2) Golden held exactly as the blast-radius analysis promised: 36/36 PASS, content_recall 1.0, mrr@10 0.8921 BYTE-IDENTICAL to the pre-wave baseline (zero ordering movement — no golden case fires the predicate), irrelevant@10 0.1083→0.0917 (slightly better). (3) Parity payoff PARTIAL: monitoring targeting 1/5→2/5 (olanzapine-lai flipped — previously called a retrieval-depth residual; quetiapine-dose also flipped on the dose side); lithium-range (232ch) + metabolic (73ch, byte-identical answer to #60) did NOT flip despite offline-proven fixes — their live chunk sets evidently contain no admissible schedule sentence even under the widened gate → reclassified as retrieval-depth/live-content residuals joining adhd; below the ≥3/5 target but strictly improved, no regression anywhere. Dose 2/5 vs 2/4: same passing count, applicable set grew (new quality-metformin-renal-dosing miss = eval-set churn, not regression). (4) No-worse EXCEEDED: relevance 0.5333→0.6 (the two-step watch-item slide FULLY REVERSED to the #58 level), targeting_rate 0.6667→0.6957, fail_closed 0.9 held, readability/artifact_leaks 1.0, route ceilings 0, grounded 1.0, unsupported_correct 1.0, numeric 0, p95 22.8s, red_result 3/3. Adoption per the measured-gain rule: primary goal achieved, three case flips, relevance recovered, zero regressions. Residual queue: monitoring retrieval-depth trio (lithium-range/metabolic/adhd), E-3d H2 discards, weekly ANSWER_CASE_LIMIT 8→44 raise now unblocked (gate would be green), comparison-class coverage. Wave spend +~$2-4 → Phase E + Option A total ~$12-20 of ≤$20. | Evidence: run #61 job log read (Threshold Status: None; Answer Metrics; neuroleptic diagnostics row; targeting metric_rates + 6-miss list; golden 36 PASS lines + summary). Revert drill NOT triggered. |
| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: I9 weekly coverage raise) | see PR head | ADDENDUM 5 post-green item I9 (plan-authorized "after reds fixed"): weekly scheduled canary ANSWER_CASE_LIMIT default 8→44 — the Sunday 18:00 UTC cron now guards the FULL answer-quality case set instead of the first 8 (both #57 blocking reds historically lived OUTSIDE the first 8, leaving the weekly gate blind to them). Unblocked by run #61 proving the citation gate green on the full 44. Cost: est +$1-2/week (user-authorized in the plan). Contract test pin updated in lockstep (eval-canary-workflow.test.ts). Dispatch shapes unchanged (input override still wins); operational-risk diff, plain-revert rollback. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS (20/20); eval-canary-workflow contract 4/4; prettier clean; no provider calls |
+| 2026-07-21 | claude/database-governance-audit-10b6ed (PR #1051: source-governance audit — safe subset) | cee396730 | Governance-metadata observability + UI display + provenance flow test; no ranking/retrieval/generation surface touched. | IMPLEMENTED + handed off (not a review of prior work). Resolved audit #1 (logger.warn on unrecognized enum values; return value unchanged), #2 (review_due_source added to frontendVisibleWarningCodes → answer-level badge; warning-severity, no refusal impact), #9 (source_metadata retained on safety-finding citations + governance pill in SafetyFindingsListContent), #13 (new tests/provenance-flow.test.ts: DB-normalize→governance→client payload sources+safety citations→render policy). Deferred #4/5/6/8/10 (RAG-protected ranking/selection/LLM-context/cache — need live eval-canary+approval), #11/#5 flag debt (D5/D4), #3 (is_public schema/RLS), #7 (conflict-detection scope), #12 (canary automation). Rebased onto origin/main (was 18 behind; conflict-free — none of the 18 commits touched the 8 files). PR-policy CI green (confirmed no ragRankingPatterns match). | verify:pr-local exit 0 (351 files/3129 tests, production build, client-bundle secret scan, offline RAG fixtures 36/36); typecheck + lint + prettier green. verify:ui NOT run locally: pre-existing globals.css Tailwind/Turbopack dev-compile error (git-clean, unrelated; prod build passed) — CI Production UI job covers it. check:production-readiness deferred (offline env/config validator; PR changes no env/secret/config inputs; secretless worktree). No provider calls. |
diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx
index 00db8fd1..144fe9b3 100644
--- a/src/components/clinical-dashboard/evidence-panels.tsx
+++ b/src/components/clinical-dashboard/evidence-panels.tsx
@@ -71,7 +71,7 @@ import {
type SafetyFinding,
type SafetyFindingKind,
} from "@/lib/clinical-safety";
-import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata";
+import { normalizeSourceMetadata, sourceStatusLabel, validationStatusLabel } from "@/lib/source-metadata";
import { normalizeExtractedGlyphs, sourceTextForVerbatimQuote } from "@/lib/source-text-sanitizer";
import type {
AnswerSection,
@@ -868,6 +868,23 @@ function SafetyFindingRowIcon({ kind }: { kind: SafetyFindingKind }) {
return ;
}
+// Issue 9: governance provenance retained on safety-finding citations lets the safety
+// panel badge sources that are outdated, due for review, or not locally validated —
+// the same currency/validation signals shown for ordinary source citations. Current
+// and unknown-status sources add no badge (they carry no actionable caveat here).
+function safetyFindingGovernanceLabels(citation: SafetyFinding["citation"]): string[] {
+ const metadata = citation.source_metadata ? normalizeSourceMetadata(citation.source_metadata) : null;
+ if (!metadata) return [];
+ const labels: string[] = [];
+ if (metadata.document_status === "outdated" || metadata.document_status === "review_due") {
+ labels.push(sourceStatusLabel(metadata));
+ }
+ if (metadata.clinical_validation_status === "unverified") {
+ labels.push(validationStatusLabel(metadata));
+ }
+ return labels;
+}
+
export function SafetyFindingsListContent({ findings }: { findings: SafetyFinding[] }) {
if (findings.length === 0) return null;
@@ -908,6 +925,15 @@ export function SafetyFindingsListContent({ findings }: { findings: SafetyFindin
{formatCompactCitationLabel(finding.citation)}
+ {safetyFindingGovernanceLabels(finding.citation).map((label) => (
+
+ {label}
+
+ ))}
{finding.text}
diff --git a/src/lib/answer-response.ts b/src/lib/answer-response.ts
index e603c3fe..34e3d51e 100644
--- a/src/lib/answer-response.ts
+++ b/src/lib/answer-response.ts
@@ -20,6 +20,11 @@ function clientSafetyWarning(warning: SafetyWarning): SafetyWarning {
chunk_index: citation.chunk_index,
...(citation.similarity === undefined ? {} : { similarity: citation.similarity }),
...(citation.provenance === undefined ? {} : { provenance: citation.provenance }),
+ // Issue 9: keep governance provenance on safety-finding citations. Regular
+ // source citations already retain it (answer-client-payload `source_metadata:
+ // "client"`); dropping it here left the safety panel unable to badge outdated /
+ // review-due / unverified provenance for its citations.
+ ...(citation.source_metadata === undefined ? {} : { source_metadata: citation.source_metadata }),
},
};
}
diff --git a/src/lib/source-governance.ts b/src/lib/source-governance.ts
index d5515624..954bfb52 100644
--- a/src/lib/source-governance.ts
+++ b/src/lib/source-governance.ts
@@ -60,6 +60,11 @@ const frontendVisibleWarningCodes = new Set([
// validated" is a clinically material caveat, not routine review metadata.
"unverified_source",
"registry_record_source",
+ // A source past its review date is a material currency caveat: guidance may have
+ // moved on. Surfacing the badge keeps the governance notice consistent with the
+ // review-due language already emitted in the render-policy copy text
+ // (answer-render-policy.buildWarnings), which the badge list previously suppressed.
+ "review_due_source",
]);
function isLocalMetadataText(value: string) {
diff --git a/src/lib/source-metadata.ts b/src/lib/source-metadata.ts
index c649e76c..aaa07496 100644
--- a/src/lib/source-metadata.ts
+++ b/src/lib/source-metadata.ts
@@ -1,3 +1,4 @@
+import { logger } from "@/lib/logger";
import type { ClinicalSourceMetadata } from "@/lib/types";
const knownStatuses = new Set(["current", "review_due", "outdated", "unknown"]);
@@ -8,8 +9,18 @@ function stringOrNull(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
-function enumOrDefault(value: unknown, allowed: Set, fallback: T): T {
- return typeof value === "string" && allowed.has(value) ? (value as T) : fallback;
+function enumOrDefault(value: unknown, allowed: Set, fallback: T, field: string): T {
+ if (typeof value === "string" && allowed.has(value)) return value as T;
+ // A present-but-unrecognized string is a real data-entry defect (typo, renamed
+ // enum, malformed ingest) that would otherwise collapse into the fallback and be
+ // indistinguishable from a genuinely-absent value. Trace it so it is fixable.
+ // Absent / null / empty values are the legitimate default and stay silent — they
+ // are the common case and would drown the signal. The returned value is unchanged,
+ // so this is observability only: no ranking/retrieval behaviour changes.
+ if (typeof value === "string" && value.trim()) {
+ logger.warn(`source-metadata: unrecognized ${field}`, { field, value });
+ }
+ return fallback;
}
export function normalizeSourceMetadata(input: unknown): ClinicalSourceMetadata {
@@ -31,9 +42,14 @@ export function normalizeSourceMetadata(input: unknown): ClinicalSourceMetadata
uploaded_at: stringOrNull(value.uploaded_at),
indexed_at: stringOrNull(value.indexed_at),
uploaded_by: stringOrNull(value.uploaded_by),
- document_status: enumOrDefault(value.document_status, knownStatuses, "unknown"),
- clinical_validation_status: enumOrDefault(value.clinical_validation_status, knownValidation, "unverified"),
- extraction_quality: enumOrDefault(value.extraction_quality, knownExtraction, "unknown"),
+ document_status: enumOrDefault(value.document_status, knownStatuses, "unknown", "document_status"),
+ clinical_validation_status: enumOrDefault(
+ value.clinical_validation_status,
+ knownValidation,
+ "unverified",
+ "clinical_validation_status",
+ ),
+ extraction_quality: enumOrDefault(value.extraction_quality, knownExtraction, "unknown", "extraction_quality"),
};
}
diff --git a/tests/answer-client-payload.test.ts b/tests/answer-client-payload.test.ts
index 78fda5eb..6a28a7e5 100644
--- a/tests/answer-client-payload.test.ts
+++ b/tests/answer-client-payload.test.ts
@@ -108,7 +108,10 @@ describe("toClientAnswerPayload", () => {
expect(payload.sources![0].content).toBe(source.retrieval_synopsis);
expect(payload.sources![0].content.length).toBeLessThan(source.content.length);
expect(payload.safetyWarnings).toHaveLength(1);
- expect(payload.safetyWarnings![0].citation).not.toHaveProperty("source_metadata");
+ // Issue 9: governance provenance is retained on safety-finding citations so the
+ // safety panel can badge outdated / review-due / unverified sources, consistent
+ // with regular source citations (which already keep source_metadata).
+ expect(payload.safetyWarnings![0].citation).toHaveProperty("source_metadata", { document_status: "current" });
expect(extractSafetyFindings(payload)).toHaveLength(1);
});
diff --git a/tests/provenance-flow.test.ts b/tests/provenance-flow.test.ts
new file mode 100644
index 00000000..8009ddfb
--- /dev/null
+++ b/tests/provenance-flow.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from "vitest";
+
+import { buildAnswerRenderModel } from "../src/lib/answer-render-policy";
+import { buildGovernedAnswerClientResponse } from "../src/lib/answer-response";
+import { frontendSourceGovernanceWarnings, sourceGovernanceWarnings } from "../src/lib/source-governance";
+import { normalizeSourceMetadata } from "../src/lib/source-metadata";
+import type { RagAnswer, SearchResult } from "../src/lib/types";
+
+// Issue 13: a single end-to-end guard that governance provenance survives every hop
+// of the pipeline — DB-boundary normalization → governance warnings → governed client
+// payload (both source citations AND safety-finding citations) → render policy. Unit
+// tests cover each stage in isolation; this proves the joints between them, and is the
+// test that would have caught the safety-citation metadata drop (Issue 9).
+
+function buildSource(overrides: Partial = {}): SearchResult {
+ return {
+ id: "chunk-1",
+ document_id: "doc-1",
+ title: "Clozapine monitoring guideline",
+ file_name: "clozapine.pdf",
+ page_number: 1,
+ chunk_index: 0,
+ section_heading: null,
+ content: "Full blood count weekly for 18 weeks.",
+ image_ids: [],
+ similarity: 0.42,
+ source_metadata: {
+ source_title: "Clozapine monitoring guideline",
+ publisher: "WA Health",
+ jurisdiction: "Australia/WA",
+ version: null,
+ publication_date: null,
+ review_date: null,
+ uploaded_at: null,
+ indexed_at: null,
+ uploaded_by: null,
+ document_status: "current",
+ clinical_validation_status: "approved",
+ extraction_quality: "good",
+ },
+ indexing_quality: {
+ document_id: "doc-1",
+ quality_score: 0.9,
+ extraction_quality: "good",
+ metrics: {},
+ issues: [],
+ },
+ table_facts: [],
+ images: [],
+ ...overrides,
+ };
+}
+
+describe("source governance provenance flows end-to-end", () => {
+ it("carries warning-level governance metadata from DB normalization through the client payload to the render policy", () => {
+ const source = buildSource({
+ // Proven safety-deriving content (mirrors answer-client-payload fixture): the
+ // useful clinical sentence is a contraindication, so a safety finding is derived.
+ content: `${"Routine context. ".repeat(60)}Contraindicated in severe disease.`,
+ source_metadata: {
+ source_title: "Clozapine monitoring guideline",
+ publisher: "WA Health",
+ jurisdiction: "Australia/WA",
+ version: null,
+ publication_date: null,
+ review_date: "2020-01-01",
+ uploaded_at: null,
+ indexed_at: null,
+ uploaded_by: null,
+ document_status: "review_due",
+ clinical_validation_status: "unverified",
+ extraction_quality: "good",
+ },
+ });
+
+ // Hop 1 — DB boundary: raw metadata normalizes to the governance fields.
+ const normalized = normalizeSourceMetadata(source.source_metadata);
+ expect(normalized.document_status).toBe("review_due");
+ expect(normalized.clinical_validation_status).toBe("unverified");
+
+ // Hop 2 — governance: currency + validation caveats are both frontend-visible.
+ const warnings = sourceGovernanceWarnings({ results: [source] });
+ const visibleCodes = frontendSourceGovernanceWarnings(warnings).map((warning) => warning.code);
+ expect(visibleCodes).toEqual(expect.arrayContaining(["review_due_source", "unverified_source"]));
+
+ const answer = {
+ answer: "Review the monitoring guidance before prescribing.",
+ grounded: true,
+ confidence: "medium",
+ citations: [],
+ sources: [source],
+ } as RagAnswer;
+
+ const response = buildGovernedAnswerClientResponse(answer);
+
+ // Warning-level governance does not refuse — the answer (and its provenance) flows through.
+ expect(response.refused).toBe(false);
+
+ // Hop 3a — client payload source citations retain provenance.
+ expect(response.payload.sources?.[0]?.source_metadata).toMatchObject({
+ document_status: "review_due",
+ clinical_validation_status: "unverified",
+ });
+
+ // Hop 3b — safety-finding citations retain provenance (Issue 9 regression guard).
+ expect(response.payload.safetyWarnings?.length ?? 0).toBeGreaterThan(0);
+ expect(response.payload.safetyWarnings?.[0]?.citation.source_metadata).toMatchObject({
+ document_status: "review_due",
+ clinical_validation_status: "unverified",
+ });
+
+ // Hop 4 — render policy surfaces the governance message in the render model.
+ const renderModel = buildAnswerRenderModel({ ...answer, sourceGovernanceWarnings: warnings } as RagAnswer);
+ expect(renderModel.warnings).toEqual(
+ expect.arrayContaining(["One or more supporting sources are due for review."]),
+ );
+ });
+
+ it("fails conservatively (refuses) when a supporting source is outdated, instead of passing stale guidance through", () => {
+ const outdatedSource = buildSource({
+ document_id: "doc-outdated",
+ source_metadata: {
+ source_title: "Superseded protocol",
+ publisher: "WA Health",
+ jurisdiction: "Australia/WA",
+ version: null,
+ publication_date: null,
+ review_date: null,
+ uploaded_at: null,
+ indexed_at: null,
+ uploaded_by: null,
+ document_status: "outdated",
+ clinical_validation_status: "approved",
+ extraction_quality: "good",
+ },
+ });
+
+ const response = buildGovernedAnswerClientResponse({
+ answer: "Outdated guidance.",
+ grounded: true,
+ confidence: "medium",
+ citations: [],
+ sources: [outdatedSource],
+ } as RagAnswer);
+
+ // An outdated source is a danger-class governance signal: the governed contract
+ // refuses and strips sources rather than serving stale clinical guidance.
+ expect(response.refused).toBe(true);
+ expect(response.payload.sources).toHaveLength(0);
+ expect(response.payload.safetyWarnings).toHaveLength(0);
+ });
+});
diff --git a/tests/source-governance.test.ts b/tests/source-governance.test.ts
index e49c74d9..8dfb1389 100644
--- a/tests/source-governance.test.ts
+++ b/tests/source-governance.test.ts
@@ -187,7 +187,7 @@ describe("source governance warnings", () => {
expect(hasDangerSourceGovernanceWarning(warnings)).toBe(false);
});
- it("keeps review-due notes hidden while surfacing the unvalidated-source caveat", () => {
+ it("surfaces review-due and unvalidated-source caveats to the frontend", () => {
const warnings = sourceGovernanceWarnings({
results: [
result({
@@ -221,9 +221,13 @@ describe("source governance warnings", () => {
expect.arrayContaining(["review_due_source", "unverified_source"]),
);
// With the whole indexed corpus promoted public regardless of validation
- // status, "not locally validated" must stay visible; review-due remains a
- // routine metadata note.
- expect(frontendSourceGovernanceWarnings(warnings).map((warning) => warning.code)).toEqual(["unverified_source"]);
+ // status, "not locally validated" must stay visible. A source past its review
+ // date is likewise a material currency caveat, so its badge is surfaced too
+ // (Issue 2) — kept consistent with the render-policy copy text.
+ expect(frontendSourceGovernanceWarnings(warnings).map((warning) => warning.code)).toEqual([
+ "review_due_source",
+ "unverified_source",
+ ]);
});
it("surfaces only clinically material warnings to the frontend", () => {
@@ -246,7 +250,6 @@ describe("source governance warnings", () => {
expect(visibleCodes).toEqual(
expect.arrayContaining(["weak_evidence", "outdated_source", "poor_extraction", "unverified_source"]),
);
- expect(visibleCodes).not.toContain("review_due_source");
expect(visibleCodes).not.toContain("non_local_source");
});
diff --git a/tests/source-metadata.test.ts b/tests/source-metadata.test.ts
index 3aa65650..5a0411e3 100644
--- a/tests/source-metadata.test.ts
+++ b/tests/source-metadata.test.ts
@@ -1,4 +1,5 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+import { logger } from "../src/lib/logger";
import {
clipboardProvenanceLine,
formatClinicalDate,
@@ -20,6 +21,50 @@ describe("source metadata helpers", () => {
expect(sourceProvenanceSummary(metadata)).toContain("Review status unknown");
});
+ it("traces unrecognized enum values via logger.warn while keeping the safe fallback, and stays silent for absent/blank inputs", () => {
+ // Issue 1: a present-but-unrecognized value (data-entry typo) is traced via
+ // logger.warn but must still coerce to the same safe fallback as before, so no
+ // downstream ranking/rendering behaviour changes. Absent/blank inputs are the
+ // legitimate default and must stay silent so the trace signal is not drowned.
+ const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
+ try {
+ const metadata = normalizeSourceMetadata({
+ document_status: "revieww_due",
+ clinical_validation_status: "aproved",
+ extraction_quality: "gud",
+ });
+
+ // Return value is unchanged — the same safe fallbacks as before.
+ expect(metadata.document_status).toBe("unknown");
+ expect(metadata.clinical_validation_status).toBe("unverified");
+ expect(metadata.extraction_quality).toBe("unknown");
+
+ // Each unrecognized non-empty value is traced once, with its field + value.
+ expect(warnSpy).toHaveBeenCalledTimes(3);
+ expect(warnSpy).toHaveBeenCalledWith("source-metadata: unrecognized document_status", {
+ field: "document_status",
+ value: "revieww_due",
+ });
+ expect(warnSpy).toHaveBeenCalledWith("source-metadata: unrecognized clinical_validation_status", {
+ field: "clinical_validation_status",
+ value: "aproved",
+ });
+ expect(warnSpy).toHaveBeenCalledWith("source-metadata: unrecognized extraction_quality", {
+ field: "extraction_quality",
+ value: "gud",
+ });
+
+ // Absent (null / undefined) and blank/whitespace values are the legitimate
+ // default and never warn.
+ warnSpy.mockClear();
+ normalizeSourceMetadata(null);
+ normalizeSourceMetadata({ document_status: "", clinical_validation_status: " " });
+ expect(warnSpy).not.toHaveBeenCalled();
+ } finally {
+ warnSpy.mockRestore();
+ }
+ });
+
it("formats dates using Australian date order", () => {
expect(formatClinicalDate("2026-05-18T10:00:00.000+08:00")).toBe("18/05/2026");
});