medOS ultra

Policy Gates Engine

Configurable workflow rule engine: policy_gates table + admin UI replaces hardcoded payment/approval gates with live-editable rules.

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

Status: Phase 1 shipped (frontend-enforced) · Phase 2 backend enforcement deferred Scope: Replace hardcoded UI-level workflow gates (e.g. “payment must be paid before specimen collection”) with admin-editable rules that propagate live across all open clinical UIs. Audience: Engineering, clinical operations admins, demo/sales.


1. Problem

The pathology specimen-collection dialogs blocked unpaid orders with a hardcoded check:

// DialogSpecimenCollectCheckbox.tsx — before
const hasUnpaidItem = filteredData.some(
  (item) => item.statusPayment === BillingStatusPathology.PENDING || item.statusPayment === 'None'
);
setAllFilteredItemsPaid(!hasUnpaidItem);

// JSX
{allFilteredItemsPaid ? <CollectButton /> : <Typography>กรุณาชำระเงิน...</Typography>}

This was duplicated across 5 pathology dialogs. The behavior was correct for default Thai outpatient flows but failed for:

  • ER — emergency cases must proceed without prepay
  • VIP patients — payment can be settled later
  • Insurance — preauth gate, not payment gate
  • Japan / Philippines / nursing-home market packs — different default policies per region

Every variant required a code change, build, and redeploy. The clinic operations team had no lever.

This document describes the configurable rule engine that replaces those checks. Admins can now toggle, edit, and create rules at /admin/policy-gates, and changes propagate to every open clinical UI within ~300 ms via Supabase realtime.


2. Architecture overview

                      ┌──────────────────────────────────────┐
                      │  /admin/policy-gates  (admin UI)     │
                      │  list · create · edit · toggle       │
                      └───────────────┬──────────────────────┘
                                      │ writes
                                      ▼
                      ┌──────────────────────────────────────┐
                      │  Supabase: policy_gates              │
                      │  (write-truth config table)          │
                      └───────────────┬──────────────────────┘
                                      │ realtime channel
                                      ▼
   ┌──────────────────────────────────────────────────────────────────────┐
   │  usePolicyGate(ctx)  →  evaluateGates(rules, ctx)                    │
   │  • runs locally in every component that asks                         │
   │  • cached via React Query (1 min stale time, realtime invalidates)   │
   │  • returns { blocked, message, severity, matchedGate }               │
   └─────────────────┬────────────────────────────────────────────────────┘
                     │
   ┌─────────────────┼──────────────────────────────────────────────────┐
   │                 │                                                  │
   ▼                 ▼                                                  ▼
DialogSpecimen    DialogActiveOrder    DialogSpecimenSent   ... future dialogs
CollectCheckbox                                              and miniapps

Key design choices:

Choice Rationale
Rules stored as JSON columns (scope_json, predicate_json, action_json) Mirrors the workflow_templates pattern — flexible, schemaless within Postgres, GIN-indexable on scope.
Pure evaluateGates() function exported separately from the service Testable without Supabase; could be reused server-side later.
Realtime pub/sub on the config table Admins editing rules see them propagate live without redeploy or page refresh. Critical for the “dial in” demo experience.
Legacy hardcoded check kept as a fallback If the table is empty or unreachable, dialogs degrade to the old behavior — zero regression risk on day 1.
Frontend-only enforcement in Phase 1 Ships in days, not weeks. Backend duplication of the engine is Phase 2 once the UX is validated.

3. Data model

Table: policy_gates

Column Type Notes
id UUID PK gen_random_uuid()
name TEXT Required. Human-readable rule name.
description TEXT Optional
status TEXT 'active' | 'inactive' | 'draft'
priority INT Higher wins on conflict. Default 100.
trigger_action TEXT The workflow action this rule guards (e.g. collect_specimen).
scope_json JSONB Who/what the rule applies to. See §3.1.
predicate_json JSONB What must be true for the action to PASS. See §3.2.
action_json JSONB What happens when the predicate FAILS. See §3.3.
override_json JSONB Reserved for Phase 2 (override permissions).
organization_id UUID Tenancy.
created_by, updated_by TEXT Audit.
created_at, updated_at TIMESTAMPTZ Auto-managed.

