Building HIPAA-Grade Pharmacy Routing With a 7-Year Audit Log

A missed prescription-routing failover in a pharmacy platform is not a bug ticket. It is a compliance liability, the kind that shows up in an audit finding, not a sprint retro. We built product-level pharmacy routing, dual prescriber paths, and a seven-year immutable audit log for a compounding-pharmacy network, and verified every phase against a numbered spec before it shipped.

**Key numbers**
- 11 epics shipped behind a spec-first verification gate
- 490+ unit tests, strict TypeScript typecheck green
- 7-year immutable audit log covering every access and change
- Patient-facing screens passing WCAG 2.1 AA
- Stack: Next.js 15, React 19, TypeScript strict, Prisma/Postgres, jose + bcrypt, Vitest

## The problem with treating compliance as a defect queue

Most SaaS teams patch compliance gaps the way they patch anything else: find it, ticket it, fix it next sprint. That model works for a broken button. It does not work for a pharmacy platform.

A compounding-pharmacy network came to us running a white-label platform across clinic, patient, and platform-admin surfaces. The system routes real prescriptions to real pharmacies. If a routing failover silently drops a script, or if the audit log has a gap when a regulator asks who touched a record and when, that is not a bug to patch later. It is a liability the moment it happens. The build had to treat every phase as something that either meets a written requirement or does not ship, because "we'll harden it in v2" is not a sentence a compliance officer accepts.

That framing set the entire build process: spec-first, with a verification gate that does not trust the code that wrote the code.

## The architecture: three surfaces, one routing engine, one audit spine

The platform has three distinct user surfaces on a single design system: clinic staff managing patients and prescriptions, patients handling consent and status, and platform admins overseeing the whole network. All three sit on top of one product-level pharmacy-routing engine, which is where the actual compliance weight lives.

The routing engine owns four responsibilities that cannot fail quietly:

1. **Failover logic.** If a primary pharmacy cannot fill or process a routed prescription, the system has to detect that and reroute, not stall silently.
2. **Dual prescriber paths.** Prescriptions can originate from more than one type of prescriber relationship, and each path has its own validation and audit requirements.
3. **Consent and e-sign.** Every patient action that matters legally gets a signed, timestamped consent record, not an implied checkbox.
4. **KYC.** Identity verification runs before a prescription enters the routing pipeline, not after.

Every one of those four surfaces writes to the same place: an append-only, seven-year retention audit log that records who accessed what, what changed, and when, across all three user surfaces.


<rect x="0" y="0" width="900" height="560" fill="#0C0C14"/>
<text x="450" y="32" text-anchor="middle" font-family="monospace" font-size="16" fill="#F4F2FF">Compounding-pharmacy platform: routing and audit spine</text>

<rect x="30" y="60" width="180" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="120" y="85" text-anchor="middle" font-family="monospace" font-size="13" fill="#F4F2FF">Clinic surface</text>
<text x="120" y="103" text-anchor="middle" font-family="monospace" font-size="11" fill="#F4F2FF">staff, chart, orders</text>

<rect x="360" y="60" width="180" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="450" y="85" text-anchor="middle" font-family="monospace" font-size="13" fill="#F4F2FF">Patient surface</text>
<text x="450" y="103" text-anchor="middle" font-family="monospace" font-size="11" fill="#F4F2FF">consent, e-sign, status</text>

<rect x="690" y="60" width="180" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="780" y="85" text-anchor="middle" font-family="monospace" font-size="13" fill="#F4F2FF">Platform-admin surface</text>
<text x="780" y="103" text-anchor="middle" font-family="monospace" font-size="11" fill="#F4F2FF">network oversight</text>

<line x1="120" y1="120" x2="450" y2="200" stroke="#8B7CF6" stroke-width="2"/>
<line x1="450" y1="120" x2="450" y2="200" stroke="#8B7CF6" stroke-width="2"/>
<line x1="780" y1="120" x2="450" y2="200" stroke="#8B7CF6" stroke-width="2"/>

<rect x="270" y="200" width="360" height="90" rx="6" fill="#8B7CF6" fill-opacity="0.12" stroke="#8B7CF6" stroke-width="2"/>
<text x="450" y="228" text-anchor="middle" font-family="monospace" font-size="14" fill="#F4F2FF">Pharmacy-routing engine</text>
<text x="450" y="248" text-anchor="middle" font-family="monospace" font-size="11" fill="#F4F2FF">failover logic, dual prescriber paths,</text>
<text x="450" y="264" text-anchor="middle" font-family="monospace" font-size="11" fill="#F4F2FF">consent/e-sign, KYC checks</text>

<line x1="360" y1="290" x2="200" y2="360" stroke="#8B7CF6" stroke-width="2"/>
<line x1="450" y1="290" x2="450" y2="360" stroke="#8B7CF6" stroke-width="2"/>
<line x1="540" y1="290" x2="700" y2="360" stroke="#8B7CF6" stroke-width="2"/>

