Skip to content

feat(qc): manual & RDT QC persistence with validation holds (OGC-1147) - #4031

Merged
samuelmale merged 6 commits into
feat/qa-nextfrom
feat/qa-manual-rdt-qc-persistence
Aug 14, 2026
Merged

feat(qc): manual & RDT QC persistence with validation holds (OGC-1147)#4031
samuelmale merged 6 commits into
feat/qa-nextfrom
feat/qa-manual-rdt-qc-persistence

Conversation

@samuelmale

Copy link
Copy Markdown
Collaborator

Summary

Persists bench-recorded QC — manual quantitative controls and RDT control lines — in the same qc_result store the analyzer path uses, and closes the loop into result validation: a failing control visibly holds the patient results it covers, and a lab can opt in to blocking their release outright.

Source-typed QC store (D1/D2)

  • qc_result gains source (ASTM/MANUAL/RDT), qualitative_outcome, captured target snapshot (expected_value/uncertainty), test_section_id, and control_label. Every pre-existing row backfills to ASTM by column default.
  • Shape is structural, not conventional: DB CHECK chk_qc_result_source_shape guarantees an RDT row carries an outcome and no number, and quantitative rows always carry a value (FR-A3 — no magic numbers).
  • POST /rest/qc/results records bench controls with the analyzer path's z-score arithmetic; a failing control raises a MANUAL_FAIL violation (D4) and auto-opens an NCE.
  • Bench QC dashboard tab groups runs by lab unit/test with a source filter; bench lots (no analyzer) are supported on MANUFACTURER_FIXED targets (D3 — no 20-run establishment for manual methods).