Indexes:

  • idx_policy_gates_trigger_action (partial, WHERE status = 'active') — main eval lookup
  • idx_policy_gates_status_priority — sorted retrieval
  • idx_policy_gates_organization
  • idx_policy_gates_scope_json (GIN) — scope filtering at scale
  • policy_gates_pkey

Realtime: table is a member of the supabase_realtime publication.

3.1 Scope JSON

Empty arrays / missing keys = “applies to all” for that dimension.

{
  "product_ids":         ["LN-2510115"],
  "product_categories":  ["pathology", "imaging"],
  "department_ids":      ["uuid"],
  "clinic_ids":          ["uuid"],
  "facility_ids":        ["uuid"],
  "encounter_types":     ["OPD", "IPD", "ER"],
  "patient_classes":     ["cash", "insurance", "VIP", "staff"],
  "benefit_scheme_ids":  ["philhealth", "kaigo"]
}

3.2 Predicate JSON

{
  "type": "all" | "any",
  "conditions": [
    { "field": "order.payment_status", "op": "equals", "value": "paid" },
    { "field": "patient.deposit_balance", "op": "gte", "value": "order.total" }
  ]
}

Operators: equals, not_equals, in, not_in, gt, gte, lt, lte, exists, not_exists, contains.

Field paths are dot-notation against the eval context (§4.1). Values can themselves be paths — "order.total" is resolved against the same context, enabling cross-field comparisons.

3.3 Action JSON

{
  "type": "block" | "warn" | "require_override",
  "severity": "error" | "warning" | "info",
  "message":     "กรุณาชำระเงินสำหรับทุกรายการในลิสต์นี้เพื่อดำเนินการต่อไป",
  "message_en":  "Please pay for all items in this list before proceeding"
}

require_override reserved for Phase 2.


4. Runtime evaluation

4.1 Eval context shape

Every component calling usePolicyGate(ctx) passes a context object with this shape:

{
  trigger: 'collect_specimen' | 'send_specimen' | 'dispense_medication' | ...,
  data: {
    order:      { payment_status, product_category, product_id, total, ... },
    patient:    { class, deposit_balance, benefit_scheme_id, ... },
    encounter:  { type, department_id, clinic_id },
    facility:   { id },
    insurance:  { preauth },
  },
}

Components only populate the fields they have available; missing fields evaluate to undefined and either match wildcards (in scope) or fail predicates (depending on the operator).

4.2 Eval algorithm

1. Fetch all rules where status='active' AND trigger_action = ctx.trigger
2. For each rule:
   a. If scope doesn't match ctx.data → skip
   b. If predicate is empty OR predicate passes → skip (rule satisfied)
   c. Otherwise → rule fired, add to fired list
3. If fired list is empty → result.blocked = false
4. Else → sort fired by priority desc, take top:
   - block        → blocked = true
   - warn         → warned = true
   - require_override → blocked = true, canOverride = true

Pure function: evaluateGates(gates, ctx) in policy-gate.service.ts. No I/O. No side effects. Easy to unit-test.

4.3 Caching & realtime

  • React Query key: ['policy-gates-by-trigger', trigger]
  • staleTime: 60_000 (1 minute) — eval is fast, but no need to refetch every render
  • Realtime channel policy-gates-realtime subscribes to postgres_changes on policy_gates
  • On any change, debounced (300 ms) cache invalidation fires
  • Singleton refcounted channel — multiple components mounting = one Supabase subscription

5. File index

Backend / config (Supabase)

Path Purpose
web/supabase/migrations/20260425_policy_gates.sql Table, indexes, realtime publication, 6 seed rules (2 active + 4 demo drafts)

Frontend service & hooks

