Structured SIG Builder + NDC Autopopulate — Technical Spec v1¶
Owner: Gene Lang, PharmD — VP Pharmacy Operations Date: 2026-04-20 Status: 🟡 DRAFT v1 — sent to Jack Mullins (design) + Faraz (engineering) for implementation scoping Review audience: Faraz (eng lead), Emil (infra), Jack (design), Jamie (design lead), Peyman (COO awareness) Mark version: Mark I (iPad pilot app) — applies to enrollment flow; schema is forward-compatible to Mark II Related (all self-contained in this bundle): - Bundle index: README.md - Mockup: wireframes/Mockup.html - Slide deck: Slides.pptx · in-browser preview Slide_Preview.html - Cover note for transmission: Cover_Note.md - NCPDP SCS field spec (internal extract of NCPDP SCRIPT v2021011 §8.1): References/NCPDP_SCRIPT_v2021011_SCS_Reference.md - Surescripts integration strategy (Phase 2 → TEFCA pathway context): References/Surescripts_Integration_Strategy.md
Design-driver data (provenance — not included in bundle, access requires Drive):
- 188,953 anchor-site fills: 08_Data_DeIdentified/BW_deidentified_final_20260408.csv
- Gene's source walkthrough voice memo transcript: _Transcripts/VP_Memos/2026-04-19_ipad_app_notes/categorized.md
Hard Rule #4: Medication data flow = patient → pharmacist/tech → device. Rx360 rep has zero visibility. This spec respects that: NDC scan and SIG construction both happen in the pharmacist-authenticated iPad flow; verification is a required, NPI-signed step before data leaves the app.
1. Problem statement¶
Today's pilot app flow: pharmacist scans the printed Rx summary (the RX30 printout) → OCR extracts medication rows → pharmacist reviews + approves in Step 4. Medications land in the cloud with directions stored as free-text sig ("Take 1 tablet by mouth twice daily with food"). That string is not machine-parseable without ML, which means:
- Downstream clinical features are blocked — Beers criteria screening, drug-drug interaction alerts, refill-gap prediction, auto-generated wearable reminder times all require structured dose/frequency/route fields, not prose.
- Phase 2 Surescripts integration requires rewrite. Incoming NCPDP SCRIPT messages are already structured (Structured and Codified Sig, v2021011 §8.1). If our schema is free-text, we'd build an ingest parser, deprecate it, and migrate. Building structured now makes Phase 2 a mapping layer.
- The CVS / enterprise pitch demo needs structured data. The demo environment called for in Surescripts_Integration_Strategy.md §5 fails with free-text.
- OCR data alone is not enough. The Rx printout gives us drug name + sig + qty + NDC (sometimes) — but no canonical RxCUI, no verified NDC digits, no DEA schedule, no manufacturer, no pill imprint. We need a database enrichment layer on top of OCR.
- Pharmacist data entry is slow. Free-text sig typing or correction is ~20-30 seconds per med. A guided builder with smart defaults should cut this to ~5 seconds for the common case.
Gene committed to the structured SIG builder + database enrichment in the 2026-04-19 walkthrough memo. This spec defines the implementation.
Primary data path:
Rx summary scan → OCR extracts {drug name, sig, NDC?, qty, refills, fill date}
↓
Drug identity enrichment (§5): OpenFDA + RxNorm + DailyMed cross-check / fill gaps
↓
Structured SIG builder (§6): pharmacist replaces free-text sig with structured fields
↓
Pharmacist verification gate (existing Step 4): NPI-signed, timestamped
↓
Cloud + wearable
NDC-barcode scanning is a secondary fallback path (for compound meds, samples, or cases where the Rx printout wasn't scanned), not the primary trigger.
2. Scope¶
In scope (v1 — this spec)¶
- Structured SIG builder as the primary medication-entry UI on Step 4 — Review & Approve of the existing Import Medication List flow
- NDC-scan autopopulate via OpenFDA NDC Directory API + RxNorm API
- Unified medication object model aligned to NCPDP SCRIPT v2021011 SCS (field names, code systems) with FHIR
MedicationRequest.dosageInstructionmapping - Smart defaults chained to dose form (tablet → oral → "Take")
- Multi-phase sig support ("Add another phase" for tapers / sequential sigs — 50% of real fills)
- Pharmacist verification gate (required; NPI + timestamp auto-populated from session)
- Graceful NDC-not-resolved fallback path (manual drug search via RxNorm autocomplete)
- Live SigText preview generated from structured fields
- Preservation of existing Step 4 features: confidence badges, time-critical flag, status flags, per-card edit/save/cancel
Out of scope (future versions)¶
- EPCS (Electronic Prescribing of Controlled Substances) — signed Rx messages
- DEA Schedule II signing workflow
- Licensed drug database integration (FDB / Medi-Span) — deferred to Q4 when CVS/enterprise pitch demands clinical decision support
- Beers criteria / DDI / allergy alerts — separate spec
- Direct Surescripts certification workflow — separate spec (v2023011 migration)
- Bulk actions on Step 4 (verify-all-high-confidence, flag-all-unverified) — iterative polish
- Field-level OCR confidence (dot per strength/sig/qty) — iterative polish
- "Restore OCR original" undo — iterative polish
- Backward step-indicator navigation — design decision pending
3. Architecture¶
3.1 Data flow¶
┌────────────────────┐ ┌──────────────────────────┐ ┌─────────────────┐
│ Pharmacist scans │ │ iPad app (this spec) │ │ Cloud backend │
│ RX30 printout │───▶│ │───▶│ (Faraz/Emil) │
│ (Rx summary) │ │ Step 1: Scan document │ │ │
│ │ │ Step 2: OCR │ │ Stores SCS XML │
│ Secondary path: │ │ Step 3: Preview │ │ + FHIR MedReq │
│ NDC barcode scan │ │ Step 4: Review + Approve│ │ │
│ (compound / gap │ │ │ └─────────────────┘
│ cases) │ │ On each med row: │
└────────────────────┘ │ 1. Parse OCR fields │
│ 2. Resolve NDC (or │
│ search by name) │
│ 3. Enrich via OpenFDA │
│ + RxNorm │
│ 4. Present in SIG │
│ builder │
│ 5. Pharmacist edits │
│ + verifies │
└──────────┬───────────────┘
│
▼
┌───────────────┐
│ Wearable │
│ reminders │
│ auto-gen │
│ from │
│ structured │
│ timing │
└───────────────┘
3.1a Backend stack — TBD at sprint planning¶
This spec is stack-agnostic on the backend side. Implementation details that depend on stack choice (XSD validation library, ORM, caching client) are flagged as open questions in §11. Faraz to confirm the Rx360 backend language + framework at Monday product sync so FAR-006 (XSD library) and FAR-007 (cache infra) can close.
3.2 External dependencies¶
| Dependency | Purpose | Endpoint / library | Cost | Phase |
|---|---|---|---|---|
| OpenFDA NDC Directory | Drug identity from NDC (brand, generic, strength, form, route, DEA, manufacturer) | https://api.fda.gov/drug/ndc.json?search=product_ndc:"NNNNN-NNN-NN" |
Free · public · 1000/day anonymous, 120K/day with API key | v1 · pilot |
| RxNorm API (NIH NLM) | RxCUI canonical ID + generic normalization | https://rxnav.nlm.nih.gov/REST/rxcui.json?idtype=NDC&id=NNNNNNNNNNN |
Free · public · no rate limit documented | v1 · pilot |
| DailyMed SPL | Package inserts, pill imprints, pill images | https://dailymed.nlm.nih.gov/dailymed/services/v2/spls.json |
Free | v2 · Q3 |
| First Databank (FDB) | Structured Sig Codes (NCPDP-native), Beers, DDI, allergies, pregnancy categories | commercial license | $15K–100K/yr negotiated | v3 · Q4 when CVS/enterprise pitch requires CDS |
| NCPDP SCRIPT XSD | Server-side structured-sig validation | local XSD files; validate on save | Free · spec download | v1 |
3.3 Caching¶
- Per-NDC cache: Cache OpenFDA + RxNorm responses keyed on 11-digit NDC. TTL = 90 days (FDA NDC Directory refreshes monthly; 90d is a safe drift window).
- Drug-name autocomplete cache: Cache RxNorm drug-name search results keyed on prefix. TTL = 24 hours.
- Invalidation: Manual flush via admin. No auto-invalidation in v1.
- Storage: Server-side Redis or equivalent. Do NOT cache in the iPad's local storage beyond session — PHI adjacency concerns.
4. Data model¶
4.1 Medication object (stored server-side)¶
Medication:
# Patient + Rx identity (existing today)
rx_number: string # e.g., "RX-990112"
patient_id: string # internal anonymized ID
fill_date: date # ISO 8601
quantity_dispensed: number
days_supply: integer
refills_remaining: integer
pay_type: string
# Drug identity (NDC autopopulate — §5)
ndc: string # 11-digit normalized
drug_name_brand: string # "Prinivil"
drug_name_generic: string # "Lisinopril"
strength: string # "10 mg" — human-readable
strength_value: number # 10
strength_unit: string # "mg" (UCUM)
dose_form: string # "tablet" (NCPDP list)
dose_form_shape: string? # "round" — optional, from FDB when licensed
route_default: string # "oral" (SNOMED)
dea_schedule: string # "non-controlled" | "C-II" | "C-III" | "C-IV" | "C-V"
manufacturer: string
package_description: string
rxcui: string # canonical RxNorm concept ID
imprint: string? # from DailyMed when integrated (v2)
data_sources: [string] # ["OpenFDA", "RxNorm"] — audit trail
ocr_confidence: enum # "High" | "Medium" | "Low"
# SIG — structured (NCPDP SCS, §6)
sig:
sig_text: string # auto-generated, ≤1000 bytes
code_system:
snomed_version: string # e.g., "20160901"
fmt_version: string # e.g., "16.03d"
instructions: [Instruction] # 1..49 — multi-phase support
multiple_instruction_modifier: enum? # "AND" | "OR" | "THEN" (only if 2+ instructions)
# App-layer flags (preserved from current app)
time_critical: boolean
status: enum # "Active" | "Discontinued" | "Not currently taking" | "Possible duplicate" | "Needs review"
flagged_for_review: boolean
# Verification (required before import)
verified: boolean
verified_by_npi: string # auto from session
verified_by_name: string # "Gene Lang, PharmD"
verified_at: datetime
verification_edits: [EditRecord] # audit trail if re-verified after edits
4.2 Instruction object¶
Instruction:
# Required — Dose Administration (§4.1 below)
dose_delivery_method:
text: string # "Take" (human-readable)
code: string # "419652001" (SNOMED)
qualifier: "SNOMED"
dose_quantity: number # 1 (or decimal: 0.5, 1.5)
dose_quantity_range: number? # if DoseRangeModifier used; second value (e.g., "1-2" → 1 and 2)
dose_range_modifier: enum? # "TO" | "AND" | "OR"
dose_unit_of_measure:
text: string # "tablet"
code: string # "C48542" (FMT NCI for tablet)
qualifier: "DoseUnitOfMeasure"
route_of_administration:
text: string # "oral route"
code: string # "26643006" (SNOMED)
qualifier: "SNOMED"
site_of_administration: # optional
text: string? # "both eyes"
code: string? # "362508001" (SNOMED)
qualifier: "SNOMED"
# Timing (0..50 per Instruction — at least one required)
timing_and_duration: [TimingAndDuration]
# Optional
indication_for_use: # required when PRN
precursor:
text: string # "as needed for"
code: string # "420449005" (SNOMED)
value:
text: string # "pain"
code: string # "22253000" (SNOMED)
maximum_dose_restriction:
value: number # 4
unit: string # "tablet" or "mg"
period: string # "day"
clarifying_free_text: string? # "Take with food"
instruction_indicator: enum? # "MEDICALENCOUNTER" (for "as directed")
administration_indicator: enum? # manufacturer-instructions flag
4.3 TimingAndDuration object¶
TimingAndDuration:
# One of: frequency, interval, administration_timing (or combination)
frequency: # "N per period" — e.g., "2 per day"
numeric_value: integer
units:
text: string # "day" | "week" | "month"
code: string # "258703001" (SNOMED for day)
qualifier: "SNOMED"
interval: # "every N period" — e.g., "every 6 hours"
numeric_value: integer
numeric_value_range: integer? # for "every 4-6 hours"
variable_interval_modifier: enum? # "TO" | "AND" | "OR"
units:
text: string # "hour" | "day" | "week"
code: string
qualifier: "SNOMED"
duration: # therapy length — optional
numeric_value: integer
text:
text: string # "day" | "week"
code: string
qualifier: "SNOMED"
duration_trigger: # event-based stop — optional
text: string # "until gone"
code: string
qualifier: "SNOMED"
administration_timing: # time of day — optional
event:
text: string # "morning" | "bedtime" | "with meal"
code: string # e.g., "814200017" (SNOMED morning)
qualifier: "SNOMED"
modifier:
text: string? # "before" | "after" | "with"
code: string?
qualifier: "SNOMED"
4.4 FHIR MedicationRequest.dosageInstruction mapping¶
For future TEFCA / direct-FHIR integration. One-to-one mapping — no data loss either direction.
| Our field | FHIR path | UCUM / SNOMED |
|---|---|---|
dose_quantity + dose_unit_of_measure |
doseAndRate.doseQuantity.value + .code |
UCUM for FHIR |
route_of_administration.code |
route.coding[0].code |
SNOMED |
site_of_administration.code |
site.coding[0].code |
SNOMED |
timing.frequency.numeric_value |
timing.repeat.frequency |
— |
timing.frequency.units |
timing.repeat.periodUnit |
UCUM ("d", "wk") |
timing.interval.numeric_value |
timing.repeat.period + timing.repeat.periodUnit |
UCUM |
administration_timing.event |
timing.repeat.when |
FHIR EventTiming code |
duration.numeric_value + duration.text |
timing.repeat.duration + .durationUnit |
UCUM |
indication_for_use.value |
reasonCode.coding |
SNOMED |
dose_delivery_method |
method.coding |
SNOMED |
clarifying_free_text |
patientInstruction or additionalInstruction |
— |
5. Drug identity enrichment (Half A)¶
5.1 Triggers (primary and fallback)¶
Primary (OCR of Rx printout): After Step 2 Processing completes, each OCR'd medication row has:
- Drug name (raw string, e.g. "LISINOPRIL 10 MG TAB")
- NDC (often printed on the Rx summary, e.g. "00378-0810-01") — may be missing or low-confidence
- Sig text (raw string, e.g. "Take 1 tablet orally daily")
- Qty, refills, fill date, Rx number
The app runs drug identity enrichment per medication row BEFORE presenting Step 3 Preview / Step 4 Review. Enrichment cross-verifies and fills gaps.
Secondary (NDC barcode scan): For cases where the Rx printout wasn't scanned (compound medications, samples, gaps where OCR failed entirely, or pharmacist-initiated manual entry). App accepts 11-digit numeric NDC and runs the same enrichment pipeline.
5.2 Enrichment call sequence (per medication row)¶
1. Normalize any extracted NDC to 11-digit (pad with leading zeros if 10-digit)
2. Resolve drug identity:
CASE A — NDC present + valid:
Check local drug-reference DB (§5a) → if hit, return cached enriched record
Miss → GET https://api.fda.gov/drug/ndc.json?search=product_ndc:"NNNNN-NNN-NN"
Extract: brand_name, generic_name, dosage_form, route, dea_schedule,
labeler_name, marketing_status, active_ingredients, package_description
GET https://rxnav.nlm.nih.gov/REST/rxcui.json?idtype=NDC&id=NNNNNNNNNNN
Extract: rxcui
CASE B — NDC missing / low-confidence, drug name present:
Fuzzy-search local DB by drug name (indexed on generic + brand names)
If exact match → return enriched record + populate NDC from DB
If ambiguous → show pharmacist a picker with top 3 RxNorm matches
If no match → set ndc_resolved=false → card state per §8.3
CASE C — Both missing:
Card auto-expands with manual drug-name search input (RxNorm autocomplete)
3. Cross-verify against OCR fields:
- If OCR'd drug name ≠ DB drug name → flag with amber dot on the field
(pharmacist sees both and decides)
- If OCR'd strength ≠ DB strength → same
- If OCR'd dose form ≠ DB dose form → same
4. Cache enriched record locally for session + server-side (§5a caching rules)
5. Populate Step 3 Preview card with enriched drug identity + OCR'd sig-text (as draft
for pharmacist to restructure into SIG Builder)
6. On Step 4 expand: drug identity bar shows "Verified via OpenFDA + RxNorm" chip; any
cross-verify discrepancies show an amber dot next to the affected field
5.3 Autopopulated fields (mapped to UI)¶
| UI field | OCR source | DB source | Priority on conflict |
|---|---|---|---|
| Drug name (brand) | DRUG NAME column from Rx printout |
OpenFDA brand_name |
DB wins on exact-NDC match; else OCR wins + flag |
| Drug name (generic) | parsed from OCR string | OpenFDA generic_name / RxNorm name |
DB wins |
| Strength | parsed from OCR string | OpenFDA active_ingredients[0].strength |
DB wins if NDC confirmed; flag if OCR differs |
| Dose form | inferred from OCR suffix (TAB, CAP, etc.) | OpenFDA dosage_form |
DB wins |
| Route | — | OpenFDA route[0] |
DB (not in printout) |
| DEA schedule | — | OpenFDA dea_schedule |
DB (not in printout) |
| Manufacturer | — | OpenFDA labeler_name |
DB |
| Package description | — | OpenFDA packaging[0].description |
DB |
| RxCUI | — | RxNorm rxcui |
DB |
| NDC | OCR (often present) | — | OCR confirmed against DB; normalize to 11-digit |
| Sig text | OCR (DIRECTION 1..5 columns) |
— | OCR is source; pharmacist restructures via SIG Builder (§6) |
| Qty dispensed | OCR | — | OCR |
| Days supply | OCR | — | OCR |
| Refills | OCR | — | OCR |
| Fill date | OCR | — | OCR |
| Rx number | OCR | — | OCR |
5.4 Decision: FDB license deferred to v3¶
Per Surescripts_Integration_Strategy.md, FDB license is the right Q4 decision when: - CVS / enterprise pitch requires clinical decision support (Beers, DDI, allergies) as a demo capability, OR - Direct Surescripts certification pushes forward (FDB's Structured Sig Codes map directly to NCPDP SCS — shortens cert path)
Open question for Faraz (GEN→FAR-004): What does RX30 use today? If FDB, our future license extends to data already flowing; if Medi-Span, decide early because switching providers mid-product is costly.
5a. Drug reference database — production strategy¶
Drug enrichment queries in §5 hit either a live API or a local pre-populated database. The progression:
5a.1 Pilot (now → Q2) — live API calls¶
- Direct REST calls to OpenFDA (
api.fda.gov/drug/ndc.json) + RxNorm (rxnav.nlm.nih.gov/REST) per-query - Redis cache in front with per-NDC TTL 90d, per-name-prefix TTL 24h
- Cost: free (OpenFDA anonymous = 1000 calls/day; API-key = 120K/day; RxNorm no documented rate limit)
- Sufficient for pilot scale (~50–200 pharmacy-enrolled members × ~8 meds each = ~1600 total NDCs, mostly repeats after first month)
5a.2 Interim (Q3) — pre-populated local drug reference DB¶
Once pilot usage exceeds free-tier limits or we need sub-100ms query latency, migrate to a local reference DB bulk-ingested from public sources.
Bulk ingest sources (all free, monthly cadence):
| Source | Format | Download | Cadence | Contains |
|---|---|---|---|---|
| FDA NDC Directory | Zipped txt + Excel | https://www.fda.gov/drugs/drug-approvals-and-databases/national-drug-code-directory (full download link on page) | Daily | All marketed NDCs, product name, labeler, form, route, DEA schedule, marketing start/end |
| OpenFDA NDC dataset | Partitioned NDJSON | https://api.fda.gov/download.json (or S3 partitions) | Weekly | Same as NDC Directory + normalized structure |
| RxNorm release | RRF (pipe-delimited) | NLM UTS account → https://www.nlm.nih.gov/research/umls/rxnorm/docs/rxnormfiles.html | First Monday of the month | RxCUI canonical IDs, ingredient/dose-form/strength relationships, NDC-to-RxCUI mapping |
| DailyMed SPL | XML bulk ZIPs | https://dailymed.nlm.nih.gov/dailymed/spl-resources-all-drug-labels.cfm | Daily | Structured Product Labeling, pill imprints, pill images, package inserts |
Ingest pipeline (run nightly or weekly via cron):
1. Pull sources → staging S3 bucket
2. Parse into canonical schema:
drug_reference {
ndc_11: primary key
ndc_10: secondary index
rxcui: foreign key to RxNorm concept
brand_name
generic_name
strength_value + strength_unit (UCUM)
dose_form
route
dea_schedule
manufacturer
package_description
imprint
pill_image_url
marketing_start_date
marketing_end_date
discontinued: boolean
data_sources: [OpenFDA, RxNorm, DailyMed]
ingested_at: timestamp
}
3. Upsert into Postgres (primary) + ElasticSearch (fuzzy drug-name search)
4. Emit metrics: records ingested, NDCs added, NDCs discontinued, errors
Query service:
- Internal REST endpoint /drug-reference/lookup?ndc={ndc} or /drug-reference/search?q={name}&limit=10
- Sub-50ms p95 latency (local DB vs. ~300-800ms for public API)
- Always available (no FDA outage risk during a pharmacist's workflow)
5a.3 Production (Q4+) — licensed drug database alongside local reference¶
When CVS/enterprise pitch requires clinical decision support, license FDB or Medi-Span. Does NOT replace the local reference DB — augments it:
- Local DB continues to serve drug identity (free, fast, complete)
- FDB/Medi-Span serves the clinical decision support layer: Beers criteria flags, DDI matrices, allergy cross-references, pregnancy categories, FDB Structured Sig Codes for Surescripts-native Sig IQ integration
- Joined via RxCUI (both systems speak RxCUI)
Open question for Faraz (GEN→FAR-005a): Infrastructure preference for the ingest pipeline — Airflow, Prefect, cron + Python scripts? (Depends on what Rx360 backend already uses for scheduled jobs.)
5a.4 Decision: API-first pilot, DB-migration Q3¶
For v1 backend: use live OpenFDA + RxNorm API calls with Redis cache. This lets us ship to pilot on May 8 without building ingestion infrastructure.
Plan the ingestion pipeline in parallel during Q2 so we can flip the switch in Q3 before hitting OpenFDA's anonymous rate limit or needing the latency/availability benefits of local DB.
5a.5 OpenFDA + RxNorm field → UI mapping reference¶
For the v1 pilot pipeline, the specific API fields to pull (supplements §5.3's OCR-vs-DB priority rules):
| UI field | OpenFDA field | RxNorm field | Fallback if missing |
|---|---|---|---|
| Drug name (brand) | brand_name |
— | show generic as primary |
| Drug name (generic) | generic_name |
name (from RxCUI) |
OpenFDA generic_name |
| Strength | parsed from active_ingredients[0].strength |
strength |
show as "Strength TBD" |
| Dose form | dosage_form |
dose_form_name |
default "tablet" with warning |
| Route | route[0] |
— | default "oral" with warning |
| DEA schedule | dea_schedule |
— | default "non-controlled" with warning |
| Manufacturer | labeler_name |
— | "—" |
| Package description | packaging[0].description |
— | "—" |
| RxCUI | — | rxcui |
null |
6. SIG Builder (Half B)¶
6.1 Design principles (data-driven, from 188,953 anchor-site fills)¶
- Buttons beat dropdowns for high-frequency choices. "Daily" is 34% of all fills — it's a button, not option #4 in a menu.
- Smart defaults chained to dose form.
tablet→ auto-fill Action=Take, Route=oral. Pharmacist only changes if different. - Hide optional fields. PRN (1.9% of fills), indication (<1%), max dose, clarifying note, as-directed all live behind
[Advanced ▾]. - Multi-phase is primary. 50% of real fills have DIRECTION 2+. "+ Add another phase" is a standing prominent CTA.
- Verb inference. Sig starting with a numeric ("1 tab daily") → auto-prepend "Take".
- Unit dropdown grouped by category. Oral solid / by weight / liquid / topical / inhaler / injectable / other. Grams/mg/mcg included.
- Touch targets ≥44px. Apple HIG compliance.
6.2 UI sections (reference mockup wireframes/Mockup.html)¶
Each medication card on Step 4 has four SIG-relevant sections when expanded:
A. Drug identity bar (top) — one-line summary of NDC-autopopulated fields. "More details ▾" reveals full 12-field grid.
B. Action & dose section - Action row: 5 big buttons (Take · Apply · Inhale · Inject · Instill) + "More ▾" for rare verbs (Chew, Place, Use, Insert) - Dose row: numeric input + unit dropdown (grouped) - Route row: route dropdown
C. Schedule section
- Primary row: 4 popularity-tagged buttons (Daily 34% · Twice daily 9% · At bedtime 3% · As needed 2%)
- Secondary row: 3× daily · 4× daily · Every 6h · Every 8h · Every 4–6h · Every 6–8h · Weekly · Custom...
- Time of day chips (4): morning · noon · evening · bedtime
- Meal timing chips (3): before · with · after
- Duration toggle: Ongoing ⟷ For [N] [days/weeks/months]
D. Live sig preview — auto-generated sentence from structured fields. Pinned visually (purple left-border).
E. Advanced panel (collapsed by default) — reveal button below sig preview. Contains: - PRN toggle (lights Indication as required) - Indication dropdown (pain · cough · headache · nausea · fever · anxiety · insomnia · custom) - Max dose/day (numeric + unit + auto-calculated mg ceiling hint) - Range dose toggle (enables second dose_quantity input) - "As directed" mode (sets instruction_indicator=MEDICALENCOUNTER) - Clarifying note (free text)
F. Multi-phase button — "+ Add another phase" appends a new Instruction block with MultipleInstructionModifier=THEN (default; overridable to AND/OR).
G. Pharmacist verification row — green when verified (NPI auto-populated, timestamp auto); amber when unverified. "Edit" button (not "Re-verify") after verification.
6.3 Smart-default logic¶
on dose_form selected (either from NDC autopopulate or manual override):
switch dose_form:
case "tablet" | "capsule" | "chewable tablet" | "lozenge" | "troche":
action = "Take"
route = "oral"
unit = dose_form
case "mL (oral)" | "teaspoon" | "tablespoon":
action = "Take"
route = "oral"
case "drop":
action = "Instill"
route = "ophthalmic" # default; pharmacist changes to "otic" if ear
case "puff" | "actuation" | "inhalation":
action = "Inhale"
route = "inhalation"
case "spray":
action = "Use"
route = "nasal"
case "application" | "gram (cream)" | "mL (lotion)":
action = "Apply"
route = "topical"
case "patch":
action = "Apply"
route = "transdermal"
case "suppository":
action = "Insert"
route = "rectal" # default; pharmacist changes to "vaginal" if applicable
case "mL (injection)" | "unit" | "IU":
action = "Inject"
route = "subcutaneous" # default; override to IM/IV as needed
default:
action = "Take"
route = "oral"
on verb inference (sig text starts with numeric):
if first token matches /^\d+(\.\d+)?$/:
prepend inferred_action + " "
(inferred_action follows the same switch above)
6.4 Schedule button → NCPDP SCS mapping¶
| UI button | Frequency | Interval | Administration Timing | NCPDP structure |
|---|---|---|---|---|
| Daily | 1 per day | — | — | Frequency{1, day} |
| Twice daily | 2 per day | — | — | Frequency{2, day} |
| 3× daily | 3 per day | — | — | Frequency{3, day} |
| 4× daily | 4 per day | — | — | Frequency{4, day} |
| Every 6h | — | 6 hour | — | Interval{6, hour} |
| Every 8h | — | 8 hour | — | Interval{8, hour} |
| Every 4–6h | — | 4 TO 6 hour | — | Interval{4, VariableIntervalModifier=TO, 6, hour} |
| Every 6–8h | — | 6 TO 8 hour | — | Interval{6, VariableIntervalModifier=TO, 8, hour} |
| At bedtime | — | — | bedtime (SNOMED 21029003) | AdministrationTiming{bedtime} (no explicit Frequency/Interval) |
| In the morning | — | — | morning (SNOMED 814200017) | AdministrationTiming{morning} |
| As needed | — | — | — | Any Frequency/Interval + IndicationForUse{as needed for …} |
| Weekly | 1 per week | — | — | Frequency{1, week} — reveals day-of-week chip row |
| Custom... | user-composed | user-composed | user-composed | opens full field-level editor |
6.5 Live sig preview generation rules¶
Generate SigText from structured fields in this order:
"{DoseDeliveryMethod.text} {DoseQuantity}[-{DoseQuantity_range}] {DoseUnitOfMeasure.text}{plural}
{RouteOfAdministration.text}
{Frequency → "N times per period" OR Interval → "every N[-M] period"}
[at {AdministrationTiming.event}[, {modifier} {event2}]]
[{meal_timing}]
[for {Duration.numeric_value} {Duration.text}{plural}]
[as needed for {IndicationForUse.value}]
[. Max {MaximumDoseRestriction.value} {unit}/{period}]
[. {ClarifyingFreeText}]."
Plural rule: if DoseQuantity ≠ 1 OR DoseRangeModifier present, pluralize the unit (tablet → tablets). Exception: weight units (mg, mcg, g) never pluralize.
Regenerate on every field change. Display pinned in purple-bordered box below the Schedule section.
7. Validation rules¶
7.1 Save-medication validation (client-side + server-side)¶
| Rule | Client | Server | Action on fail |
|---|---|---|---|
| NDC is 11 digits and valid checksum | ✓ | ✓ | Reject; show format error inline |
Either NDC resolves OR flagged_for_review=true must be set |
— | ✓ | Reject; show NDC-not-resolved card state |
dose_delivery_method.code matches dose_delivery_method.text semantically |
✓ | ✓ | Reject; warn inline |
dose_quantity > 0 |
✓ | ✓ | Reject; inline "Dose required" |
route_of_administration.code is in the allowed SNOMED subset |
✓ | ✓ | Reject; inline "Route required" |
At least one TimingAndDuration element present |
✓ | ✓ | Reject; inline "Schedule required" |
If PRN toggle active, indication_for_use.value populated |
✓ | ✓ | Reject; red "required with PRN" under Indication |
| Generated SigText ≤ 1000 bytes | — | ✓ | Reject; force non-electronic handoff per NCPDP spec |
| Codes + text semantically match (XSD validation) | — | ✓ | Reject; log to error service |
verified=true before import |
— | ✓ | Reject card-level; show amber verification bar |
verified_by_npi matches session NPI |
— | ✓ | Reject; re-auth flow |
| Dose input accepts decimals (0.5, 1.5) | ✓ | — | inputmode="decimal" triggers iPad decimal keypad |
7.2 Unspecified-code pattern (per NCPDP §8.1)¶
When a user picks a value that doesn't map to a SNOMED/FMT code:
- For SNOMED: use code 10003008 + text "Unspecified" + populate clarifying_free_text
- For FMT DoseUnitOfMeasure: use code C38046 + text "Unspecified" + populate clarifying_free_text
Any unspecified-code save triggers a flagged_for_review=true on the medication row.
8. Error states & edge cases¶
8.1 Decimal doses (0.5, 1.5, 2.5)¶
Supported. dose_quantity is a float. iPad decimal keypad triggered via <input inputmode="decimal" />. No UI change needed beyond the attribute.
8.2 Range doses ("1–2 tablets")¶
Pharmacist toggles "Range dose" in Advanced panel → second numeric input appears between unit and dose. Structured as dose_quantity=1, dose_range_modifier=TO, dose_quantity_range=2. Sig preview: "Take 1–2 tablets..."
8.3 NDC not resolved (compound, repackaged, new drug, OTC gap)¶
Card state:
- Collapsed header shows amber ⚠ NDC not resolved · manual entry required in place of drug meta line
- Card auto-expands on page load (don't hide the recovery path)
- Body shows:
- Amber sig-section banner: "Drug search — NDC not found in OpenFDA"
- Drug-name search input → RxNorm autocomplete
- Fallback: "Force save without NDC (flag for review)" button (sets flagged_for_review=true, ndc=null)
8.4 "As directed" sig (prescriber intent ambiguous)¶
Advanced panel → "As directed" toggle. When on:
- instruction_indicator = "MEDICALENCOUNTER"
- Structured fields become optional (builder doesn't force full structure)
- Required: clarifying_free_text populated with whatever the prescriber wrote
- Sig preview shows: "Take as directed. {clarifying_free_text}"
8.5 Tapering / sequential sigs ("2 tabs day 1, then 1 tab daily for 4 days")¶
50% of real fills have DIRECTION 2+. This is common, not edge-case.
Flow:
1. Pharmacist builds first Instruction normally
2. Clicks "+ Add another phase"
3. Second Instruction block appears below first, pre-filled with the first Instruction's drug/route defaults (only dose/schedule differ in most tapers)
4. MultipleInstructionModifier defaults to "THEN" (sequential); dropdown to change to AND (all apply) or OR (either)
5. Sig preview includes both phases with the connector: "Take 2 tablets daily for 1 day. Then take 1 tablet daily for 4 days."
No limit in UI beyond NCPDP's 49-Instruction cap (practical cap: ~5 phases for real pharmacy use).
8.6 Weekly / day-of-week meds (levothyroxine, bisphosphonates)¶
Schedule button "Weekly" reveals a day-of-week chip row (7 chips: S M T W T F S).
Schema (v1):
frequency: { numeric_value: 1, units: { text: "week", code: "258705008" } }
administration_timing.day_of_week: ["Monday", "Wednesday", "Friday"] // app-layer extension
clarifying_free_text: "Take Mondays, Wednesdays, and Fridays" // redundant but human-readable
NCPDP SCS v2021011 has no native dayOfWeek field. Two handling paths:
1. v1 pilot: Store administration_timing.day_of_week as a custom app-layer extension (array of ISO weekday names) + duplicate as free-text in clarifying_free_text for anyone reading the NCPDP XML who doesn't know the extension.
2. v2 (FHIR path opens): Migrate to FHIR Timing.repeat.dayOfWeek — native support. Conversion is deterministic; no data loss.
Example sig preview: "Take 1 tablet by mouth once weekly on Monday."
Edge case: For multiple specific days (bisphosphonate "1st of the month"), use clarifying_free_text as the authoritative store; day_of_week is empty. Document this in the migration plan to FHIR.
8.6a Site of administration (ophthalmic "right eye," topical "upper arm")¶
Spec'd in §4.2 as site_of_administration (optional SNOMED-coded). UI treatment for v1: not a separate control — inferred from Route where possible. For ophthalmic/otic routes, the pharmacist can disambiguate left/right/both via clarifying_free_text ("right eye only"). This matches what OCR'd printouts actually say and keeps the primary UI clean.
v2 consideration: Promote site_of_administration to an optional Advanced-panel field for ophthalmic / otic / topical cases where laterality matters clinically. Defer until we have user data showing pharmacists want it as a structured field.
Schema v1 behavior: site_of_administration field remains in the data model but unpopulated unless a future flow writes to it. Null/empty = no structured site info; free-text clarifying note is the source of truth.
8.6b "Custom..." schedule (the fallback editor)¶
When the pharmacist clicks "Custom...", reveal a full schedule editor with all NCPDP TimingAndDuration primitives exposed:
- Frequency (numeric value + units)
- Interval (numeric value + variable-interval modifier + units)
- Administration timing event (coded dropdown: morning / noon / evening / bedtime / breakfast / lunch / dinner / sleep)
- Administration timing modifier (before / after / with / between)
- Duration (numeric + units)
- Duration trigger (until gone / until symptoms relieved / user-specified)
Used for <5% of sigs — anything not covered by the 12 primary buttons. Acceptance criterion added below (§9.7).
8.7 OCR low-confidence field¶
Card's existing ocr_confidence badge (High/Medium/Low) is retained at card-header level. Field-level OCR confidence dots (on strength, sig, qty specifically) are deferred to v1.1.
8.8 Empty DEACLASS from data source¶
The anchor-site fills data showed DEACLASS entirely empty. Pilot behavior:
- Always populate from OpenFDA (dea_schedule field) at scan time, not from the source data
- If OpenFDA doesn't return a DEA schedule, default to "Non-controlled" with a flag for review
9. Acceptance criteria (for Faraz to test against)¶
9.1 Happy path — structured tablet, daily¶
- Scan NDC
00378-0810-01→ OpenFDA returns Lisinopril 10mg tablet, oral, non-controlled, Mylan - Drug identity bar populates automatically
- Action defaults to "Take" (selected button), Route to "by mouth (oral)", Unit to "tablet"
- Pharmacist clicks "Daily" → Schedule:
Frequency{1, day} - Pharmacist clicks "morning" chip →
AdministrationTiming{morning} - Sig preview renders: "Take 1 tablet by mouth daily in the morning."
- Pharmacist clicks "Done editing" → save validates, writes to server
- Record is saved with
verified=false - Pharmacist clicks "Verify now" →
verified=true,verified_by_npifrom session, timestamp recorded - Card collapses showing green "✓ Verified" chip and sig preview text in the meta line
Time to complete: ≤ 10 seconds. Baseline target.
9.2 PRN with interval range + max dose¶
Scan ibuprofen 600 mg → select "Every 4–6h" → expand Advanced → toggle PRN → select Indication=pain → Max 4 tablets/day → Clarifying note "Take with food" → sig preview: "Take 1 tablet by mouth every 4–6 hours as needed for pain. Max 4 tablets/day. Take with food."
9.3 NDC not resolved¶
Scan invalid NDC → card auto-expands with amber banner → drug-name search → type "Tretinoin 0.025% cream" → RxNorm returns match → pharmacist selects → form continues normally.
9.4 Tapering sig (2 instructions, THEN)¶
Scan prednisone 10 mg → build first Instruction: 2 tablets daily for 1 day → click "+ Add another phase" → second Instruction pre-filled → change dose to 1 tablet, duration 4 days → sig preview: "Take 2 tablets by mouth daily for 1 day. Then take 1 tablet by mouth daily for 4 days."
9.5 Verification gate¶
Attempt to click "Import medications >" at page level when any card has verified=false → blocked with dialog: "1 medication not yet verified. Verify before importing."
9.6 XSD validation¶
Generate NCPDP SCS XML from the structured medication → validate against NCPDP XSD locally before server save → any schema violation rejects the save with a surfaced error message.
9.7 Custom schedule fallback¶
Pharmacist encounters a sig that doesn't match any of the 12 primary/secondary buttons → clicks "Custom...", reveals the full primitive editor (§8.6b) → composes frequency + interval + administration timing manually → live sig preview regenerates → save validates via XSD. Round-trip preserves the structured fields with no free-text fallback.
10. Migration plan (existing free-text sig data)¶
Current pilot app has existing medications stored with free-text sig. Migration options:
| Option | Effort | Outcome |
|---|---|---|
| A. Leave existing records free-text-only; new records get structured | Low | Bifurcated data model; downstream features work only for new records |
| B. Retro-parse existing sigs via a one-time Surescripts Sig IQ call | Medium (if license available) | All records get structured; Sig IQ cost per parse |
| C. Local parser using NLP library (e.g., med7, MedSpaCy) | Medium-High | All records structured, pharmacist-verifies each; zero API cost but NLP accuracy limited |
D. Flag existing records as needs_migration=true and force pharmacist re-entry on next edit |
Low-Medium | Manual but high-quality; matches pilot pharmacist workflow |
Recommendation: Option D for pilot. Pilot scale is small (~50-200 pharmacy-enrolled members); manual re-entry on next edit is tractable. Defer Options B/C to when scale requires automation.
Server-side: add sig_schema_version field to Medication record. Existing records = v0, new records = v1.
11. Open questions (tied to Asks_List)¶
For Faraz (eng lead)¶
- GEN→FAR-004: What drug database does RX30 query today? Answer determines our eventual license path (FDB vs. Medi-Span) for v3. Quick answer expected Monday product sync.
- GEN→FAR-005: Sprint estimate for OpenFDA + RxNorm integration. Free APIs, local caching, fallback to manual search. Target: 1 sprint for v1 backend.
- NEW-FAR-006: XSD validation library choice —
xmlschema(Python) orlibxmljs(Node)? Depends on Rx360 backend stack — confirm language/framework Faraz's team uses before answering. - NEW-FAR-007: Cache infra — Redis OK or something lighter for pilot scale?
For Jack + Jamie (design)¶
- GEN→JJ-001: Can the wireframe in
wireframes/Mockup.htmlbe turned into Figma frames by Apr 28? - GEN→JJ-002: Pharmacist verification step UX — Card 2 (ibuprofen) shows the amber unverified state. Is that bar-placement correct, or should it be a modal?
- NEW-JJ-003: Weekly-meds day-of-week chip row — should appear inline or as a secondary sub-section?
- NEW-JJ-004: "More ▾" for rare verbs (Chew, Place, Use, Insert) — dropdown or inline reveal?
- NEW-JJ-005: PRN / "As directed" / Taper — are these mode-switches that reshape the form, or in-form flags? (Deferred from mockup; design call.)
For Peyman (COO awareness)¶
- Authorize Gene as the spec owner through v2; Apr 24 target for v1 sign-off
- Authorize engineering sprint allocation for v1 backend (OpenFDA + RxNorm integration)
For future (out of scope for v1)¶
- When do we license FDB? Trigger = CVS/enterprise pitch commits OR direct Surescripts cert starts
- When do we upgrade to NCPDP SCRIPT v2023011? Mandatory Jan 2028; start migration planning Q1 2027
- EPCS workflow — separate spec when controlled-substance prescribing enters scope
12. Timeline¶
| Milestone | Owner | Date |
|---|---|---|
| v1 spec draft published | Gene | 2026-04-20 (this doc) |
| Send to Jack + Faraz + Peyman | Gene | 2026-04-20 |
| Monday product sync review | All | 2026-04-21 |
| Design wireframes in Figma | Jack | 2026-04-28 |
| Backend sprint estimate | Faraz | 2026-04-21 |
| v1 spec lock (incorporating sprint 1 feedback) | Gene | 2026-04-24 |
| Backend dev: OpenFDA + RxNorm integration + schema | Faraz | Sprint 1 — May 1 |
| UI dev: SIG builder components | Jack / eng | Sprint 1 — May 1 |
| Integration test (anchor-site dummy NDCs) | All | May 5 |
| Pilot launch (first real pharmacist use) | Gene | May 8 (target) |
13. Change log¶
| Date | Version | Author | Summary |
|---|---|---|---|
| 2026-04-20 | v1 | Gene | Initial spec published. Consolidates Gene's 4/19 voice memo + NCPDP SCRIPT v2021011 SCS spec + 188,953-fill data analysis + v3 mockup + OpenFDA/RxNorm/FDB tier strategy. Supersedes the staging one-pager. |