Validation hold (FR-C1/C3/C4)

  • Held rows on /AccessionValidation carry an explanatory red "QC failed" tag; the reason renders in a DefinitionTooltip popover (Carbon's Tag silently discards title props).
  • New qcFailBlocksValidation site flag (default off = warn-only). When on, release of covered results is withheld fail-closed, re-resolved from the DB on save — a client posting qcHold=false changes nothing. Enforced on the REST save and both legacy validation endpoints, so there is no sidestep.
  • A blocked save is loud: the response lists the withheld accessions and the UI shows a persistent warning across the post-save reload. Notes still persist; rejection stays available.

QC lot integrity

  • Lot-level Levey-Jennings chart is reachable from QC Lot Management (Options → View chart) against the existing lot-scoped chart endpoints.
  • Duplicate live lots are refused twice (inversion-test convention): validator returns a 400 naming the conflict, and partial unique index uq_qc_control_lot_active on (lot_number, test_id, COALESCE(control_level,'')) among non-retired lots makes raw SQL fail too. A migration first retires existing duplicates, keeping the row that owns the results.
  • Statistics calculators use the sample SD (÷N−1, CLSI C24) instead of population SD, with a guard for windows < 2.

Deviations & follow-ups

  • GAP-4 of the UAT register (bench-QC empty state witnessed on clean data) is deferred to post-merge — exercising it destroys the UAT evidence.
  • Stored ÷N statistics are not rewritten: ROLLING lots self-correct on their next run; INITIAL_RUNS lots already ACTIVE keep their established SD (accepted — recalculating would silently shift live control limits).
  • POST /rest/qc/results is gated by qa.view.qc for now; a distinct write authority lands with the capture UI (OGC-1025).
  • Documented scope decisions (Westgard-on-manual, capture UI, target prefill OGC-1148, missed-control detection OGC-428) are per the FRS build notes.

Test plan

  • Unit + integration: calculators (incl. sample-SD reference values and small-window guards), control-lot duplicate refusal (service 400 + raw-SQL constraint inversion test), QcHoldService fail-closed semantics, bench capture contract tests against real Postgres, Westgard suites, bench dashboard summary.
  • Frontend: mounted-component tests assert the tooltip reason actually renders and the hold tag disappears without a hold.
  • Full UAT pass 2026-08-10 against a live stack (Playwright): every P0 of the handover protocol plus re-tests of DEF-1/DEF-2/GAP-3/GAP-5/GAP-6 — all pass. Migrations qc-021..qc-028 applied cleanly to a populated database, deduping the real duplicate lots in place.

🤖 Generated with Claude Code

samuelmale and others added 5 commits August 13, 2026 14:31
Source-typed QC store and bench capture:
- qc_result gains source (ASTM/MANUAL/RDT), qualitative_outcome, captured
  target snapshot, lab unit, and control_label; shape enforced by DB CHECK
  (qualitative rows carry an outcome and no value, quantitative the reverse)
- POST /rest/qc/results records bench controls with analyzer-path z-score
  arithmetic; a failing control raises a MANUAL_FAIL violation and an NCE
- Bench QC dashboard tab grouped by lab unit/test; bench lots (no analyzer)
  supported on MANUFACTURER_FIXED targets

Validation hold (FR-C1/C3/C4):
- Held rows show an explanatory "QC failed" tag (DefinitionTooltip popover
  carries the reason - Carbon's Tag discards title props)
- qcFailBlocksValidation site flag: when on, release of covered results is
  withheld fail-closed, re-resolved server-side; enforced on the REST save
  and both legacy validation endpoints
- A blocked save reports the withheld accessions on the response; the UI
  surfaces them as a persistent warning across the post-save reload

QC lot integrity:
- Lot-level Levey-Jennings chart reachable from QC Lot Management
- Duplicate live lots refused twice: validator 400 naming the conflict, and
  partial unique index on (lot_number, test_id, control_level) among
  non-retired lots; existing duplicates retired keeping the row with results
- Calculators use sample SD (N-1, CLSI C24) with a small-window guard;
  established statistics are not rewritten

Settles UAT register DEF-1, DEF-2, GAP-3, GAP-5, GAP-6; GAP-4 (empty-state
witness on clean data) deferred to post-merge per the register.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local pre-commit spotless run was silently aborted by a zsh nomatch on the
cache-clear glob, so CI's cold check caught four unformatted files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and stale sequences

New test classes in this branch reshuffled surefire order, landing three
pre-existing pollution classes on the QA suites in CI:

1. Datasets naming type_of_test_result truncate it and re-insert only their
   own rows, so later result_limits inserts lose their FK target (id 4).
2. Datasets renaming requester_type ids 1-2 break later
   getRequesterTypeByName('organization') lookups.
3. Fixture rows carry explicit ids without advancing <table>_seq, so later
   sequence-backed inserts collide (observation_history id 3).

Fix all three at the loader, following the ensureAuditSystemUser precedent:
restore the two reference vocabularies after every dataset load (by id /
by name), and resync every conventionally-named sequence of a dataset-named
table to MAX(id)+1 — correct because those tables were just truncated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng; forward-only sequence resync

Second CI round surfaced one real OGC-1147 regression and one flaw in the
previous commit's test-infra fix:

- findDistinctTestInstrumentPairs now excludes bench lots (instrument_id
  NULL). Both consumers are per-instrument, and a NULL instrument poisoned
  getUnconfiguredMappings' read-only transaction (analyzer lookup threw,
  caught, transaction rollback-only) — the Westgard config screen died as
  soon as one bench lot existed. Regression test added.
- The dataset-load sequence resync is now forward-only (GREATEST against the
  sequence's high-water mark). Pulling high sequences down to a truncated
  table's MAX+1 created the opposite collision with raw-JDBC literal-id
  inserts outside dataset loads (dictionary id 8, history id 100009 in CI).
- RejectionReportServiceTest heatmap assertions made leftover-tolerant: the
  heatmap is a window-wide query, so global allMatch/positional assertions
  are order-fragile; assert the fixture's own null-location delta instead.

Full local suite: 4937 tests, 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ixture loads

Third pollution surface, same class: fixtures gut observation_history_type
down to ids 1-5, but ObservationHistoryService caches the name->id mapping
for SampleRecordStatus/PatientRecordStatus (15/16) at first use, so the
analyzer accept path FK-fails on its next status write (CI: reviewerChoice_
removesTheHold). Restore the pair by exact id — the cache makes any other id
wrong — and document the two exhaustive-count assertions that now see it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry (#4058)

The QC section of the expanded results row showed a footnote saying manual
control capture was unavailable: qc_result could only be written by the
analyzer import. OGC-1147 added the write path (POST /rest/qc/results); this
adds the UI that uses it, closing the last of OGC-1025's acceptance criteria.

The form is polymorphic by test type (FR-D3). A quantitative row records a
measured value, a tech-entered expected value and uncertainty, Pass/Fail, and
a bench control lot chosen from the lots the API will actually accept. An RDT
row records the control-line outcome (Valid/Invalid) against a free-text kit
lot, with no lot record and no numeric value — the shape the DB CHECK
constraint enforces. Selecting a failing outcome warns that reporting will be
blocked before the tech submits, and the confirmation after a failing capture
says the same, because the server has by then opened a non-conformity and put
a hold on the covered results.

Expected value and uncertainty are typed by the tech (FR-D4). Prefilling them
from a configured QC target needs OGC-1148, which is unbuilt; the hook is
marked in a comment rather than guessed at.

Capture is deliberately not gated on the row being editable. A control run is
a QC record, not an edit of the patient result, and the hold it raises covers
results already sitting at Technical Acceptance — gating on editability would
mean a tech who saves the result first could never record the control for that
row. Write authority is enforced server-side instead.

That server-side gate moves from qa.view.qc to the RESULTS role: recording a
control is a results-entry write, so it takes the same authority as the rest
of results entry, and a QC-view-only user is now refused. The controller's
error bodies become JSON objects so the form can surface the backend's
human-readable message verbatim, with a fallback for an exception carrying no
message (Map.of would otherwise turn an intended 400 into a 500).

Success is keyed on the saved row's id rather than on the response status: the
fetch helper reports a transport failure as status 0, so a status-only check
would have told the tech a control was recorded when it never reached the
server.

Verified end to end on the dev stack: a failing manual control stored its
z-score against the lot, raised MANUAL_FAIL, auto-opened an NCE and tagged the
Validation row; an Invalid RDT control did the same without a numeric value;
a passing control produced neither. Both appear under Bench QC, and only the
quantitative runs plot on the lot's chart.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@samuelmale
samuelmale merged commit 5a6a468 into feat/qa-next Aug 14, 2026
9 checks passed
@samuelmale
samuelmale deleted the feat/qa-manual-rdt-qc-persistence branch August 14, 2026 11:22
samuelmale added a commit that referenced this pull request Aug 17, 2026
#4031)

* feat(qc): manual & RDT QC persistence with validation holds (OGC-1147)

Source-typed QC store and bench capture:
- qc_result gains source (ASTM/MANUAL/RDT), qualitative_outcome, captured
  target snapshot, lab unit, and control_label; shape enforced by DB CHECK
  (qualitative rows carry an outcome and no value, quantitative the reverse)
- POST /rest/qc/results records bench controls with analyzer-path z-score
  arithmetic; a failing control raises a MANUAL_FAIL violation and an NCE
- Bench QC dashboard tab grouped by lab unit/test; bench lots (no analyzer)
  supported on MANUFACTURER_FIXED targets

Validation hold (FR-C1/C3/C4):
- Held rows show an explanatory "QC failed" tag (DefinitionTooltip popover
  carries the reason - Carbon's Tag discards title props)
- qcFailBlocksValidation site flag: when on, release of covered results is
  withheld fail-closed, re-resolved server-side; enforced on the REST save
  and both legacy validation endpoints
- A blocked save reports the withheld accessions on the response; the UI
  surfaces them as a persistent warning across the post-save reload

QC lot integrity:
- Lot-level Levey-Jennings chart reachable from QC Lot Management
- Duplicate live lots refused twice: validator 400 naming the conflict, and
  partial unique index on (lot_number, test_id, control_level) among
  non-retired lots; existing duplicates retired keeping the row with results
- Calculators use sample SD (N-1, CLSI C24) with a small-window guard;
  established statistics are not rewritten

Settles UAT register DEF-1, DEF-2, GAP-3, GAP-5, GAP-6; GAP-4 (empty-state
witness on clean data) deferred to post-merge per the register.


* style: apply spotless formatting (OGC-1147)

Local pre-commit spotless run was silently aborted by a zsh nomatch on the
cache-clear glob, so CI's cold check caught four unformatted files.


* test: make dataset loads self-repairing against reference-seed wipes and stale sequences

New test classes in this branch reshuffled surefire order, landing three
pre-existing pollution classes on the QA suites in CI:

1. Datasets naming type_of_test_result truncate it and re-insert only their
   own rows, so later result_limits inserts lose their FK target (id 4).
2. Datasets renaming requester_type ids 1-2 break later
   getRequesterTypeByName('organization') lookups.
3. Fixture rows carry explicit ids without advancing <table>_seq, so later
   sequence-backed inserts collide (observation_history id 3).

Fix all three at the loader, following the ensureAuditSystemUser precedent:
restore the two reference vocabularies after every dataset load (by id /
by name), and resync every conventionally-named sequence of a dataset-named
table to MAX(id)+1 — correct because those tables were just truncated.


* fix(qc): bench lots must not surface in per-instrument Westgard pairing; forward-only sequence resync

Second CI round surfaced one real OGC-1147 regression and one flaw in the
previous commit's test-infra fix:

- findDistinctTestInstrumentPairs now excludes bench lots (instrument_id
  NULL). Both consumers are per-instrument, and a NULL instrument poisoned
  getUnconfiguredMappings' read-only transaction (analyzer lookup threw,
  caught, transaction rollback-only) — the Westgard config screen died as
  soon as one bench lot existed. Regression test added.
- The dataset-load sequence resync is now forward-only (GREATEST against the
  sequence's high-water mark). Pulling high sequences down to a truncated
  table's MAX+1 created the opposite collision with raw-JDBC literal-id
  inserts outside dataset loads (dictionary id 8, history id 100009 in CI).
- RejectionReportServiceTest heatmap assertions made leftover-tolerant: the
  heatmap is a window-wide query, so global allMatch/positional assertions
  are order-fragile; assert the fixture's own null-location delta instead.

Full local suite: 4937 tests, 0 errors.


* test: restore the record-status observation_history_type pair after fixture loads

Third pollution surface, same class: fixtures gut observation_history_type
down to ids 1-5, but ObservationHistoryService caches the name->id mapping
for SampleRecordStatus/PatientRecordStatus (15/16) at first use, so the
analyzer accept path FK-fails on its next status write (CI: reviewerChoice_
removesTheHold). Restore the pair by exact id — the cache makes any other id
wrong — and document the two exhaustive-count assertions that now see it.


* feat(OGC-1025): capture manual and RDT control results at results entry (#4058)

The QC section of the expanded results row showed a footnote saying manual
control capture was unavailable: qc_result could only be written by the
analyzer import. OGC-1147 added the write path (POST /rest/qc/results); this
adds the UI that uses it, closing the last of OGC-1025's acceptance criteria.

The form is polymorphic by test type (FR-D3). A quantitative row records a
measured value, a tech-entered expected value and uncertainty, Pass/Fail, and
a bench control lot chosen from the lots the API will actually accept. An RDT
row records the control-line outcome (Valid/Invalid) against a free-text kit
lot, with no lot record and no numeric value — the shape the DB CHECK
constraint enforces. Selecting a failing outcome warns that reporting will be
blocked before the tech submits, and the confirmation after a failing capture
says the same, because the server has by then opened a non-conformity and put
a hold on the covered results.

Expected value and uncertainty are typed by the tech (FR-D4). Prefilling them
from a configured QC target needs OGC-1148, which is unbuilt; the hook is
marked in a comment rather than guessed at.

Capture is deliberately not gated on the row being editable. A control run is
a QC record, not an edit of the patient result, and the hold it raises covers
results already sitting at Technical Acceptance — gating on editability would
mean a tech who saves the result first could never record the control for that
row. Write authority is enforced server-side instead.

That server-side gate moves from qa.view.qc to the RESULTS role: recording a
control is a results-entry write, so it takes the same authority as the rest
of results entry, and a QC-view-only user is now refused. The controller's
error bodies become JSON objects so the form can surface the backend's
human-readable message verbatim, with a fallback for an exception carrying no
message (Map.of would otherwise turn an intended 400 into a 500).

Success is keyed on the saved row's id rather than on the response status: the
fetch helper reports a transport failure as status 0, so a status-only check
would have told the tech a control was recorded when it never reached the
server.

Verified end to end on the dev stack: a failing manual control stored its
z-score against the lot, raised MANUAL_FAIL, auto-opened an NCE and tagged the
Validation row; an Invalid RDT control did the same without a numeric value;
a passing control produced neither. Both appear under Bench QC, and only the
quantitative runs plot on the lot's chart.


---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant