medOS ultra

Policy Gates Coverage Map

Visual coverage map of every UI button gated by the policy engine, with DOM mockups of each gate point.

8 min read diagramsUpdated 2026-04-28docs/architecture/policy-gates-coverage.md

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, requires insurance.preauth exists for insurance pathology/imaging orders)
  • Place order: high-cost order needs supervisor sign-off (draft, pri 150, blocks if order.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:

  1. No regression risk. If Supabase is unreachable or no rules match, every gate falls back to the previous hardcoded check.
  2. Live updates. When an admin saves a rule, the realtime channel fires and the dialog re-renders within ~300 ms. No page refresh.
  3. Same source of truth for both editors. The visual builder writes the canonical scope_json / predicate_json / action_json (via graphToGate()); the runtime never reads graph_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

  1. Open /pathology (PathologyLanding). Show the three sections (Workflow / Tools / Admin). Click “Visual Rule Builder” in Admin.

  2. Visual builder opens at /admin/policy-gates/builder. Drag Trigger → Scope → Condition → Action. Configure each in the right inspector. Save.

  3. 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.
  4. 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).

  5. Repeat for collect_specimen. Show the pathology specimen-collection screen → unpaid order → block message. Toggle the seeded rule off → button area unblocks.

  6. 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.

Ask Anything