Patient Portal
Results when the provider has reviewed them. Not before.
The most common patient complaint about portals is not a technical failure — it's finding their own cancer diagnosis in an automated result release before the doctor called them. This module prevents that. Every time. Two PRIORITY 100 rules enforced at write, not advisory.
The two release blocks
Critical results NEVER auto-release
RULE critical_result_never_auto_release {
WHEN ResultsContext.critical_result_auto_release_attempted == true
THEN FLAG "critical_result_auto_release_blocked_provider_must_release_manually"
SEVERITY critical
PRIORITY 100
}
A critical result — troponin surge, sodium of 128, positive tumor marker — cannot reach the portal without the provider explicitly attesting they have communicated with the patient. release_lab_result refuses the write with the rule name when provider_ack is missing. The provider clicks *release*, gets a checkbox that reads *"I have communicated this result with the patient directly,"* and only then does the portal light up.
Abnormal results REQUIRE review
RULE abnormal_result_requires_review {
WHEN ResultsContext.abnormal_result_unreview_release_attempted == true
THEN FLAG "abnormal_result_release_blocked_pending_provider_review"
SEVERITY critical
PRIORITY 100
}
An A1c of 9.2 has to sit in the review queue until a clinician has actually looked at it. The two-step split — review then release — is the whole point. A provider can review a result (add an interpretation note for the patient) and *then decide separately* whether the patient should get it via portal or via a phone call first.
Normal results release cleanly once reviewed.
The classifier
classifyResultFlag(value, refs) in src/patient-portal.ts is regex-simple:
value ≤ critical_low → critical
value ≥ critical_high → critical
value < ref_low → abnormal
value > ref_high → abnormal
otherwise → normal
No clinical judgment. The lab supplies the reference ranges (they're on every LOINC result); we compute the flag deterministically. Callers who want to override — qualitative results like *positive*/*negative* cultures, textual reads — supply the flag explicitly.
Not encoded here: which tests are critical, what the cutoffs are, or when a "normal" result actually needs a callback anyway (all clinical judgment). That's the SaMD line. The classifier is a mechanical filter; the *provider* decides what to do with the classification.
The two views
Result queue — every result across the tenant, filterable by status (pending_review / reviewed / released) and flag. Inline *Review* and *Release* actions per row. The release button on a critical result opens a modal with a required checkbox — *I have communicated this result with the patient directly.* Without it, the request is refused.
Portal preview — pick a patient, see exactly what they see in their portal timeline. Only released results appear. Unreleased results are invisible from this view *by construction*, not by filtering — the query is WHERE released_at IS NOT NULL. If it's not in this query result, the patient can't see it.
What's wired
| Entity | Purpose | |---|---| | LabResult | The whole lifecycle — test, value, refs, flag, review + release state |
Tools (5): list_lab_results, post_lab_result, review_lab_result, release_lab_result, portal_timeline.
Every write goes through the audit log — every review timestamp, every release, every provider attestation. If a critical result somehow reaches a patient before the provider communicated it, the git log tells you exactly who released it and when. Which is also what 13_GIT_CANONICAL.md promises: audit by construction, not by add-on.
What's NOT wired (and won't be)
The full patient-portal README covers messaging SLAs, NIST IAL2 identity verification, MFA lockout, controlled-substance refill blocks, proxy access, and plain-language health summaries. Not shipped here.
Two reasons: the result-release loop is the *load-bearing* story (the one that keeps a patient from discovering their diagnosis via an automated email); the rest is portal chrome. If a hospital pitch asks for messaging or identity, those are follow-on slices on top of this same audit-log foundation.
The other reason: some of that surface (MFA, IAL2) is genuinely useful but also genuinely enterprise-integration work — the demo doesn't gain from including it. Ship the block that matters most. The rest can be quoted as "on the roadmap."