Status: ✅ Implemented (all four phases shipped). This document began as the design plan and is retained as the design record; the "Phased delivery" section below maps each phase to the PR and the code that shipped it. The end-user description lives in the in-app reference doc and the User Guide → "Clinical report, traceability & sign-out" section.
Goal: make a reported result reproducible and medico-legally defensible by locking it to exactly what produced it — the annotation/reference module versions, the filter settings, the variant list, and the classifier inputs — backed by an immutable record of who classified/tagged/edited what and when, and a frozen, versioned case sign-out.
| Phase | Ships | PR |
|---|---|---|
| 0 | Annotation/reference version manifest + report footer | #220 |
| 1 | Per-classification evidence snapshot + drift surfacing | #221 |
| 2 | Immutable clinical audit trail | #222 |
| 3 | Case sign-out + frozen, versioned, content-hashed snapshot | #223 |
This document maps what existed before, the gaps it closed, and how it was delivered. It is grounded in the current code (file references inline).
- Provenance of the data. A report states which modules/versions produced its inputs: reference assembly, VEP, ClinVar, gnomAD, dbNSFP, SpliceAI, GenCC, PanelApp, Monarch, HPO — with a clear timestamp in a report footer.
- Provenance of the interpretation. Each classification records the evidence it was based on (ClinVar significance, gnomAD AF, in-silico scores, gene constraint, annotation version), frozen at classification time.
- Drift surfacing. When the underlying evidence changes after a classification (a new ClinVar release, a new annotation version), the platform flags "evidence has changed since you classified this" so stale interpretations don't silently persist.
- Immutable audit trail. An append-only log of who classified/tagged/edited/signed what, and when, queryable by case and variant.
- Case sign-out. A frozen, versioned snapshot of the report at sign-out — the variant list, the filter settings that produced it, the classifications + their evidence snapshots, the pedigree version, and the module-version manifest — content-hashed and tamper-evident. Amendments create a new version; the signed snapshot is never mutated.
| Capability | Status | Where |
|---|---|---|
| Append-only HTTP audit log | ✅ | audit_log_events (04_traceability.sql); immutability trigger (04_traceability.sql); writer services/audit_log_pg.py; middleware middleware/request_logging.py; admin UI AdminAuditLogsPage.tsx |
| Per-variant annotation version + set hash | ✅ (label only) | ClickHouse annotation_version (default "current") + annotationSetHash — clickhouse_variant_storage.py |
| Per-family annotation/tool provenance from VCF headers | ✅ | family_annotation_manifest (source='vcf_header'); parser vcf_header_provenance.py; captured at import for SNV/SV/TRGT, refreshed on re-import, frozen at sign-out; shown on the filter page + report — see annotation-provenance.md |
| Reference import bookkeeping | ◑ partial | reference_dataset_imports (dataset, performed_at/by, source); assemblies (version, release_date); monarch_gene_disease.release_version (the only explicit source version); raw_import_files (input file name/path/sha256/source/metadata); gene_info_refresh_jobs (sync history, no source version) |
| Review state | ✅ (decision only) | small_variant_reviews / structural_variant_reviews: acmg JSONB {criteria, point_total, classification, vus_tier}, acmg_class, tags, tag_metadata {per-tag who/when}, note, updated_by/at, created_at. Score recomputed server-side (acmg_points.py, small_variant_review_pg.py) |
| Family workflow status | ✅ (mutable) | family_statuses + families.status_id/assigned_to_id/reviewed_by_id (03_assay.sql) |
| Versioned snapshot pattern | ✅ (reusable) | family_structure_versions (id, family_id, version, structure_hash, snapshot JSONB, metadata, created_by, created_at, UNIQUE(family_id, version)) — ped_service.py. This is the exact pattern to reuse for report snapshots. |
| Report generation | ✅ (live) | FamilyReportPage.tsx / FamilyNiptReportPage.tsx fetch variants tagged report, build prose via reportNarrative.ts, browser-print. No frozen snapshot, no footer, no sign-out. |
Upstream annotation versions are lost at import.✅ Addressed — the VCF##headers (VEP + embedded ClinVar/gnomAD/dbNSFP/SpliceAI, plus the SNV/SV/TRGT callers) are now parsed at import and stored per family (source='vcf_header'). See annotation-provenance.md.- Reference versions are partially tracked. Only Monarch records a release; gnomAD/ClinVar (as loaded into the platform), GenCC, PanelApp, HPO, dbNSFP-gene, ClinGen have no consistent version/release column.
- Classifications store the human decision, not the evidence. The
acmgblob has the criteria and score but not the ClinVar/gnomAD/in-silico/annotation-version inputs the classifier saw — these are recomputed live, so they silently change under the classification. - No drift detection. Nothing compares a classification's basis against current evidence.
- Audit is HTTP-only. Events are inferred from method+path+body; there is no semantic "classification PVS1 added", no field-level before/after, no fast query by variant/family, and no "report signed out" event.
- No sign-out / immutability. Reports are regenerated live and fully mutable; there is no frozen record, no content hash, no footer, no lock.
┌────────────────────────────────────────────────────────────┐
import → │ (A) Annotation/reference VERSION MANIFEST (per family + │
│ platform reference layer) │
└───────────────┬────────────────────────────────────────────┘
│ stamped into
classify → ┌─────────────▼───────────────┐ compare ┌──────────────┐
│ (B) EVIDENCE SNAPSHOT on each│─────────────▶│ (C) DRIFT │
│ classification (frozen) │ vs current │ surfacing │
└─────────────┬───────────────┘ └──────────────┘
│ included in
sign-out → ┌─────────────▼───────────────────────────────┐
│ (D) FROZEN REPORT SNAPSHOT (versioned, │
│ content-hashed, append-only) + footer │
└─────────────┬───────────────────────────────┘
│ every step writes to
┌─────────────▼───────────────────────────────┐
│ (E) CLINICAL AUDIT TRAIL (append-only, │
│ keyed by family + variant, with deltas) │
└──────────────────────────────────────────────┘
Two layers, merged by one service:
- Platform reference layer — versions of what CoGA loaded (assembly, Monarch, HPO,
gene reference [ClinGen/GenCC/ClinVar-gene-condition/dbNSFP-gene], PanelApp, clinical CNVs).
Mostly already in Postgres; needs a consistent
source_version/source_release_date. - Per-family pipeline layer — versions of the upstream tools that produced this family's annotated VCF (VEP+cache, ClinVar, gnomAD, dbNSFP, SpliceAI). Captured at import.
-- New: per-family upstream annotation provenance.
CREATE TABLE family_annotation_manifest (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE,
assembly_id UUID REFERENCES assemblies(id) ON DELETE SET NULL,
modules JSONB NOT NULL DEFAULT '{}'::jsonb, -- {vep:{version,cache,assembly},
-- clinvar:{release}, gnomad:{version},
-- dbnsfp:{version}, spliceai:{model}, …}
source TEXT NOT NULL DEFAULT 'manifest', -- 'manifest' | 'vcf_header' | 'manual'
recorded_by TEXT,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (family_id)
);
-- Extend reference import bookkeeping with source release.
ALTER TABLE reference_dataset_imports
ADD COLUMN source_version TEXT,
ADD COLUMN source_release_date DATE,
ADD COLUMN source_url TEXT;- Capture at import: ✅ implemented — the VCF
##headers are parsed at import (vcf_header_provenance.py) for SNV/SV/TRGT and merged into the manifest (source='vcf_header', refreshed on re-import, manual never clobbered); the preprocessing pipeline may additionally declare versions in the package manifest (source='manifest'); admin override issource='manual'. Full design: annotation-provenance.md. - Service:
get_annotation_version_manifest(family) -> ManifestOutmerges the per-family manifest with the current platform reference versions (read fromreference_dataset_imports,monarch_gene_disease,assemblies, HPO/gene/panel sync records). Used by the footer and frozen into the sign-out snapshot.
Decision needed: the authoritative source of the upstream versions. Recommended: the preprocessing pipeline declares them in the import manifest (deterministic), with VCF-header parsing as a fallback and an admin override. This is a small process change upstream of CoGA.
Freeze what the classifier saw. The save path already fetches the ClickHouse record
(get_small_variant_family_record) and the frontend ACMG modal already reads the evidence —
so the inputs are in hand at save time.
ALTER TABLE small_variant_reviews ADD COLUMN acmg_evidence_snapshot JSONB;
ALTER TABLE structural_variant_reviews ADD COLUMN cnv_evidence_snapshot JSONB;- Cheap primary signal: the stored
annotation_set_hashvs the variant's currentannotationSetHashin ClickHouse — a single mismatch means "annotations changed". - Human-readable diff: compare the snapshot's stored
evidencefields against current to produce e.g. "ClinVar: VUS → Pathogenic", "gnomAD AF 0.001 → 0.0005", "annotation v2024-06 → v2024-12". - Surfaces: a
⚠ evidence changedbadge on the variant row / review card; a per-family and cohort "stale classifications" list (drift since classification). Serviceevaluate_classification_drift(family).
Reuse the family_structure_versions pattern; make it append-only like audit_log_events.
CREATE TABLE family_report_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
family_id UUID NOT NULL REFERENCES families(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'signed_out', -- 'signed_out' | 'amended' | 'withdrawn'
content_hash TEXT NOT NULL, -- sha256 of canonical snapshot (tamper-evident)
snapshot JSONB NOT NULL, -- frozen variants + reviews + ACMG + evidence
-- snapshots + pedigree version + ROI + narrative inputs
filter_settings JSONB NOT NULL DEFAULT '{}'::jsonb, -- the search/preset that produced the candidate list
module_versions JSONB NOT NULL DEFAULT '{}'::jsonb, -- the version manifest at sign-out (A)
signed_out_by UUID REFERENCES users(id) ON DELETE SET NULL,
signed_out_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (family_id, version)
);
-- + an append-only trigger mirroring audit_log_events_block_mutation (no UPDATE/DELETE).Sign-out flow (POST /families/{id}/report/sign-out):
- Assemble the snapshot:
report-tagged variants with full review/ACMG/evidence-snapshot, the currentfamily_structure_versionsversion, the ROI, the filter settings used, the module manifest (A). - Compute
content_hash; insert the immutable snapshot row (version = max+1). - Set family status to a locked
signed_outstatus; write a clinical-audit event (E). - The signed snapshot is the legal record; the live review state remains the working copy. An amend produces a new version (never mutates the prior one).
Report rendering: the report page renders from the frozen snapshot for a signed-out case (reproducible) and live for a draft. Both gain a footer:
Report generated 2026-06-25 14:30 UTC · signed out by Dr X · v2 · hash 4f9c… · GRCh38 · VEP 110 (cache 110_GRCh38) · ClinVar 2026-05 · gnomAD 4.1 · dbNSFP 4.7 · SpliceAI 1.3 · GenCC 2026-05 · PanelApp 2026-06 · Monarch 2026-05 · HPO 2026-04
A dedicated append-only table for semantic clinical actions (distinct from the HTTP audit log), keyed for fast case/variant queries, with field-level before/after.
CREATE TABLE clinical_audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_email TEXT, actor_role TEXT,
family_id UUID, variant_id TEXT,
action TEXT NOT NULL, -- 'classify' | 'tag.add' | 'tag.remove' | 'note.edit'
-- | 'structure.edit' | 'report.sign_out' | 'report.amend'
summary TEXT,
before JSONB, after JSONB, -- field-level delta
context JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- + the same append-only trigger.Emit from the classify / tag / note / structure / sign-out service paths. Add a per-family audit timeline UI; the sign-out snapshot embeds (or links) the relevant slice.
Why a second table, not the HTTP
audit_log_events? The HTTP log is infra-level and inferred; clinical defensibility needs intentional, semantic, before/after, fast-by-case events. Both stay append-only; they serve different questions.
family_annotation_manifesttable +source_version/source_release_date/source_urlonreference_dataset_imports(04_traceability.sql). Capture is free: the import already flowsmanifest.metadataintofamily.metadata, so a pipeline-declaredannotation_manifestis read as a fallback;PUT /families/{id}/annotation-manifestrecords or overrides it.annotation_manifest_service.pymerges the per-family pipeline layer with the platform reference layer (assembly + release date, Monarch release, read live); pipeline wins, unknown modules pass through.GET /families/{id}/annotation-manifest.- Report footer (
FamilyReportPage.tsx): generation timestamp + the full module/version list. - Tests:
backend/tests/test_annotation_manifest.py(merge precedence + order; metadata fallback) + the footer test inFamilyReportPage.test.tsx.
acmg_evidence_snapshotJSONB onsmall_variant_reviews(031_…). Captured on every ACMG save from the ClickHouse record the save path already fetches:annotation_version+annotationSetHash(the drift key) + ClinVar significance.get_small_variant_family_recordwas extended to return the annotation identity.classification_drift_service.pycompares each classification's frozen snapshot against the current annotation →current/drifted/variant_missing, with ClinVar + version from→to (drift only when both hashes are known and differ).GET /families/{id}/classification-drift; an amber drift banner on the report.- Tests:
backend/tests/test_classification_drift.py(snapshot extraction, dash handling, diff states, service) + the drift-banner test.
clinical_audit_eventsappend-only table + DB-level immutability trigger (032_…, mirroring029).clinical_audit_service.pyderives granular before→after events (classification with ACMG class + criteria, tags added/removed, note lifecycle) and writes them in the same transaction as the review save (upsert_small_variant_review).GET /families/{id}/clinical-audit; a "Classification audit trail" section on the report.- Tests:
backend/tests/test_clinical_audit.py(diff logic, one insert per change) + the audit-timeline test; immutability proven live (raw UPDATE/DELETE rejected).
report_signoutsappend-only table + immutability trigger (033_…).report_signout_service.pyfreezes the manifest + reported variant list + each classification & its evidence snapshot + the drift state, SHA-256 content-hashes a canonical encoding, and stores it as the next version; the sign-out is recorded in the audit trail.- Drift gate: sign-out returns
409if any classification has drifted, unlessacknowledge_driftis set (baked into the snapshot + audit event). POST /families/{id}/report/sign-out,GET .../report/sign-outs,GET .../report/sign-outs/{version}. The report carries a green frozen sign-out record (version · who · when · content hash) and a "Sign out / Amend sign-out" action.- Tests:
backend/tests/test_report_signout.py(canonical hash stable + order-independent; drift gate 409 / acknowledged; clean sign-out) + the sign-out-record test; immutability proven live.
The four phases shipped as independent, CI-green PRs in order; each migration applies on startup via the dollar-quote-aware loader and is exercised by the CI smoke job.
- Upstream version source (Phase 0). Manifest-declared (recommended) vs VCF-header-parsed vs manual. Header formats vary; the manifest is deterministic but needs an upstream process change.
- Sign-out lock semantics (Phase 3). Immutable snapshot + editable live state (recommended: the signed record is frozen, the working copy can keep moving and shows as "amended pending") vs hard-lock the case (no edits until explicitly reopened). Affects clinical workflow.
- Snapshot ↔ ClickHouse coupling. The snapshot must embed the variant/annotation values it
needs (it already will, via the evidence snapshots), so a later ClickHouse rebuild/
annotation_versionchange can't alter a signed report. Do not re-query ClickHouse when rendering a signed report. - PDF stability. v1 = browser print of the frozen render (good enough, reproducible from the snapshot). A byte-stable server-side PDF/archive is a later hardening.
- Backfill. Existing classifications have no evidence snapshot and existing families no manifest; treat as "unknown / pre-traceability" rather than blocking, and capture going forward.
- PHI. Snapshots and audit rows carry interpretation data; they inherit existing project
scoping and the audit masking rules (
request_logging.py). The clinical auditbefore/aftermust not leak identifiers beyond what the variant review already exposes.
- Schema: new
backend/db/schema/postgres/0NN_*.sqlmigrations (append-only triggers reuse the029pattern). Loader is dollar-quote-aware (core/postgres.py). - Services:
services/annotation_manifest_service.py(new),small_variant_review_pg.py/structural_variant_review_pg.py(evidence snapshot + audit emit),services/clinical_audit_pg.py(new),services/report_snapshot_service.py(new),family_package_import.py(manifest capture). - Routers:
routers/families.py(manifest, drift, sign-out endpoints),routers/admin.py(reference source versions, clinical audit read, expose date filters). - Frontend:
FamilyReportPage.tsx(footer + render-from-snapshot), a Provenance panel, a drift badge in the variant review, a sign-out action, an audit timeline, a "stale classifications" view. - Tests: backend pytest per service + the integration/smoke suite; frontend vitest per UI;
reuse the append-only-trigger test pattern from
029.