<rect x="100" y="360" width="200" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="200" y="385" text-anchor="middle" font-family="monospace" font-size="12" fill="#F4F2FF">Primary pharmacy</text>
<text x="200" y="403" text-anchor="middle" font-family="monospace" font-size="11" fill="#F4F2FF">fill or fail</text>

<rect x="350" y="360" width="200" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="450" y="385" text-anchor="middle" font-family="monospace" font-size="12" fill="#F4F2FF">Failover pharmacy</text>
<text x="450" y="403" text-anchor="middle" font-family="monospace" font-size="11" fill="#F4F2FF">rerouted on failure</text>

<rect x="600" y="360" width="200" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"/>
<text x="700" y="385" text-anchor="middle" font-family="monospace" font-size="12" fill="#F4F2FF">Prescriber path</text>
<text x="700" y="403" text-anchor="middle" font-family="monospace" font-size="11" fill="#F4F2FF">dual validation</text>

<line x1="200" y1="420" x2="450" y2="470" stroke="#8B7CF6" stroke-width="2"/>
<line x1="450" y1="420" x2="450" y2="470" stroke="#8B7CF6" stroke-width="2"/>
<line x1="700" y1="420" x2="450" y2="470" stroke="#8B7CF6" stroke-width="2"/>

<rect x="150" y="470" width="600" height="70" rx="6" fill="#8B7CF6" fill-opacity="0.18" stroke="#8B7CF6" stroke-width="2"/>
<text x="450" y="498" text-anchor="middle" font-family="monospace" font-size="14" fill="#F4F2FF">7-year immutable audit log (append-only)</text>
<text x="450" y="518" text-anchor="middle" font-family="monospace" font-size="11" fill="#F4F2FF">every access, every change, across all three surfaces</text>


Every arrow in that diagram is a write path into the audit spine, not just the routing engine. Access to a patient record from the clinic surface writes an entry. A consent sign-off from the patient surface writes an entry. A configuration change from platform-admin writes an entry. Nothing reads or mutates protected data without leaving a row behind.

## What "append-only" means in the schema, not just in the pitch

An audit log that can be edited is not an audit log, it is a log-shaped table someone can quietly clean up. The schema pattern enforces immutability at the data layer, not just in application logic, so a compromised service account still cannot rewrite history.

```typescript
// prisma/schema.prisma (representative pattern, not verbatim)

model AuditLogEntry {
id String @id @default(cuid())
actorId String
actorRole Role
action AuditAction
resourceType String
resourceId String
beforeState Json?
afterState Json?
surface Surface // CLINIC | PATIENT | PLATFORM_ADMIN
ipAddress String
createdAt DateTime @default(now())

// No updatedAt. No delete cascade. No soft-delete flag.
// Retention is enforced by policy (7 years), never by app-level mutation.

@@index([resourceType, resourceId])
@@index([actorId, createdAt])
}

enum AuditAction {
VIEW
CREATE
UPDATE
ROUTE_PRESCRIPTION
FAILOVER_TRIGGERED
CONSENT_SIGNED
KYC_VERIFIED
}
```

Notice what is missing: no `updatedAt`, no delete path, no soft-delete boolean that a future migration could quietly flip. Every write to a protected resource, including a plain read, produces a new row. The pattern also constrains the database grants the application runs under, so append and read are the only operations available to it in the first place. Retention is a policy enforced at the infrastructure and access-control layer, not a field an engineer could edit under deadline pressure.

Session verification runs on the same discipline. Every authenticated request checks a signed token before it ever reaches a route handler that touches patient data.

```typescript
// Simplified illustration of a jose-based session-verification middleware.
// Pattern only, not the client's implementation.

import { jwtVerify } from "jose";
import type { NextRequest } from "next/server";

const secret = new TextEncoder().encode(process.env.SESSION_SECRET);

export async function verifySession(req: NextRequest) {
const token = req.cookies.get("session")?.value;
if (!token) {
return { authenticated: false as const };
}

try {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
});

return {
authenticated: true as const,
userId: payload.sub as string,
role: payload.role as string,
surface: payload.surface as string,
};
} catch {
// Expired, tampered, or malformed token: treat as unauthenticated.
// Never fall through to a default-allow path.
return { authenticated: false as const };
}
}
```

The failure mode matters here as much as the success path. A malformed or expired token returns "not authenticated," full stop. There is no default-allow branch, no fallback role, nothing that treats an unverifiable session as a lower-privilege guest. In a regulated system, the safe default is always "no access," never "reduced access."

## Spec-first, adversarial verifier: what actually happened before merge

The phrase "spec-first" gets used loosely. Here it meant something specific: every phase of the build was written against a numbered requirements document, and nothing merged until a separate, automated adversarial verifier checked the shipped code against those numbered requirements and tried to find gaps.