Path Purpose
web/src/services/policy-gate.service.ts CRUD service + pure evaluateGates() engine + types
web/src/hooks/usePolicyGatesRealtime.ts Singleton refcounted realtime channel; mirrors useBloodBankRealtime
web/src/hooks/usePolicyGate.ts usePolicyGate(ctx) runtime hook for components

Admin UI

Path Purpose
web/src/pages/admin/policy-gates/index.tsx List view + create/edit drawer + condition builder
web/src/routes/AdminRoutes.tsx Adds route /admin/policy-gates
web/src/pages/admin/setup-hub.tsx Adds “Policy gates” card under Operations in Setup Hub

Wired dialogs (5 of 5)

Path Trigger
web/packages/diagnostics-kit/src/pathology/collect-specimens/components/dialog/dialog-speciment-collect-checkbox/DialogSpecimenCollectCheckbox.tsx collect_specimen
web/packages/diagnostics-kit/src/pathology/collect-specimens/components/dialog/dialog-active-order/DialogActiveOrder.tsx collect_specimen
web/packages/diagnostics-kit/src/pathology/collect-specimens/components/dialog/dialog-specimen-collect/DialogSpecimenCollect.tsx collect_specimen
web/packages/diagnostics-kit/src/pathology/collect-specimens/components/dialog/dialog-speciment-sent-checkbox/DialogSpecimenSentCheckbox.tsx send_specimen
web/packages/diagnostics-kit/src/pathology/collect-specimens/components/dialog/dialog-specimen-sent/DialogSpecimenSent.tsx send_specimen

DialogCancelOrder.tsx was reviewed and intentionally not wired — it has no payment gate (its isPaid is purely chip display logic).

Integration pattern in dialogs

Each wired dialog follows the same pattern:

// 1. Import
import { usePolicyGate } from '@/hooks/usePolicyGate';

// 2. Build context
const policyCtx = useMemo(() => ({
  trigger: 'collect_specimen' as const,
  data: { order: {...}, patient: {...}, encounter: {...} },
}), [/* deps */]);

// 3. Evaluate
const { result: policyResult, gates: policyGates } = usePolicyGate(policyCtx);

// 4. Decide with legacy fallback
const canProceed = policyGates.length > 0 ? !policyResult.blocked : legacyCanProceed;
const blockedMessage = policyGates.length > 0 && policyResult.message
  ? policyResult.message
  : 'hardcoded fallback string';

// 5. JSX uses canProceed + blockedMessage instead of legacy boolean + hardcoded text

The legacy boolean and hardcoded text are kept to ensure zero regression if Supabase is unreachable.


6. Seeded rules (live in production)

Status Priority Trigger Name What it does
active 100 collect_specimen Pathology: payment required before specimen collection Default behavior — preserves existing isPaid check
active 100 send_specimen Pathology: payment required before sending specimen Same for the send action
draft 500 collect_specimen ER bypass When activated, ER encounters skip payment gate (priority 500 wins over 100)
draft 500 collect_specimen VIP bypass When activated, VIP patients skip payment gate
draft 200 perform_imaging Insurance preauth required When activated, MRI/CT for insurance patients require preauth
draft 150 collect_specimen Cash patients deposit must cover order When activated, cash patients need deposit ≥ order total

Drafts are inactive on day 1 and serve as the demo dial-in.


7. Demo script (Wednesday)

  1. Show the problem. Open the pathology specimen-collection page for an unpaid order. The “เก็บตัวอย่าง” button is replaced by the red “กรุณาชำระเงิน…” message.

  2. Show the admin page. Navigate Admin → Setup Hub → Policy gates (Operations section). Six rules listed.

  3. Show real-time switching. Flip the switch on “Pathology: payment required before specimen collection” to inactive. Within ~300 ms the pathology dialog re-renders with the collect button enabled — no refresh.

  4. Show new rule creation. Click “New rule.” Fill in:

    • Name: “Demo: warn instead of block for pathology”
    • Trigger: collect_specimen
    • Scope → product categories: pathology
    • Conditions: order.payment_status equals paid
    • Action: warn, severity warning, message: “Order not paid — proceed with caution”
    • Status: active

    Save. Pathology dialog now shows a yellow warning instead of blocking.

  5. Show priority resolution. Activate the “ER bypass” demo rule (priority 500). Toggle an order’s encounter type to ER (or change the demo data). The higher-priority bypass rule wins.

  6. Show multi-region scenario. Open Japan deployment in a separate browser tab. The same policy_gates table can hold per-region rules; market packs seed region-specific defaults.


