Resources & Tech · Laboratory Solutions

Two state machines, and a process that never drops a message.

A laboratory information system has to be right about things a clinic chart never has to think about: that a number is on the correct tube, that a released result cannot be quietly edited, that an analyzer's message survives a deploy, and that nothing releases itself past a failed control run. EKLIS enforces each of those as a server-side rule with a stored reason — isolation as a database per laboratory, two guarded state machines, an interface engine that logs before it parses, and auto-verification as versioned data rather than per-test code.

Overview

The engineering record behind tenancy, specimen and result state, analyzer connectivity, auto-verification and the revenue cycle.

Technology stack by layer
LayerWhat runs thereWhy
Front endNext.js App Router in TypeScript — one route per laboratory surface, plain fetch against a same-origin APIThere is no client cache library and no state manager: each bench screen loads its own worklist and re-loads it after an action, which is the honest shape for a surface whose data changes under you while you work.
APIPython with FastAPI — one router per domain, mounted under a single API prefixEvery endpoint takes a Pydantic model with explicit length caps, so input validation is a property of the signature rather than a habit. Roles are declared as a dependency on the route, which makes deny-by-default the default.
Data accessSQLAlchemy Core with parameterized statements, deliberately no ORM mapping layerThe hard parts here are state transitions and gates, not object graphs. Explicit SQL under an explicit transaction makes a row lock, an idempotent upsert and a conditional update legible instead of hidden behind lazy loading.
DatabaseMySQL 8 — one database per tenant laboratory, plus a small core databaseTenant separation is physical rather than a column in a shared table, and the application's own grant is scoped to the tenant-database prefix, so isolation survives an application bug instead of depending on the absence of one.
Schema managementA minimal ordered, idempotent migration runner applied when a tenant engine is first openedNew and existing laboratories converge on the same schema without a manual step. Every change so far is additive; a proper migration framework replaces the runner at the first non-additive change rather than pre-emptively.
Cache and limitsRedis — sign-in, signup and portal rate limiters as counters with an expiryLogin lockout and abuse limits have to be shared across API workers to mean anything, and a counter with a window is the smallest thing that does that correctly without inventing a session store.
Interface engineA second process from the same image, running one threaded TCP listener per enabled interfaceAnalyzers do not know that a deploy is happening. Keeping the listeners out of the application process means instrument traffic is still accepted and logged while the API restarts, which is the whole reason it is separate.
Documentsfpdf2 for report, label and manifest PDFs; ZPL emitted directly as text for Zebra printersLaboratory clients live on printed and faxed paper, so documents are a first-class output rather than a rendering afterthought — and label printers speak ZPL, not HTML.
Packaging and edgeDocker Compose — database, cache, API, engine, web and an nginx gatewayThe gateway puts the web app and the API on one origin, which is what lets the session cookie be first-party and httpOnly instead of a token in browser storage.
CI and deploymentLocal scripts that run every job against a clean scratch clone of committed stateLint, tests, a secret scan over full history, dependency audits on both ecosystems, and both image builds — run against a fresh checkout rather than the working tree, so a green run means the commit is green, not the desk.
Architecture

How the engine actually works

Each section describes a mechanism that exists in the codebase today, not a pattern we admire.

Isolation at the data layer

Tenancy is a registry lookup, not a filter

A tenant is a row in a small core database and a MySQL database of its own. Nothing in the application can open a connection to a laboratory's data except by asking the registry for it by slug, and the registry answers only for a laboratory that is registered and active. The slug itself is checked against a strict pattern before it is ever interpolated into a database name, so the one place a dynamic identifier is unavoidable is also the one place with a whitelist.

Specimens and results

Two state machines carry the patient-safety weight

A specimen moves ordered → collected → accessioned → complete, with rejected as a terminal branch. The transition function selects the row for update, checks the requested move against an explicit map of allowed transitions, and answers with a conflict when the move is not in it. That means a stale screen, a double-scan or a replayed request cannot talk the system into an illegal sequence, because the check happens under the lock that also performs the write.

Analyzer and HL7 connectivity

