Auto-Verification Rule Configuration
Auto-verification is the decision to release a result to the patient chart without a technologist touching it, and that decision is only as trustworthy as the rule set behind it. This page is about the rule set itself — how the laboratory authors, reviews, versions, and promotes the declarative rules that the engine later executes — rather than the engine that runs them. The distinction matters because configuration is where clinical judgment enters the pipeline: a range bound, a delta threshold, a reflex trigger, and an interference exclusion are all human decisions, encoded as data, that must be reproducible on inspection years after a result was signed out. Treat the rule set as a versioned, dual-approved artifact with an immutable release history, and auto-verification stops being a black box and becomes an auditable, testable contract.
Context and Pipeline Position
Within the Clinical Result Validation & Rule Engine Architecture, configuration is the control plane that feeds the data plane. The engine evaluates each result against a directed acyclic graph of checks — reference range, delta, critical value, reflex, and interference — but it does not decide what those checks are; it loads a compiled, versioned rule set that this configuration path produces. Upstream, results arrive already canonicalized by the Instrument Data Ingestion & HL7/CSV Pipelines tier, with units harmonized to UCUM and analyte identity resolved to LOINC through the laboratory’s Test Code Taxonomy & Standards. Those canonical keys are exactly what a rule row binds to, which is why configuration and ingestion must agree on identity before any rule can be written.
The individual checks that configuration composes each have their own dedicated implementation. The bounds a range rule references are owned by Reference Range Check Implementation; the trend logic a delta rule invokes lives in Delta Validation & Trend Analysis; a critical breach escalates through Critical Value Alert Routing; and the numeric limits themselves are validated and retuned by Threshold Tuning & Calibration. Configuration is the layer that decides which of those checks apply to which analyte, in what order they compose, and how their verdicts combine into one release decision. It also governs the conditional cascades handled by Reflex Testing Routing Logic, since a reflex order is itself a configured rule action rather than engine-internal behavior.
The controlling idea is that no rule ever reaches the engine except as a member of a named, immutable version that survived review and validation. A rule cannot be edited in place; a change produces a new version with a new identifier, and the old version remains resolvable so any historical result can be replayed against the rule set that was live when it was signed out.
Stage Boundaries
Configuration exposes three sharply separated states, and the transition between each is the point where governance attaches. Blurring them — letting a draft edit take effect before approval, or letting the engine read an unvalidated file — is how a laboratory loses the ability to explain why a result auto-verified.
Draft — the mutable working set. An author edits rules as ordinary source: a YAML file per analyte family, or a decision table. Drafts are freely mutable and carry no authority; the engine never reads them. A draft references analytes by their canonical (loinc, specimen, method_version) key and expresses each check declaratively — a condition and an action — without embedding evaluation logic.
Validated version — the promotion boundary. A draft becomes a candidate version only after it passes validation: every rule parses against the schema, every analyte key resolves, every numeric bound is well-formed, and the composed rule set has no ambiguous precedence. Validation is deterministic and produces either a compiled, content-addressed version or a list of defects; it never partially promotes. This boundary is also where dual approval is recorded — two distinct authorized identities must sign the candidate before a version identifier is minted.
Released ruleset — the hot-reload boundary. A minted version is written to an immutable, effective-dated store. The engine watches that store and hot-reloads on a new effective version, swapping the active rule set atomically so no result is ever evaluated against a half-loaded configuration. A released version is never mutated; a correction is a new version whose effective_from supersedes the prior one, and the superseded version stays queryable for replay.
Failure semantics. A validation failure blocks promotion entirely — the candidate is rejected and the previously released version stays active, so a bad edit degrades toward the last known-good rule set rather than toward an empty or partial one. An infrastructure failure loading a released version is retryable and must never cause the engine to fall back to auto-releasing without rules; if the active rule set cannot be loaded, evaluation holds results for review rather than releasing them unchecked.
Rule Configuration Schema
Every rule is one row in a declarative model. The row binds a canonical analyte key to a condition and an action, carries the metadata that makes it reviewable and replayable, and never contains executable code. The table below is the field contract for a single auto-verification rule.
| Field | Type | Semantics |
|---|---|---|
rule_id |
string | Stable identifier for the rule across versions; how a change is traced. |
analyte |
string | Canonical (loinc, specimen, method_version) binding the rule applies to. |
check |
enum | range, delta, critical, reflex, interference — which subsystem evaluates it. |
condition |
string | Declarative predicate (e.g. value within reference or delta_pct <= 20); data, not code. |
action |
enum | verify, hold, reject, or reflex_order — the rule’s contribution to the decision. |
priority |
int | Tie-break weight when two rules of equal restrictiveness both fire. |
version |
string | The released rule-set version this row belongs to; immutable once cut. |
effective_from / effective_to |
date | Window used at replay time to select the rule live at collection. |
author / approver |
string | The two distinct identities required for dual approval; recorded, not editable. |
Modeling the rule as a table rather than a script is deliberate. A row is diffable in review, serializable to the audit sink, and impossible to make Turing-complete by accident — an author cannot smuggle a side effect into a condition string because the schema only admits a fixed grammar of comparisons. The concrete authoring formats for these rows are covered in depth in Writing Auto-Verification Rules as Versioned YAML, and the tabular condition-to-action mapping is built out in Building an Auto-Verification Decision Table in Python.
Deterministic Composition and Precedence
The engine evaluates every applicable rule for a result, producing a set of per-check verdicts. Configuration decides how that set collapses into one release decision, and it must do so deterministically — the same result against the same version must always produce the same disposition. The composition rule is most-restrictive-wins: order the possible actions by restrictiveness (reject > hold > reflex_order > verify), and the composed decision is the most restrictive action any firing rule contributed. A single hold from a delta check overrides five verify verdicts from range checks; a reject from an interference exclusion overrides everything.
Most-restrictive-wins is what makes the composition safe by construction: adding a rule can only ever make the pipeline more cautious, never less, so a partial or in-progress edit cannot accidentally widen what auto-releases. Priority is the tie-break for the rare case where two rules produce the same restrictiveness but must be distinguished for auditing — it never lets a lower-restrictiveness action win over a higher one. If two rules fire with equal restrictiveness and equal priority and disagree on anything material, that is an ambiguous-precedence defect: validation must reject the version rather than let query order decide.
Because composition is monotonic in restrictiveness, the engine can evaluate the checks in any order — even concurrently — and still reach the same decision, which is what lets a burst of results fan out across the async batch processing workers without any risk that scheduling order changes a verdict.
Implementation Patterns
Model the rule row and the composed decision with Pydantic v2 so that a malformed rule fails at load rather than at release, and so the decision is serializable to the audit sink. The condition grammar is constrained to a small enum-plus-operands form; the action is an ordered enum whose integer value is its restrictiveness.
from __future__ import annotations
from datetime import date
from enum import Enum, IntEnum
from pydantic import BaseModel, ConfigDict, Field, model_validator
class Check(str, Enum):
RANGE = "range"
DELTA = "delta"
CRITICAL = "critical"
REFLEX = "reflex"
INTERFERENCE = "interference"
class Action(IntEnum):
"""Integer value is the restrictiveness rank; higher wins composition."""
VERIFY = 0
REFLEX_ORDER = 1
HOLD = 2
REJECT = 3
class Rule(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
rule_id: str
analyte: str # canonical (loinc, specimen, method_version) key
check: Check
condition: str # declarative predicate, constrained grammar
action: Action
priority: int = 0
version: str
effective_from: date
effective_to: date | None = None
author: str
approver: str
@model_validator(mode="after")
def _distinct_signers(self) -> "Rule":
if self.author == self.approver:
raise ValueError(f"{self.rule_id}: author and approver must differ")
if self.effective_to is not None and self.effective_to <= self.effective_from:
raise ValueError(f"{self.rule_id}: effective_to must follow effective_from")
return self
The composed decision is a pure reduction over the firing rules. Because Action is an IntEnum ordered by restrictiveness, composition is a max — the single most-restrictive action, with priority only distinguishing ties:
class Decision(BaseModel):
model_config = ConfigDict(frozen=True)
action: Action
deciding_rule_id: str
version: str
reason: str
def compose(rules: list[Rule], fired: list[str]) -> Decision:
"""Reduce firing rules to one decision, most-restrictive-wins."""
active = [r for r in rules if r.rule_id in fired]
if not active:
# No rule fired: absence of a positive verify never auto-releases.
return Decision(action=Action.HOLD, deciding_rule_id="",
version="", reason="no_rule_fired")
winner = max(active, key=lambda r: (r.action, r.priority))
ties = [r for r in active
if r.action == winner.action and r.priority == winner.priority]
if len(ties) > 1 and len({r.check for r in ties}) > 1:
# Same rank and priority but different checks disagree on ownership.
return Decision(action=Action.HOLD, deciding_rule_id=winner.rule_id,
version=winner.version, reason="ambiguous_precedence")
return Decision(action=winner.action, deciding_rule_id=winner.rule_id,
version=winner.version, reason=winner.check.value)
The safety default is encoded twice: an empty firing set composes to HOLD, and an unresolved precedence tie also composes to HOLD. Neither degrades to VERIFY. This mirrors the invariant enforced at the reference range check — the absence of a positive, resolvable verdict never resolves to auto-release.
Error Classification and Handling
Configuration errors are tiered so each has one correct disposition, and — critically — so a defect in the control plane never silently degrades the data plane into releasing unchecked.
| Tier | Example | Disposition | Blocks promotion |
|---|---|---|---|
| Schema | Unknown check, malformed condition, unparseable date |
Reject candidate at validation, list defects | Yes |
| Semantic | Analyte key does not resolve, bound inverted, self-approval | Reject candidate, route to author | Yes |
| Precedence | Two equal-rank, equal-priority rules disagree | Reject candidate as ambiguous | Yes |
| Runtime | Released version fails to load into engine | Hold results, retry with backoff | N/A (never releases) |
The first three tiers are caught before a version is minted, which is the point: a rule set either validates completely and becomes promotable, or it is rejected in full and the previously released version stays active. There is no partial promotion, so an author cannot ship half a change. The runtime tier is the one that touches live results, and its rule is strict — if the engine cannot load the active version, it holds rather than releases. An invalid config and an unloadable config both fail closed, toward human review, never open toward auto-release.
Semantic failures carry stable reason codes (analyte_unresolved, bound_inverted, self_approval, ambiguous_precedence) so a review dashboard can distinguish an authoring mistake from an identity-resolution gap upstream and route each to the right owner.
Regulatory Touchpoints
Auto-verification is a named, regulated activity, and the configuration path is where the regulatory obligations actually land.
- CLIA §493.1291(a) and the auto-verification provisions of §493.1256 require that the criteria used to verify results without human review be defined, documented, and validated before use — which is exactly what the released, dual-approved version represents. The version identifier persisted with every released result is what lets an inspector see which documented criteria applied.
- CAP GEN.41350 and the automated-verification checklist items require that auto-verification rules be documented, version-controlled, validated before implementation, and re-verified periodically, and that any result failing a rule route to human review. The immutable version history, the shadow-mode validation described below, and the
HOLD-by-default composition satisfy these together, building on the CLIA/CAP Data Boundaries established at the platform layer. - 21 CFR Part 11 §11.10(d),(e),(k) demands controlled, attributable, tamper-evident change management for the electronic records that govern a decision. Dual approval with two distinct recorded identities, an append-only version history, and effective-dating together provide the attributable audit trail; the HIPAA-compliant audit trail patterns apply to the configuration store as much as to results.
- CLIA §493.1105 retention applies to the rule set itself: because a result may be reviewed years later, the version that was effective at collection must remain resolvable for the full retention window, which is why released versions are immutable and superseded rather than deleted.
Because these clauses attach to the configuration change, the approval event, the validation outcome, and the minted version identifier must all be written to the audit sink before the version can go effective — not reconstructed after the fact.
Testing and Validation
A rule set is only trustworthy if its behavior is proven before it can release a patient result. Three layers do that work.
Config contract tests load a candidate version and assert structural invariants without touching live results: every analyte key resolves against the current taxonomy, every referenced bound exists, no two rules share equal rank and priority with conflicting actions, and every rule carries two distinct signers. Run these in CI on every draft so a defect is caught at authoring time, before review even begins.
import pytest
from ruleset import Rule, Action, compose
def test_hold_dominates_verify() -> None:
common = dict(analyte="2951-2|serum|v4", version="rs-2026-06",
effective_from=__import__("datetime").date(2026, 6, 1),
author="a.tech", approver="b.director")
rules = [
Rule(rule_id="na-range", check="range", condition="within",
action=Action.VERIFY, **common),
Rule(rule_id="na-delta", check="delta", condition="delta_pct<=20",
action=Action.HOLD, priority=5, **common),
]
decision = compose(rules, fired=["na-range", "na-delta"])
assert decision.action is Action.HOLD
assert decision.deciding_rule_id == "na-delta"
def test_empty_firing_set_holds() -> None:
decision = compose([], fired=[])
assert decision.action is Action.HOLD
assert decision.reason == "no_rule_fired"
Shadow-mode validation runs a candidate version against a stream of real recent results in parallel with the active version, recording what the candidate would have decided without acting on it. Comparing the shadow decisions against the released ones surfaces exactly how many results the change would newly hold, release, or reflex — the signal a laboratory director needs to sign the dual approval with evidence rather than intuition. A candidate that would flip a meaningful fraction of results from HOLD to VERIFY is precisely the change that must not ship without scrutiny.
Round-trip tests dump every Rule and Decision through model_dump_json() / model_validate_json() to guarantee the version written to the immutable store and the decision written to the audit sink deserialize back identically — the property an inspector relies on when replaying a years-old result.
Related
- Writing Auto-Verification Rules as Versioned YAML — the concrete YAML authoring format and pydantic loader for the rule rows specified here.
- Building an Auto-Verification Decision Table in Python — the tabular condition-to-outcome model that composes into the release decision.
- Reflex Testing Routing Logic — the sibling area that configures conditional reflex orders as
reflex_orderrule actions. - Threshold Tuning & Calibration — where the numeric bounds a rule references are validated and retuned in shadow mode.
- Reference Range Check Implementation — the range subsystem a
rangerule invokes when it composes into the decision.
Part of: Clinical Result Validation & Rule Engine Architecture.
Frequently Asked Questions
Why separate rule configuration from the engine that runs the rules?
Configuration is where clinical judgment enters the pipeline and where regulators attach change-control obligations. Keeping it a separate, versioned, dual-approved artifact means the engine only ever executes a named immutable version, and any historical result can be replayed against the exact rule set that was live when it was signed out.
What does most-restrictive-wins mean for combining rule verdicts?
When several checks fire for one result, the composed decision is the most restrictive action any of them contributed, ordered reject > hold > reflex_order > verify. Because adding a rule can only make the pipeline more cautious, a partial or in-progress edit can never accidentally widen what auto-releases.
What happens when no rule fires for a result?
The decision composes to HOLD, not VERIFY. The absence of a positive, resolvable verify verdict never resolves to auto-release — an uncovered analyte is a configuration gap to close, not a value to wave through, so it degrades toward human review.
How is dual approval enforced in the rule model?
Each rule carries an author and an approver, and a model validator rejects any rule where the two identities match. A version can only be minted after two distinct authorized identities sign the candidate, and both are written to the append-only audit sink before the version goes effective.