Policy Gates Coverage Map
Visual coverage map of every UI button gated by the policy engine, with DOM mockups of each gate point.
Purpose: Show every place a policy-gate rule actually fires in the UI, the trigger string, the button or action it gates, and what the user sees when blocked.
Read this alongside: policy-gates.md for architecture, schema, and the rule engine. This doc is the visual “where”.
End-to-end flow
┌──────────────────────────────────────────────────────────────────────┐
│ ADMIN ────────────────────────────────────────────────────────── │
│ │
│ /admin/policy-gates /admin/policy-gates/builder │
│ ┌─────────────────────┐ ┌──────────────────────────────┐ │
│ │ Form-based editor │ ⇄ │ Visual drag-and-drop builder │ │
│ │ • list + drawer │ │ • Trigger → Scope → Cond → │ │
│ │ • chip-multi-select │ │ Action node graph │ │
│ └──────────┬──────────┘ └────────────┬─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Supabase: policy_gates (write-truth config table) │ │
│ │ scope_json · predicate_json · action_json · graph_json │ │
│ └──────────────────────────────┬───────────────────────────────┘ │
└──────────────────────────────────┼──────────────────────────────────┘
│ realtime postgres_changes (~300 ms)
▼
┌──────────────────────────────────────────────────────────────────────┐
│ CLINICAL UI ───────────────────────────────────────────────────── │
│ │
│ usePolicyGate({ trigger, data }) → React Query (cached) │
│ evaluateGates(rules, ctx) → { blocked, message, severity } │
│ │
│ Six gate points wired today (see §Gates below) │
└──────────────────────────────────────────────────────────────────────┘
The same policy_gates rows drive both editors and every gate. A rule edited in the visual builder updates the canonical JSON columns (via graphToGate() in serialize.ts); the runtime hook reads those columns and never touches graph_json. The two editors are interchangeable surfaces over the same truth.
Gate inventory
Six gate points wired into the UI today. Each one is a single usePolicyGate(ctx) call with a trigger string and a data context.
| # | Trigger | Where it fires | File |
|---|---|---|---|
| 1 | place_order |
Patient profile → Order system → Submit button | DialogOrder.tsx |
| 2 | collect_specimen |
Pathology specimen-collection → multi-select dialog | DialogSpecimenCollectCheckbox.tsx |
| 3 | collect_specimen |
Pathology — single specimen “Collect + Send” | DialogSpecimenCollect.tsx |
| 4 | collect_specimen |
Pathology — activate-order screen | DialogActiveOrder.tsx |
| 5 | send_specimen |
Pathology — send to lab (multi-select) | DialogSpecimenSentCheckbox.tsx |
| 6 | send_specimen |
Pathology — send to lab (single) | DialogSpecimenSent.tsx |
Gate 1 · place_order — first order placement from patient profile
The button being gated:
Patient profile ▸ ระบบสั่งการ ▸ สั่งยา: พยาธิวิทยา
┌────────────────────────────────────────────────────────────────┐
│ ✓ ALK Ag [+/-] in Tissue by Immune stain 420.00 │
│ ✓ Actin Ag [+/-] in Tissue by Immune stain 1,320.00 │
│ ✓ AFP Ag [+/-] in Tissue by Immune stain 290.00 │
│ … │
├────────────────────────────────────────────────────────────────┤
│ รายการทั้งหมด: 3 ยอดสุทธิ: 2,030.00 │
│ │
│ ┌─────────┐ ┌──────────┐ ┌─────────────────────┐ ⌘+Enter │
│ │ ยกเลิก │ │ บันทึก │ │ ยืนยันสั่งออเดอร์ ◀━━━━━━━━━━ GATE
│ └─────────┘ │ (draft) │ │ (pending) │ │
│ └──────────┘ └─────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
Behavior when a rule fires:
┌────────────────────────────────────────────────────────────────┐
│ ╔═══════════════════════════╗ │
│ ║ ⚠ Snackbar (top-right) ║ │
│ ║ ต้องตรวจสอบสิทธิประกัน… ║ │
│ ╚═══════════════════════════╝ │
│ │
│ ┌─────────┐ ┌──────────┐ ┌─────────────────────┐ │
│ │ ยกเลิก │ │ บันทึก │ │ ยืนยัน ◇ disabled ◇│ │
│ └─────────┘ │ (draft) │ └─────────────────────┘ │
│ └──────────┘ hover → tooltip with message │
└────────────────────────────────────────────────────────────────┘
• Submit button: disabled + tooltip with the rule's message
• Snackbar: red/error variant, 6s auto-hide, top-right
• Draft save (บันทึก) is INTENTIONALLY allowed through —
doctors don't lose work; only final submission is blocked
• Cmd+Enter shortcut also routes through the same gate
Eval context the dialog passes to usePolicyGate:
{
trigger: 'place_order',
data: {
order: {
payment_status: 'pending', // not paid yet (just placing)
product_category: 'pathology', // mapped from typeForm
item_count: 3, // # rows in the cart
total: 2030.00, // ยอดสุทธิ
},
encounter: { type: 'OPD', department_id: '…', clinic_id: '…' },
patient: { class: 'cash' },
}
}
Demo rules already seeded for this trigger:
Place order: insurance verification required(draft, pri 200, requiresinsurance.preauth existsfor insurance pathology/imaging orders)Place order: high-cost order needs supervisor sign-off(draft, pri 150, blocks iforder.total > 10000)
Gate 2 · collect_specimen — multi-select specimen collection
Where the user is: Pathology → Collect Specimens → click a queued patient → multi-select rows → “Collect” / “Collect + Send” button area at footer.
/pathology/collect-specimens ▸ เก็บสิ่งส่งตรวจหลายตัว (dialog)
┌────────────────────────────────────────────────────────────────┐
│ สถานะการชำระเงิน: ◇ รอชำระ │
│ │
│ ┌──────────┬─────────────────┬──────────┬─────────┐ │
│ │ รหัส │ ชื่อรายการ │ payment │ status │ │
│ ├──────────┼─────────────────┼──────────┼─────────┤ │
│ │ LN-26115 │ Routine bx │ 🟠 รอชำระ│ pending │ │
│ │ LN-26115 │ Routine bx │ 🟠 รอชำระ│ pending │ │
│ │ LN-26115 │ Routine bx │ 🟠 รอชำระ│ pending │ │
│ └──────────┴─────────────────┴──────────┴─────────┘ │
├────────────────────────────────────────────────────────────────┤
│ 📨 พิมพ์ ┌─ when rule passes ──────────────────────────┐ │
│ │ ยกเลิก │ เก็บตัวอย่าง │ เก็บตัวอย่าง+ส่งตรวจ │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌─ when rule blocks (CURRENT) ─────────────────┐ │
│ │ ยกเลิก │ 🔴 กรุณาชำระเงินสำหรับทุกรายการ… │ │
│ └────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
▲
canProceed = !policyResult.blocked
legacy fallback: allFilteredItemsPaid
The whole action block (3 buttons) collapses to Cancel + inline error message when blocked. Replaces the previous hardcoded isPaid check exactly.
Gate 3 · collect_specimen — single-specimen “Collect + Send”
Same trigger string as Gate 2, but fired in the single-row dialog. UI shape:
┌────────────────────────────────────────────────────────────────┐
│ Collect Specimen — single row │
├────────────────────────────────────────────────────────────────┤
│ ┌─ pass ──────────────────────────────────────────┐ │
│ │ ยกเลิก │ เก็บตัวอย่าง │ ส่งตรวจ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ blocked ───────────────────────────────────────┐ │
│ │ ยกเลิก │ ⓘ กรุณาชำระเงินเพื่อดำเดินการต่อไป │ │
│ └─────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
A rule with trigger=collect_specimen and matching scope fires both Gate 2 and Gate 3 — they’re two views of the same action.
Gate 4 · collect_specimen — activate-order (“Active + Print Sticker”)
When the patient first arrives at pathology and the queue activates their order. Same trigger, slightly different button label:
┌────────────────────────────────────────────────────────────────┐
│ Order — pre-collection activation │
├────────────────────────────────────────────────────────────────┤
│ ┌─ pass ──────────────────────────────────────────┐ │
│ │ Patient Timeline │ │ Active + Print Sticker │ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ blocked ───────────────────────────────────────┐ │
│ │ Patient Timeline │ │ 🔴 กรุณาชำระเงิน… │ │ │
│ └─────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
Gate 5 · send_specimen — send to lab (multi-select)
When pathology has collected specimens and is now sending them to the lab. Different trigger so admins can configure separately:
┌────────────────────────────────────────────────────────────────┐
│ ส่งสิ่งส่งตรวจหลายตัว (dialog) │
├────────────────────────────────────────────────────────────────┤
│ 📨 พิมพ์ ┌─ pass ─────────────────────────────────┐ │
│ │ ยกเลิก │ ส่งตรวจ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─ blocked ──────────────────────────────┐ │
│ │ ยกเลิก │ 🔴 กรุณาชำระเงิน… │ │
│ └─────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
Gate 6 · send_specimen — send to lab (single)
Same trigger as Gate 5, single-row dialog version. Same UI pattern.
What the runtime hook does (one place)
Every gate above is wired with the same 4-step pattern. This is the entire integration:
// 1. Build the eval context (component-specific data)
const ctx = useMemo(() => ({
trigger: 'collect_specimen' as const,
data: {
order: { payment_status, product_category: 'pathology', total },
encounter: { type, department_id },
patient: { class },
},
}), [/* deps */]);
// 2. Evaluate against active rules (auto-refreshes via realtime)
const { result, gates } = usePolicyGate(ctx);
// 3. Decide. If no rule is configured, fall back to legacy behavior.
const canProceed = gates.length > 0 ? !result.blocked : legacyCheck;
const blockedMessage = (gates.length > 0 && result.message)
? result.message
: 'fallback string';
// 4. Use canProceed in JSX. The legacy boolean and string remain.
{canProceed ? <ActionButtons/> : <Typography>{blockedMessage}</Typography>}
Three properties this guarantees:
- No regression risk. If Supabase is unreachable or no rules match, every gate falls back to the previous hardcoded check.
- Live updates. When an admin saves a rule, the realtime channel fires and the dialog re-renders within ~300 ms. No page refresh.
- Same source of truth for both editors. The visual builder writes the canonical
scope_json/predicate_json/action_json(viagraphToGate()); the runtime never readsgraph_json. Either editor, same outcome.
What is NOT gated yet (Phase 2 candidates)
| Action | File | Why not in Phase 1 |
|---|---|---|
| Drug dispensing | medication / pharmacy modules | Not yet identified specific dialog. dispense_medication trigger reserved. |
| Imaging proceed | imaging modules | perform_imaging trigger reserved. Demo seed rule exists. |
| Patient discharge | ADT / discharge module | discharge_patient trigger reserved. |
| Patient admission | ADT module | admit_patient trigger reserved. |
| Order cancellation | Order module | cancel_order trigger reserved. Could be added quickly with the same 4-step pattern. |
| Backend enforcement | NestJS / Moleculer services | UI gates only — direct API calls bypass. Same evaluateGates() engine should run server-side. |
The hook + service pattern is generic. Any new gate point is an ~8-line addition matching §“What the runtime hook does”.
Demo flow — what to show on Wednesday
-
Open
/pathology(PathologyLanding). Show the three sections (Workflow / Tools / Admin). Click “Visual Rule Builder” in Admin. -
Visual builder opens at
/admin/policy-gates/builder. Drag Trigger → Scope → Condition → Action. Configure each in the right inspector. Save. -
Open patient profile in another tab. Click Order → pathology → add 3 items. Try to submit.
- If your rule blocks: button disabled, tooltip with message, snackbar on click.
- If your rule allows or no rule: order submits.
-
Toggle the rule’s status to inactive in the admin list. Without refreshing the patient profile tab, click submit again — now passes (~300 ms after toggle).
-
Repeat for
collect_specimen. Show the pathology specimen-collection screen → unpaid order → block message. Toggle the seeded rule off → button area unblocks. -
Show the visual graph round-trip. On the rule list, click the visual-builder icon for an existing form-edited rule → it auto-lays out into the graph → make a tweak → save → form-editor still shows the same data.
The whole story: one rule, six points where it can fire, two ways to author it, propagating live to every open clinical UI.