The engine logs before it parses

The interface engine reads the interface registry from the core database and polls it, starting a listener when an interface appears and shutting one down when it disappears — so bringing an analyzer online does not require a restart. Each listener binds a port from a published range, and each interface belongs to the laboratory that created it, which is what lets one engine serve many tenants without any tenant seeing another's traffic.

Gates, rule sets and quality control

Auto-verification is versioned data

Release without a human is the feature most likely to hurt someone, so the gates are ordered, named, and stored as rows rather than written into per-test code. A result filed by an instrument is considered only if auto-verification is enabled for that test; then only if it is not critically flagged; then only if quality control for that test was in control within the last day; then only if the value falls inside the configured range; and finally only if the delta against the patient's own prior final result is within the absolute and percentage limits.

Replay, counters and generated billing

Idempotency where the network is not trustworthy

Three places in the system can be asked to do the same thing twice, and each answers differently on purpose. A field action — a phlebotomist's collection or a courier pickup — carries a client-generated action id; if that id has already been recorded, the endpoint returns the original outcome marked as replayed instead of applying the action again. That is what makes an offline collection app safe to build: it can queue actions and flush them without reconciling.

Necessity, charges, claims and denials

The revenue cycle reads the same rows as the bench

Laboratory billing usually fails because it starts after the fact. Here the payer decisions move to the order: medical-necessity rules are ICD-10 prefixes attached to a test, and an order whose diagnoses match no rule for a test that has them is marked as requiring an ABN at creation time. Diagnosis codes are validated against a strict pattern and an unknown payer is refused, so the claim is being made clean while the specimen is still a plan rather than a tube.

Data model

The records underneath, and the rules that hold them true

Core entities and their invariants
EntityWhat it holdsRule that always holds
tenants · interfaces (core database)The registry of laboratories and of the analyzer and HL7 connections the engine is meant to be listening on.A database engine is resolved only for a registered, active tenant; an interface port is unique across the platform and belongs to the laboratory that created it.
testsThe compendium row: code, department, specimen type, container, units, reference intervals, critical limits, and the send-out, direct-order and billing flags.Reference intervals are per sex, with the female columns overriding the base columns when set; the same row drives result flagging, the printed range and the charge amount.
patientsMinimal demographics with an auto-assigned record number, plus the hashed patient-portal access code when one has been issued.The record number is issued from the counters table and is unique per laboratory; the portal code is stored only as a hash and is rotatable at any time.
orders · order_tests · order_diagnosesWhat was ordered for whom, at what priority, under which payer, with the diagnoses that justify it and the ABN state.Diagnosis codes are validated against a strict pattern before insert, and the ABN-required flag is set at creation from the necessity rules rather than edited later.
specimensThe physical tube as a row: its own identifier, its type and container, its state, and the accession number, receiver and timestamps once it is taken in.State moves only along the allowed map under a row lock, rejected is terminal, and an accession number is assigned exactly once with the receiving user recorded.
resultsOne analyte value with its units, flag, state, version, correction reason, auto-verification metadata and hold reason.A final row is never updated in place — a correction inserts the next version and points the superseded row at it, so both remain readable forever.
reportsThe generated report document itself, stored with its version and whether it is a final or corrected issue.Versions are monotonic per order and the newest is what downloads serve; every generation and every download writes an audit row.
av_rules · countersThe per-test auto-verification range and delta limits, and the counters that issue sequential numbers including the rule-set version.One rule row per test; every edit increments the rule-set counter, and the version at the time of release is copied onto the result it released.
qc_lots · qc_runsControl lots with their mean, standard deviation and level, and every run with its z-score, rule violation and in-control verdict.The verdict is computed server-side from the recent run history, and the most recent run within the day is what the auto-verification gate reads.
critical_notificationsThe closed-loop record for a critically flagged result: who was notified, whether read-back was confirmed, and by whom it was closed.A loop closes only with a named person and an explicit read-back confirmation, and a loop that is already closed cannot be closed again.
interface_messages · interface_code_mapThe durable log of every inbound message with its direction, status, detail and raw text, and the per-interface translation from analyzer code to compendium test.The message row exists before parsing begins and is updated with the outcome; a code map entry is unique per interface and analyzer code.
custody_eventsThe append-only custody trail for a specimen — collection, courier pickup and receipt — with the acting user, a note and a timestamp.The client action id is unique, so replaying a queued field action returns the original outcome instead of writing a second event.
reference_labs · sendoutsThe reference laboratories a test can be routed to, and the row tracking each sent test until its result comes back.A send-out requires an accessioned specimen with an unsent send-out test, and the row closes itself when the corresponding result goes final.
payers · necessity_rules · charges · claims · claim_payments · dtc_paymentsThe billing side: who pays, which diagnoses justify which test, what was charged, the claim and its balance, the payments posted against it, and direct-order payments.All money is integer cents; a claim is unique per order; the balance never goes negative and the paid status derives from the balance reaching zero.
audit_logOne append-only row for every access to or mutation of patient data, attributed to the acting user or interface.The table has no payload column by design, so the audit trail can record that something was read without ever accumulating a second copy of it.
Interfaces