That is a different gate than code review. A code reviewer asks "does this look right and does it follow our patterns." An adversarial verifier asks a narrower, harder question: "does this satisfy requirement 4.2, and can I construct an input or a state transition that breaks it." It is looking for the edge case the implementation missed, not the style violation.

The client's numbered requirements are theirs, so the specifics stay with them. The shape of what a verifier gate asks of a routing engine looks like this:
- Does every failover path actually trigger on the documented failure conditions, or only the ones covered by the happy-path test.
- Does the audit log capture the access event even when the underlying request errors out partway through.
- Does the dual prescriber path reject a malformed submission from either path, not just the more common one.
- Does a consent record actually block downstream routing when consent has not been signed, or does the block only apply in the UI.

Each of those is a failure mode a code reviewer skimming a pull request could plausibly miss, especially under deadline pressure. The verifier does not get tired and does not skim. It runs the same numbered checklist against every phase, every time, and a phase that fails a checklist item does not merge until it passes.

This is the same discipline we apply across any AI-assisted build: a pull request in the client's own repository, reviewed by the named engineer who owns it, typed contracts, tests in CI. The adversarial verifier is an added layer specific to a regulated build where "looks right" is not the bar. Our [security](/security) posture and process are built around that same idea: nothing ships on trust that can instead ship on a check.

## What eleven epics behind a spec gate actually produced

The build shipped in eleven separate epics, each one gated the same way: numbered spec, implementation, adversarial verification, merge. That discipline produced measurable, checkable outcomes rather than a claim in a sales deck.

| Outcome | What it means in practice |
|---|---|
| 490+ unit tests | Routing logic, failover triggers, and audit-write paths are exercised by test, not by hope |
| Strict TypeScript typecheck, green | No `any` escape hatches hiding a routing or consent bug at runtime |
| WCAG 2.1 AA, patient-facing screens | Patients using assistive technology can complete consent and status flows |
| 7-year immutable audit log, live | Every access and change across all three surfaces is recorded, not sampled |

None of these numbers describe intent. They describe what merged. A 490+ count of unit tests is a count of assertions that ran and passed in CI on this codebase, not a target the team was aiming for. Strict typecheck green means the compiler enforced type safety across the whole codebase, not just the files someone remembered to annotate.

That is the difference between a compliance posture that is promised in a deck and one that is proven in code. A deck can claim anything. A test suite either passes or it does not, and a strict TypeScript build either compiles clean or it throws.

## Where "HIPAA-compliant" claims usually go wrong

We are careful about this phrase because most vendors are not. There is no certification called "HIPAA certified." HIPAA does not issue one, and any vendor claiming it is either confused or overselling. The honest, checkable claims are: we sign a Business Associate Agreement, and we operate controls aligned with the HIPAA Security Rule and Privacy Rule, backed by evidence like an immutable audit log, access controls tied to role and surface, and a SOC 2 Type II report available under NDA.

For a compounding-pharmacy network specifically, the audit log is the artifact a regulator actually wants to see: not a policy document describing intent, but a queryable record of who accessed a given prescription, when, and what changed. Seven years of retention matches the kind of recordkeeping period a pharmacy network needs to defend against an audit years after the fact, not just at go-live.

If your team is evaluating vendors on this exact question, our longer breakdown of [what HIPAA-compliant software actually requires](/blog/hipaa-compliant-software) covers the Security Rule requirements in more depth than fits in a single build walkthrough. For dental practices weighing similar infrastructure decisions, [dental IT services](/blog/dental-it-services) covers the adjacent ground: patient data handling, imaging pipelines, and practice-management integration under the same regulatory pressure.

## Why this shipped as a pod, not a staffing search

A build like this does not tolerate a slow ramp. The failover logic, the audit schema, and the verifier gate all had to exist together from early in the project, because none of them are safely retrofitted onto a system already handling live prescriptions. That is the case for staff augmentation over a standard hiring cycle in a regulated build: a matched [pod](/pods) with a pod lead and an engineer bench starts within days, not the 3-6 months a typical senior hire takes to source, interview, and onboard.

The engineering discipline itself, spec-first development with an adversarial verification gate, is not something we reserve for pharmacy platforms. It is closer to the standard for any regulated or data-heavy build we take on, because the cost of a missed requirement in those domains is not a bug report, it is an audit finding.

## The short version

A pharmacy-routing platform cannot treat a failover gap or an audit-log hole as a bug to fix later, because the liability lands the moment it happens, not when someone notices. We built this one spec-first: eleven epics, each verified against numbered requirements by an adversarial verifier before merge, on Next.js 15, React 19, strict TypeScript, Prisma/Postgres, and Vitest. The result is checkable, not promised: 490+ unit tests, a clean strict typecheck, WCAG 2.1 AA patient screens, and a seven-year immutable audit log recording every access and change across clinic, patient, and platform-admin surfaces.