Policy Gates Engine
Configurable workflow rule engine: policy_gates table + admin UI replaces hardcoded payment/approval gates with live-editable rules.
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 lookupidx_policy_gates_status_priority— sorted retrievalidx_policy_gates_organizationidx_policy_gates_scope_json(GIN) — scope filtering at scalepolicy_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-realtimesubscribes topostgres_changesonpolicy_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)
-
Show the problem. Open the pathology specimen-collection page for an unpaid order. The “เก็บตัวอย่าง” button is replaced by the red “กรุณาชำระเงิน…” message.
-
Show the admin page. Navigate Admin → Setup Hub → Policy gates (Operations section). Six rules listed.
-
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.
-
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, severitywarning, message: “Order not paid — proceed with caution” - Status: active
Save. Pathology dialog now shows a yellow warning instead of blocking.
-
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.
-
Show multi-region scenario. Open Japan deployment in a separate browser tab. The same
policy_gatestable 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):
- Pick a trigger string. Existing:
collect_specimen,send_specimen. For meds:dispense_medicationis already in the type union — just use it. - Find the dialog or button that performs the action.
- Compute eval context — what’s the order? what patient class? what encounter type?
- Drop in
usePolicyGate(ctx)following the §5 pattern. - (Optional) Add the trigger to the dropdown options in
web/src/pages/admin/policy-gates/index.tsxTRIGGER_OPTIONSif 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.:
- Make sure the field is populated in the eval context where the dialog calls
usePolicyGate. - Add it to
FIELD_OPTIONSinweb/src/pages/admin/policy-gates/index.tsxso 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 patternuseBloodBankRealtime.ts— singleton refcounted realtime channelworklist-editor/— admin config UI shapecondition-list/(CDS metadata filters) — predicate builder ergonomics
-
Related architecture docs:
patient-intake-resolver.md— workflow template resolution (different layer; rules are evaluated during a workflow, templates define the workflow itself)department-command-center-tabs.md— department UI composition
-
Supabase project:
hynsmfrevlsegbmjnoiy(Test) — migration applied 2026-04-25.