What it exchanges, and in which direction

Optional integrations degrade gracefully: with no key configured the product still runs, it just does less.

Bench analyzers over ASTM

Inbound

E1381 framing with checksum validation and the ENQ/ACK/EOT session handshake, and E1394 records read for the specimen identifier and each analyte. A frame that fails its checksum is answered with a negative acknowledgement and files nothing.

HL7 v2.5.1 result messages over MLLP

Bidirectional

Messages are unwrapped from the MLLP envelope, the specimen identifier is taken from the order segment and code and value from the observation segments, and every message is answered — accepted when filed, error when processing failed, rejected when the type is unsupported.

Zebra label printers

Outbound

Specimen labels are emitted as ZPL directly, carrying patient name, date of birth, record number, container, a STAT marker and the specimen barcode, with a PDF label sheet as the fallback for a laboratory without a ZPL printer.

Reference laboratories

Bidirectional

Tests flagged as send-outs generate a worklist of accessioned specimens awaiting routing; sending records the destination laboratory and sender, a courier manifest PDF accompanies the shipment, and the row closes when the result returns and goes final.

Clearinghouse submission

Outbound

Claim export assembles the clean-claim payload — payer, subscriber, diagnoses and coded service lines — and hands it to a single billing-provider seam, which is the one place a submission adapter is wired in. The structured payload and the export time are persisted on the claim.

Payer eligibility

Bidirectional

Eligibility runs behind a provider seam with exactly one call site, and the answer is written to the order rather than recomputed per screen. Configuring a provider endpoint is what routes the check at a real payer instead of the deterministic local one used for development.

Card payments for direct orders

Bidirectional

Direct patient orders charge through a payment-provider seam that returns a provider name and reference, both stored on the payment row. Until a provider key is configured the recorded approval is explicitly marked as simulated, so a development order can never be mistaken for a real charge.

Reference code catalogs

Inbound

LOINC test codes, procedure codes and ICD-10-CM diagnosis codes are licensed or regulated reference data, loaded by a catalog loader; lookups fall back to the built-in starter compendium seeded when a laboratory is provisioned.

Security

How access is decided and recorded

