Capstone: PriorAuth
Before an insurer pays for an expensive procedure, a system checks the request against clinical criteria. That check delays real patients' care when it's slow and costs real money when it's wrong — which makes it one of the most consequential pieces of backend software in healthcare.
Domain Briefing: Prior Authorization
A physician orders an MRI of the lower back. The clinic submits a prior authorization request to the insurer: which procedure (a CPT codeCurrent Procedural Terminology — the standard 5-digit codes for medical procedures. 72148 = MRI lumbar spine without contrast; 97110 = therapeutic exercise.), for which diagnosis (an ICD-10 codeThe international diagnosis coding standard. M54.50 = low back pain, unspecified; S83.511A = ACL sprain, initial encounter.), plus clinical history. The insurer's criteria say things like: "MRI lumbar spine is approved for low back pain only after 6 weeks of documented conservative therapy, unless red-flag conditions are present." Most requests are clear-cut either way — software should decide those in milliseconds. The genuinely ambiguous ones go to a human clinical reviewer. The pain in real systems: criteria hard-coded into if-statements (every policy update is a code deploy), decisions nobody can explain afterward, and audit trails that can't survive a regulator's subpoena. Your build fixes all three.
This is the canonical "policy as data" problem: a rules engine where criteria live in versioned JSON (clinical teams update policy without deploys), explainable decisions (every outcome carries machine-readable reason codes), and compliance-grade auditing (immutable, complete, PHI-safe). The same architecture runs loan underwriting, insurance claims, content moderation, and fraud rules — learn it here, reuse it everywhere.
Real prior-auth systems handle PHIProtected Health Information — patient-identifying health data regulated by HIPAA in the US. Unauthorized disclosure carries serious penalties.. You will use only synthetic data (you'll generate it), but you'll build the habits as if it were real: patient data files in .aiexclude, no patient identifiers in logs or error messages, and the audit trail referencing requests by internal ID only. Practicing on fake data with real discipline is exactly how professionals train.
What You Build
priorauth — a Flask + SQLite service with:
POST /requests— submit a prior-auth request (procedure CPT, diagnosis ICD-10, patient_age, clinical_flags likeconservative_therapy_weeks,red_flag_neuro_deficit) → evaluated immediately, returns 201 with a decisionGET /requests/<id>→ request + decision + reasons;GET /requests?outcome=DENIED→ filtered listrules/criteria.json— versioned policy file; the engine inengine.pyis pure (no Flask, no SQL)GET /audit/<request_id>— the immutable decision trail: which rule version ran, which conditions passed/failed, when- pytest suite where every criteria rule has approve, deny, AND pend cases; agent-mode feature; PR-reviewed
APPROVED
All criteria for the CPT/ICD pair satisfied. Returns auth number + validity window.
DENIED
A hard criterion failed. Reasons listed as codes + human text — deniable decisions must be appealable, so they must be explainable.
PENDING_REVIEW
Criteria partially met or unknown CPT/ICD combination. Never guess — route to a human queue.
Executing This Capstone in VS Code / PyCharm
Everything in this project is built through Gemini Code Assist inside your IDE — never by copying finished code from elsewhere. Each phase uses some of these five moves; every paste-block below names its move in the header.
- Create the project folder and open it: VS Code File → Open Folder (PyCharm: File → Open). New files via the Explorer panel / Project tool window.
- Confirm Gemini Code Assist is signed in: the ✦ icon in VS Code's Activity Bar opens the panel with no error badge (PyCharm: the Gemini tool window on the right edge greets you by account). Setup is module G01.
- Open the integrated terminal for git, Python, and pytest:
Ctrl+`in VS Code (PyCharm:Alt+F12). - After creating GEMINI.md in Phase 1, start a new chat so the rules — especially the PHI hygiene section — load into context (the G05 lesson).
- Chat and smart commands act on the active file or selection — keep the file you're working on focused, and select code before asking about it (the G03 gotcha).
| Move | VS Code | PyCharm / IntelliJ |
|---|---|---|
| 1 · Spec-comment | Type the comment in the editor and pause — ghost text appears. Tab accepts, Ctrl+→ accepts word-by-word, Esc rejects (G02) | Same: type, pause, Tab; hover the suggestion to cycle alternatives |
| 2 · Chat / smart cmd | Click the ✦ icon in the Activity Bar → chat panel opens. Select code first to scope it; /explain /fix /doc /simplify work here (G03) | Gemini tool window (right edge) → Chat tab; same slash commands |
| 3 · Generate tests | Select the function/class → right-click → Gemini Code Assist → Generate unit tests; save the output into tests/ (G04) | Select → right-click → Gemini → Generate unit tests |
| 4 · Agent ticket | In the chat input, switch the Agent toggle ON → paste the ticket → review the plan in the panel → Accept/Reject each diff → click Allow when it asks to run a command (G06) | Gemini tool window → Agent tab → paste the ticket; leave Auto-approve changes OFF; review diffs the same way |
| 5 · Headless CLI | Integrated terminal: gemini -p "..." — same login, same GEMINI.md rules as the IDE (G08) | |
Example: a block labeled "Move 4 · paste in Agent mode" means: open the Gemini panel, flip the Agent toggle (VS Code) or open the Agent tab (PyCharm), paste, and work the plan→diff→approve loop.
The Rules Engine: Policy as Data
Your starter policy — three real-shaped criteria. This file is the product; the engine just evaluates it:
{
"version": "2026.06-v1",
"criteria": [
{
"cpt": "72148", "name": "MRI lumbar spine w/o contrast",
"allowed_icd_prefixes": ["M54.5", "M51.2", "S33"],
"requires": [
{"field": "conservative_therapy_weeks", "op": ">=", "value": 6,
"waived_by": ["red_flag_neuro_deficit", "red_flag_cancer_history"],
"fail_code": "CONSERVATIVE_THERAPY_NOT_MET"}
],
"age_range": [18, null]
},
{
"cpt": "97110", "name": "Therapeutic exercise (PT)",
"allowed_icd_prefixes": ["M54", "M25", "S83"],
"quantity_limit": {"max_units": 12, "per_days": 90,
"fail_code": "QUANTITY_LIMIT_EXCEEDED",
"exceeded_outcome": "PENDING_REVIEW"}
},
{
"cpt": "70551", "name": "MRI brain w/o contrast",
"allowed_icd_prefixes": ["G43", "R51", "S06"],
"requires": [
{"field": "headache_duration_weeks", "op": ">=", "value": 4,
"waived_by": ["red_flag_neuro_deficit", "red_flag_head_trauma"],
"fail_code": "DURATION_NOT_MET"}
]
}
],
"defaults": {"unknown_cpt": "PENDING_REVIEW", "icd_mismatch": "DENIED"}
}
ICD_NOT_INDICATED; each requires condition must pass unless any waived_by flag is true (waivers must appear in the reasons as WAIVED_BY_RED_FLAG); quantity limits sum prior approved units for the same patient_ref within the window; unknown CPT → PENDING_REVIEW (the "never guess" rule). GOTCHA: the waiver logic is where most first implementations go wrong — waived means the condition is skipped and recorded, not passed silently.Build Phases
| Phase | What happens | Skills from |
|---|---|---|
| 1. Foundation | GEMINI.md + PHI hygiene + synthetic data | G05, G08 |
| 2. Engine | Pure rules engine, test-first | G02–G04 |
| 3. Service | API + immutable audit trail | G02–G03 |
| 4. Policy change | New criteria version via agent mode | G06 |
| 5. Ship | PR review + compliance self-audit | G09 |
Each phase shows a sample of success. Your generated code will phrase details differently — what must match is the shape: the three outcomes, machine-readable reason codes, rules_version on every decision, and audit entries that never name a patient. Run the service with python app.py (port 5002 in these samples) and curl from a second terminal tab.
Foundation: hygiene rules before anything else
New repo priorauth/. Your GEMINI.md gets a section no other capstone has — write it first:
## Data hygiene (non-negotiable)
- All patient data in this project is SYNTHETIC.
- Requests reference patients by opaque patient_ref (e.g. "PT-3920") —
never name, DOB, SSN, or address. No such fields may exist in any schema.
- Log lines and error messages NEVER include patient_ref or clinical flags;
reference requests by request_id only.
- The engine (engine.py) is pure: no Flask, no SQL, no I/O, no logging.
- Decisions are deterministic: same request + same rules version = same
outcome and same reason codes, always.
Add .aiexclude with data/ and *.db. Then generate synthetic test requests headlessly (Move 5 — in the integrated terminal, gemini -p "..." > make_requests.py): "a script producing 30 synthetic prior-auth requests as JSON covering all three CPTs: clear approvals, clear denials (wrong ICD, therapy weeks too low), waiver cases (red flags), quantity-limit edge cases at exactly 12 units, and 3 unknown CPTs".
data/ (your .aiexclude at work, exactly as in G05).Engine: write the tests before the engine exists
This capstone inverts the usual order — in compliance software, expected outcomes are specifiable up front, so specify them. Open rules/criteria.json in the editor (active file = chat context), then in chat (Move 2): "Generate a pytest file for a not-yet-written engine.evaluate(request: dict, rules: dict) -> Decision. Cover for each criterion: approve, deny with the exact fail_code, waiver path (with WAIVED_BY_RED_FLAG in reasons), ICD prefix mismatch → ICD_NOT_INDICATED, unknown CPT → PENDING_REVIEW, and the 97110 quantity boundary at exactly max_units." Review the expected values against the JSON yourself — you are signing off on policy here, not code. Then build engine.py with spec-comments (Move 1) until pytest tests/ -v in the integrated terminal is green.
PS> pytest tests/test_engine.py -v
tests/test_engine.py::test_72148_approved_with_6_weeks_therapy PASSED
tests/test_engine.py::test_72148_denied_therapy_not_met PASSED
tests/test_engine.py::test_72148_waived_by_neuro_red_flag PASSED
tests/test_engine.py::test_icd_prefix_mismatch_denied PASSED
tests/test_engine.py::test_unknown_cpt_pending_review PASSED
tests/test_engine.py::test_97110_quantity_at_exact_limit PASSED
tests/test_engine.py::test_97110_quantity_exceeded_pends PASSED
...
24 passed in 0.34s
evaluate() imports nothing but stdlib; running it twice on the same input yields byte-identical decisions.Service: thin API, fat audit
Build storage.py + app.py via spec-comments (Move 1). The route does four things only: validate → evaluate → persist (request, decision, reasons, rules_version) → append audit events. Use chat as your compliance reviewer before moving on:
Audit this codebase against the "Data hygiene" section of GEMINI.md:
1. Does any log statement or error message include patient_ref or clinical flags?
2. Can any endpoint mutate or delete audit rows?
3. Is rules_version stored with every decision?
List violations with file:line, or confirm each rule passes.
# APPROVED (72148, ICD M54.50, 8 weeks therapy)
{"request_id":"REQ-0007","outcome":"APPROVED",
"auth_number":"PA-2026-0007","valid_through":"2026-09-09",
"reasons":[{"code":"ALL_CRITERIA_MET","detail":"icd allowed; therapy 8/6 weeks"}],
"rules_version":"2026.06-v1"} <-- 201
# DENIED (72148, only 4 weeks therapy, no red flags)
{"request_id":"REQ-0011","outcome":"DENIED",
"reasons":[{"code":"CONSERVATIVE_THERAPY_NOT_MET",
"detail":"4 weeks documented; 6 required"}],
"rules_version":"2026.06-v1"} <-- 201
# PENDING_REVIEW (unknown CPT 99999)
{"request_id":"REQ-0019","outcome":"PENDING_REVIEW",
"reasons":[{"code":"UNKNOWN_CPT","detail":"99999 not in criteria set"}],
"rules_version":"2026.06-v1"} <-- 201
PS> curl http://localhost:5002/audit/REQ-0011
[{"event":"EVALUATED","rules_version":"2026.06-v1","at":"2026-06-11T10:42:18",
"conditions":[{"check":"icd_prefix_allowed","result":"PASS"},
{"check":"conservative_therapy_weeks>=6","result":"FAIL"}]}]
# note: request_id only — no patient_ref anywhere in audit or logs
GET /audit/<id> shows rule version + per-condition results; the self-audit returns clean (fix anything it flags — it usually finds a logging leak on the 404 path).Policy change: the day-two test, via agent
Real rules engines are judged on day two, when policy changes. Simulate it with an agent-mode ticket (G06):
Policy update effective 2026-07-01, ship as rules version "2026.07-v2"
in a NEW file rules/criteria_2026_07.json (v1 stays untouched):
1. 72148: conservative therapy requirement rises from 6 to 8 weeks.
2. New criterion: CPT 73721 (MRI knee), allowed ICD prefixes S83/M23/M25,
requires conservative_therapy_weeks >= 4, waived by red_flag_locked_knee.
3. The service selects the rules file by decision date: requests on/after
2026-07-01 use v2, earlier use v1. Decision date is injectable for tests.
4. Tests: the SAME request approved under v1 (7 weeks therapy) is DENIED
under v2 — prove version selection works both ways.
Plan first. Do not modify engine.py unless something truly requires it.
PS> pytest tests/test_versioning.py -v
tests/test_versioning.py::test_7wk_request_approved_under_v1 PASSED
tests/test_versioning.py::test_7wk_request_denied_under_v2 PASSED
tests/test_versioning.py::test_decision_date_selects_rules_file PASSED
tests/test_versioning.py::test_73721_knee_mri_new_in_v2 PASSED
4 passed in 0.18s
# identical clinical facts, different decision dates:
decision date 2026-06-15 -> APPROVED (rules_version 2026.06-v1, 6 wks required)
decision date 2026-07-02 -> DENIED (rules_version 2026.07-v2, 8 wks required,
reason CONSERVATIVE_THERAPY_NOT_MET)
# both decisions sit in the audit trail, each citing its own rules_version
engine.py, interrogate why — a true policy-as-data design needs only a rules file + version selection. (If your engine genuinely needs changes, that's a finding about your Phase 2 design — the failing-test lesson from G04 at architecture scale.) The 7-weeks request flips outcomes across the version boundary, with both results in the audit trail citing their rules version.Ship: review gate with compliance teeth
Push to GitHub. Your .gemini/styleguide.md "must flag (High)" list: patient_ref or clinical flags in any log/error string; audit-table mutation; SQL via f-strings; decisions stored without rules_version; engine.py importing Flask or sqlite3. Open Phase 4 as a PR, run the G09 discipline (fix / push back / interrogate), merge clean. Finish with a headless README: domain summary, the three outcomes, how policy updates ship without code deploys, and how to run the tests.
Grading Rubric
Stretch Goals
- Reviewer queue:
GET /queue+POST /requests/<id>/resolvefor human resolution of PENDING_REVIEW items — with the resolution joining the audit trail. - Appeals: a DENIED request can be resubmitted with new clinical flags, linked to the original; the decision must cite both.
- Criteria linting: a
pipeline-stylevalidator that checks a new criteria.json for structural errors (unknown ops, overlapping CPTs, missing fail_codes) before the service will load it. - Throughput report:
gemini -pheadless summary of decision distribution per rules version — the report a medical director would actually read.
"Built a healthcare prior-authorization backend with a versioned policy-as-data rules engine, explainable decisions with reason codes, immutable audit trails, and PHI-safe logging; policy updates ship as data with zero code deploys."