8. Operational notes

8.1 Applying the migration to other regions

The migration is idempotent. To apply to a new Supabase project:

# Option A — via Supabase Management API (used for current Test project)
curl -X POST \
  -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  --data "$(jq -Rs '{query: .}' web/supabase/migrations/20260425_policy_gates.sql)" \
  "https://api.supabase.com/v1/projects/$PROJECT_REF/database/query"

# Option B — psql directly
psql "$SUPABASE_DB_URL" -f web/supabase/migrations/20260425_policy_gates.sql

# Option C — supabase CLI
supabase db push --linked

The WHERE NOT EXISTS guards on seed rules and the DO $$ ... EXCEPTION block on ALTER PUBLICATION make re-runs safe.

8.2 Per-region seeding (market packs)

For the Japan / Philippines market packs, add region-specific seed rules to:

infrastructure/market-packs/medos-japan/seed-policy-gates.sql
infrastructure/market-packs/medos-philippines/seed-policy-gates.sql

Use these to activate kaigo-specific bypass rules, PhilHealth preauth flows, etc. The base table schema is shared.

8.3 Rotating tokens

The Wednesday demo migration was applied with a Personal Access Token shared in chat. Rotate after demo: Supabase Dashboard → Account → Access Tokens → revoke + regenerate.

8.4 Adding a new wired action

To bring a new action under policy-gate control (e.g. dispense medication):

  1. Pick a trigger string. Existing: collect_specimen, send_specimen. For meds: dispense_medication is already in the type union — just use it.
  2. Find the dialog or button that performs the action.
  3. Compute eval context — what’s the order? what patient class? what encounter type?
  4. Drop in usePolicyGate(ctx) following the §5 pattern.
  5. (Optional) Add the trigger to the dropdown options in web/src/pages/admin/policy-gates/index.tsx TRIGGER_OPTIONS if it isn’t there.

No backend changes needed. The rule engine and admin UI are entirely generic.

8.5 Adding a new field to predicates

If you need patient.height or order.specimen_count etc.:

  1. Make sure the field is populated in the eval context where the dialog calls usePolicyGate.
  2. Add it to FIELD_OPTIONS in web/src/pages/admin/policy-gates/index.tsx so admins can pick it from the dropdown.

The runtime eval reads via dotted path — no other changes required.


9. What’s deferred (Phase 2)

Item Why deferred Risk if not done
Backend Moleculer enforcement Frontend-only ships in days. Duplicating the eval in NestJS / Moleculer is straightforward but costs another week. UI can be bypassed by direct API calls. Fine for demo; not fine for production security.
Override flow (reason + signature) override_json column exists; UI not built. High-priority for production — clinical staff legitimately need to override gates for emergencies.
Audit log table (policy_gate_audit) Schema implied; not implemented. HIPAA/regulatory requirement. Build before any real-patient deployment.
Replace CSV scope inputs with proper pickers DepartmentSelector and product picker exist; current admin UI uses CSV strings for speed. Admin UX is rough but functional. Demoable.
Visual ReactFlow rule editor Out of scope for Wednesday. Power-users may want it later; current form-based editor is fine for ~95 % of rules.

10. References

  • Existing patterns this design mirrors:

    • workflow-template.service.ts — JSON-config CRUD pattern
    • useBloodBankRealtime.ts — singleton refcounted realtime channel
    • worklist-editor/ — admin config UI shape
    • condition-list/ (CDS metadata filters) — predicate builder ergonomics
  • Related architecture docs:

  • Supabase project: hynsmfrevlsegbmjnoiy (Test) — migration applied 2026-04-25.

Ask Anything