Tenant isolation is physical
One database per laboratory, engines resolved only through the registry of active tenants, and the application's database grant scoped to the tenant-database prefix — so a missing filter in application code cannot reach another laboratory's rows. Cross-tenant reads answer 404.
Deny-by-default authorization
Roles are declared on the route as a dependency and read from the validated token's claims, never from request state. A phlebotomist cannot enter or verify results, a billing user has no clinical powers, and an ordering provider sees only their own orders — cross-access is 404, not 403.
Token kinds are separated
A patient-portal token is a distinct kind with a short lifetime that staff routes reject outright, and staff tokens are rejected by patient routes. The patient endpoints expose only that patient's current final results and latest report; preliminary and held results are never reachable.
Nothing enumerates
Sign-in answers a uniform 401 for a wrong password, an unknown user and an unknown laboratory alike, and the patient portal does the same for an unknown record number. Both are rate-limited in Redis, and the public signup and direct-order endpoints are limited per address.
Audit without a second copy of the data
Every access to or mutation of patient data writes an append-only row attributed to the acting user or interface. The table deliberately has no payload column, so the trail records that something happened without becoming another place patient data accumulates.
Interface traffic is untrusted input
Parsers validate framing and checksums before anything is interpreted, and filing is constrained to the interface's own tenant, a specimen in the accessioned state and an order test that is still open. An unknown specimen or code is rejected with the reason written onto the stored message.
SQL is parameterized, identifiers are whitelisted
Every statement uses bind parameters, and the single unavoidable dynamic identifier — the tenant database name — is derived from a slug checked against a strict pattern. All request bodies are Pydantic models with explicit length caps.
Secrets are required outside development
The application refuses to start without a real signing secret and database password unless it is explicitly in development mode, so a deployment cannot come up on an ephemeral generated key. The operator console stays disabled unless its key is configured, and compares it in constant time.
Result integrity is enforced, not documented
Final results are immutable, corrections are additive versions carrying a reason, out-of-order transitions are refused under a row lock, and critical values open a loop that closes only with a named person and a read-back — the CLIA-shaped behaviors are code paths with tests, not policy text.

Infrastructure is designed for HIPAA obligations under a BAA. We document the controls and hand over the runbooks; we do not claim a certification that does not exist for software.

Reliability

What keeps it correct under load

Row-locked transitions
Specimen and result transitions select the row for update, validate the requested move against an explicit map, and write inside the same transaction — so a double-submit or a stale screen produces a conflict rather than a corrupted sequence.
Idempotent field replay
Collection and courier pickup carry a client-generated action id with a unique constraint behind it. A repeated id returns the original outcome marked as replayed, which is what makes an offline collection client safe to flush without reconciliation.
Atomic counters
Record numbers, specimen identifiers, accession numbers and the auto-verification rule-set version all come from an insert-or-increment against a counters table, so concurrent requests can never be issued the same number.
Durable-before-parse logging
Inbound interface messages are written to the tenant's message log with a received status before any parsing happens, and the outcome is written back onto the same row — so a parse failure loses the meaning of a message, never the message.
Supervised listeners
The engine reconciles running listeners against the interface registry on a loop, starting and stopping them as it changes, logging a port it cannot bind and carrying on rather than exiting — one bad interface does not take the others down.
Generate-once billing
Charges and a draft claim are produced when an order completes, guarded by a unique claim per order and an early return when one already exists or the order has no payer, so re-completion and retries cannot double-bill.
Derived claim status
A claim reads paid only because its balance reached zero, and a payment larger than the balance is refused. Status is a consequence of the ledger rather than a field someone sets, which is what keeps analytics and the balance from disagreeing.
Inspect-then-apply migrations
The migration runner checks the live schema before applying each ordered step, so it can run on every engine open. Provisioning a laboratory and upgrading one are the same code path with different amounts of work to do.
One gate, many surfaces
Report generation, the provider portal and the patient portal all select current final results by the same predicate — superseded rows and preliminary rows are excluded at the query, so no surface can disagree with another about what has been released.
Development record

The documents this was built from

Analysis, design discussion, implementation notes and QA written while the work happened. These are engineering artifacts in the product repository, not published pages — listed here so you can see what exists and ask for any of it.

Development-time documentation for Laboratory Solutions
DocumentKindDateWhat it covers
CLAUDE.md — project rules and document mapCLAUDE.mdReference2026-09-03The global working rules and the index of which document governs which area, so that stack, security, design, CI and plan decisions each have exactly one home rather than being restated.
ARCHITECTURE.md — stack, principles and standardsARCHITECTURE.mdDesign2026-09-03The decided stack, the modular-monolith principle with the interface engine as its deliberate exception, tenant isolation, the rules and lab-profile layers, the standards list, and the non-functional targets the design is aimed at.
PLAN.md — business and development planPLAN.mdPlan2026-09-03Positioning, target segments and entry order, the four requirement groups, the module map, the phased roadmap, the reuse map from the sibling projects, and the open decisions with their dispositions.
SECURITY.md — security registerSECURITY.mdReference2026-09-04The security requirements every change is written against, the threat verification table with one row per identified threat and the code and test that closed it, and the accepted-risk log with a written rationale per entry.
CICD.md — pipeline and deployment rulesCICD.mdRunbook2026-09-03The job list run against a clean scratch clone, the rule that no job is skipped and no exit code hidden, the deploy gate on green CI, the rollback path, backup and retention duties, and the reference-catalog loading rule.
DESIGN.md — UI and UX guidelinesDESIGN.mdDesign2026-09-03Bench-speed keyboard and scanner-first operation, pinning patient and specimen identity on any clinical screen, blocking mismatch warnings, the status-color contract paired with text glyphs, worklist-centric navigation, and print and PDF as first-class outputs.
CODINGRULES.md — coding behavior guidelinesCODINGRULES.mdGuide2026-09-03Think-before-coding, simplicity first, surgical changes that trace to the request, and goal-driven execution with verifiable success criteria — plus the language conventions the product's user-facing strings follow.
GRAPHENGINEERING.md — when a graph is warrantedGRAPHENGINEERING.mdReference2026-09-03The opt-in rule for graph modeling: it is reached for in genuinely complex relationship cases rather than as a default, which keeps the relational model the primary one for the ordinary lab workflow.
Competitive and domain researchdocs/research/labgen-medfar-lis-research.mdAnalysis2026-09-03The incumbent LIS landscape and its acquisition history, what a laboratory information system is expected to do, where the cloud transition stands, and the evidence base the positioning and reuse map are drawn from.
Functional-area surveydocs/research/lis-functional-areas-research.mdAnalysis2026-09-03The functional areas a laboratory information system has to cover, used to check the module map for omissions before the phased roadmap was fixed.
Visual planning documentdocs/plan/lis-plan.htmlPlan2026-09-03The plan rendered as flows, diagrams and tables — the version used to talk through workflow, connectivity and module coverage rather than to read linearly.
Client report sitedocs/plan/html/Report2026-09-03A nine-page report and confirmation site covering market, modules, workflow, order, result, report, schedule, staff, connectivity, compliance, platform, statistics and roadmap, plus a single-page pre-engagement overview.
Glossary

Terms used on this page

LIS
Laboratory information system — the software that runs orders, specimens, results, quality control, reporting and billing for a laboratory, as distinct from a clinic's EMR.
Compendium
The laboratory's test catalog: code, department, specimen type, container, units, reference intervals and critical limits, plus the send-out, direct-order and billing flags.
LOINC
The standard vocabulary identifying a laboratory test and its result; the starter compendium seeded at provisioning is LOINC-coded.
Accession
The intake record for a received specimen — a sequential number assigned only after the two-identifier confirmation, with the receiving user and time stored on the specimen.
Specimen UID
The identifier issued when the order derives its specimens; it is what gets printed as a barcode, scanned at accessioning, and quoted by an analyzer message.
ASTM E1381 / E1394
The low-level framing and the record format bench analyzers use to talk to a LIS; E1381 supplies the checksummed frames, E1394 the header, patient, order and result records.
MLLP
Minimal lower layer protocol — the byte wrapper that delimits one HL7 message on a TCP socket so a reader knows where a message begins and ends.
ORU
The HL7 v2 message type that carries observation results; here it is the inbound message the engine parses and files.
ACK
The HL7 acknowledgement returned for a message — accepted, rejected or error — so the sender learns the outcome rather than assuming it.
Auto-verification
Releasing an instrument result without a human, permitted only when every ordered gate passes; the rule-set version that released it is stored on the result.
Delta check
A comparison against the same patient's own prior final result for that test; a change beyond the configured absolute or percentage limit holds the result for review.
Westgard multirules
The control-run rules — 1-3s, 2-2s, R-4s, 4-1s and 10x — evaluated server-side to decide whether a quality-control run is in control.
Levey-Jennings
The chart of a control lot's runs in standard deviations from its mean, drawn next to the run entry so drift is visible where the decision is made.
Hold reason
The column recording which auto-verification gate closed on a result — critical, quality control, range or delta — and therefore why it is sitting on the verification worklist.
Critical value
A result far enough outside the interval to need an immediate call; the notification closes only with the name of the person notified and an explicit read-back confirmation.
Corrected report
A reissued report produced because a result was corrected; the new version supersedes the prior one, which is retained and still linked rather than overwritten.
Send-out
A test routed to a reference laboratory rather than run in house, accompanied by a courier manifest; the row closes when the result returns and goes final.
Chain of custody
The append-only trail of who held a specimen and when — collection, courier pickup, receipt — each event carrying the acting user, a note and a timestamp.
Idempotent replay
Applying the same client action twice safely: the client generates an action id, and a repeat returns the original outcome instead of writing a second event.
Schema-per-tenant
Giving each laboratory its own database rather than a tenant column in shared tables, so isolation is enforced by the data layer and the database grant.
ABN
Advance beneficiary notice — the form a patient signs when a test may not be covered; the order is flagged at creation and claim export is blocked until it is signed.
Clean claim
A claim carrying everything the payer needs the first time — payer, subscriber, diagnoses and coded service lines — assembled here from the completed order rather than re-keyed.
Questions

Asked by the people who evaluate this

Why a database per laboratory rather than a tenant column?

Because a tenant column makes every query one forgotten filter away from a breach. A database per laboratory moves the boundary below the application: engines resolve only through the registry of registered, active tenants, and the application's own grant is scoped to the tenant-database prefix, so an application bug cannot reach another laboratory's rows. The cost is a migration runner that has to converge many databases, which is why migrations are ordered, idempotent and applied when an engine is opened.

Why is the interface engine a separate process?

Because an analyzer does not know a deploy is happening. If the listeners lived in the application process, every restart would refuse connections and somebody would be re-running batches afterwards. As its own process it keeps accepting and logging while the API restarts, and it reconciles its listeners against the interface registry on a loop, so adding an instrument does not need a restart either.

What stops a bad analyzer message from causing damage?

Three things in sequence. The message is written to the durable log before parsing, so it is never lost. ASTM frames are checksum-validated and a failure is answered with a negative acknowledgement rather than filed. And filing itself is constrained: the result must match a specimen belonging to that interface's own laboratory, in the accessioned state, with that test still open on its order — anything else is rejected with the reason recorded on the stored message.

How is auto-verification kept auditable?

The rules are rows, not code. Each test has an auto-verify range and absolute and percentage delta limits, and every edit increments a laboratory-wide rule-set counter. An auto-released result stores the rule-set version that released it and a flag marking it auto-verified; a held result stores which gate closed. So both questions an inspector asks — what released this, and why did that one stop — are answered from the row rather than reconstructed.

Can a released result be edited?

No. Verification moves a result from preliminary to final, and a final row is never updated in place. A correction inserts a new row with the next version and a required reason, then points the superseded row at it. Report generation reads only current final rows, prints a CORRECTED banner when any of them is a later version, and names that version beside the test — the retention statement on the report is a description of the schema.

What makes a retry safe?

Different guarantees in different places. Field actions carry a client-generated action id with a unique constraint behind it, so a replayed queue returns the original outcome. Sequential numbers come from an insert-or-increment counter, so concurrency cannot issue a duplicate. Billing generation is unique per order with an early return. And state transitions are validated under a row lock, so a repeat produces a conflict rather than a second move.

How does billing avoid becoming a second system?

It reads the same rows. Medical necessity is checked when the order is created, from diagnosis-prefix rules attached to the test, and the ABN requirement is stamped on the order there. Completing the last test generates charges and a draft claim once, in integer cents, from the fee schedule already in the compendium. Export assembles the payload from those rows and refuses the two things that reliably come back as denials: an unsigned required ABN, and a charge with no procedure code.