Insurance-Aware Workflows
Coverage as a 4th orthogonal axis: insurance_context column, four ReactFlow node types, edge condition DSL, registry-driven scheme slots.
Status: Design draft (2026-05-08) Branch:
claude/review-insurance-worklist-config-0TEy1Owners: workflow / orchestrator / market-pack maintainers Related docs:policy-gates.md,policy-gates-coverage.md,encounter-orchestrator-triggers.md,cross-region-policy-gates-deployment.md,patient-intake-resolver.md,department-command-center-tabs.md
TL;DR
Today,
policy_gatescan already gate actions bybenefit_scheme_ids, but the workflow itself, the queues, and the modals do not branch by scheme. We will add a singleinsurance_contextcolumn onencounter_journey_cache, four new ReactFlow node types inmain-flow-editor, edge-levelconditionJSONB onworkflow_templates.edges, three small additions toencounter-orchestrator/index.ts, and a frontend `` registry. Together they let admins drag-and-drop a flow that branches per insurance scheme, with the right modals/tabs popping up automatically per table.
1. Problem
A “Hospital Information System” running in PH, JP, and TH has fundamentally different patient journeys depending on payer:
| Payer | Branch points |
|---|---|
| Self-pay (cash) | No preauth. Deposit gates collection. Direct-pay receipts at discharge. |
| PhilHealth (PH) | Member eligibility check. PhilHealth Case Rate selection. PhilHealth Benefit Eligibility (PBEF) form before discharge. e-Claims submission queue. |
| Kaigo / 介護保険 (JP) | LTC certification check. Service plan binding. Care manager routing. Co-pay calc by income tier. |
| Social Security / SSS / NHSO | Hospital tier check. Referral validity. Transferable-rights queue. |
| Private insurance | Preauth code. Letter-of-guarantee modal. Direct-billing vs. reimbursement branch. |
| Corporate | Employer-pays-portion calc. Discount table by employer code. |
Today these are either:
- Hardcoded in component branches (
if (encounter.scheme === 'PH') …) — fragile, scattered. - Achieved by giving each market its own template — duplicates 80% of node graph per payer.
- Bypassed by a one-size-fits-all flow that fails an audit (no preauth gate, no PhilHealth tab).
We need a first-class scheme dimension on the workflow graph + read model + UI registry, so:
- Admins drag a
SchemeBranchnode in the canvas and route per scheme. - The orchestrator stamps
insurance_contextinto every queue row. - The frontend mounts the right modal/tab/badge on each table without per-page
if. - The market-pack ships scheme-specific overlays without forking core templates.
2. The four orthogonal axes
The repo already documents the three-coordinate model (web/src/common/components/medical/builder/main-flow-editor/CLAUDE.md):
| Axis | Means | Lives in |
|---|---|---|
| Spatial (WHERE) | Floor plan location | floor_plan_drawings, Location tab on node panel |
| Workflow (WHAT state) | Step/status | workflow_templates.nodes[], Settings tab |
| Organizational (WHO sees it) | Role-based visibility | worklist-editor role config |
This proposal adds a fourth, orthogonal axis:
| Axis | Means | Lives in |
|---|---|---|
| Coverage (HOW PAID) | Insurance scheme + plan + entitlements | encounter_journey_cache.insurance_context, Coverage tab on node panel, `` registry |
These four axes compose multiplicatively. Existing axes never need to know about coverage — coverage flows in through:
- A read-model column the orchestrator writes.
- An edge condition on
workflow_templates. - A registry the frontend consults to mount slot extensions.
policy_gates sits at the intersection of “WHAT state” + “HOW PAID” — it gates actions; it does not own workflow shape. We keep that separation strict.
3. End-to-end wiring
┌──────────────────────────────────────────────┐
│ main-flow-editor (ReactFlow v11 canvas) │
│ • SchemeBranchNode │
│ • CoverageCheckNode │
│ • PreauthGateNode │
│ • SchemeSlotNode │
│ • edges with condition: { schemeIn: [...]} │
└──────────────────────┬───────────────────────┘
│ save → workflow_templates
▼
┌──────────────────────────────────────────────────────┐
│ Supabase: workflow_templates │
│ nodes JSONB, edges JSONB (now with .condition), │
│ manifest_bindings, scheme_overlay_of (new) │
└──────────────────────┬───────────────────────────────┘
│ bound by worklist-editor
▼
┌──────────────────────────────────────────────────────┐
│ encounter_journey_cache │
│ • clinical_context (existing) │
│ • financial_summary (existing) │
│ • insurance_context ← NEW first-class column │
│ • workflow_template_id ← existing column │
└──────────────────────┬───────────────────────────────┘
│ consumed by Deno orchestrator
▼
┌────────────────────────────────────────────────────────────────────┐
│ encounter-orchestrator (Deno edge function) │
│ │
│ handleEncounterSeeded → buildInsuranceContext(payload) │
│ handleFinancialUpdated → refresh insurance_context.coverageStatus│
│ handleOrdersAcknowledged → stamp scheme into metadata │
│ handleWorkflowStepCompleted → evaluateEdgeCondition(edge, ctx) │
│ evaluateEdgeCondition(edge, ctx) ← NEW shared helper │
│ applyPolicyGates(ctx) ← reuses policy_gates evaluator │
└──────────────────────┬─────────────────────────────────────────────┘
│ writes
▼
┌──────────────────────────────────────────────────────┐
│ department_queues.metadata │
│ { schemeCode, benefitSchemeIds, schemeSlot, │
│ workflowState, preauthRefs, … } │
└──────────────────────┬───────────────────────────────┘
│ realtime subscribed
▼
┌──────────────────────────────────────────────────────┐
│ Frontend: <SchemeSlot/> host │
│ reads metadata + InsuranceContext from cache row │
│ resolves registry: (slotName, scheme) → component │
│ mounts the right modal/tab/badge │
└──────────────────────────────────────────────────────┘
Three things stand out:
- One source of truth — every layer reads
insurance_contextshape; no duplicate state. - Orchestrator is the only writer — frontend never writes to
insurance_context(perweb/CLAUDE.mdrule “NEVER write directly to Supabase read model tables from frontend”). - Registry > switch statements — modals/tabs are looked up by
(slot, scheme), never branched in caller code.
4. Data model
4.1 insurance_context column on encounter_journey_cache
-- Migration: 070_encounter_journey_cache_insurance_context.sql
-- Adds first-class insurance_context column + GIN index for scheme filtering.
-- Replaces buried JSONB paths in clinical_context.encounter_context.schemeCode
-- and financial_summary.schemeCode (migration 042:99-102).
-- Idempotent.
ALTER TABLE public.encounter_journey_cache
ADD COLUMN IF NOT EXISTS insurance_context JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN public.encounter_journey_cache.insurance_context IS
'Computed by encounter-orchestrator. Shape:
{
"schemeCode": "philhealth" | "kaigo" | "sss" | "nhso" | "private" | "self_pay" | …,
"benefitSchemeIds": ["philhealth", "philhealth_zbenefit"], -- multi-coverage stack
"payerId": "<uuid|null>",
"payerName": "Philippine Health Insurance Corp",
"planCode": "Z-BENEFIT" | null,
"memberId": "12-345678901-2",
"memberStatus": "active" | "lapsed" | "pending",
"entitlements": ["preauth_required", "ltc_certified", "copay_waived"],
"coverageStatus": "eligible" | "pending" | "expired" | "none",
"preauthRefs": [{ "id": "PA-2026-0001", "scope": "MRI", "expiresAt": "..." }],
"tier": "tier1" | "tier2" | "vip" | null,
"evaluatedAt": "2026-05-08T12:34:56Z",
"evaluatedBy": "orchestrator@v1"
}';
-- Indexes: most filters are "encounter has scheme X" or "scheme in [...]"
CREATE INDEX IF NOT EXISTS idx_ejc_insurance_scheme
ON public.encounter_journey_cache USING GIN ((insurance_context -> 'benefitSchemeIds'));
CREATE INDEX IF NOT EXISTS idx_ejc_insurance_status
ON public.encounter_journey_cache ((insurance_context ->> 'coverageStatus'))
WHERE (insurance_context ->> 'coverageStatus') IS NOT NULL;
-- Backfill from existing JSONB paths so day 1 is consistent.
UPDATE public.encounter_journey_cache
SET insurance_context = jsonb_strip_nulls(jsonb_build_object(
'schemeCode', COALESCE(
clinical_context #>> '{encounter_context,schemeCode}',
financial_summary ->> 'schemeCode'
),
'benefitSchemeIds', CASE
WHEN COALESCE(
clinical_context #>> '{encounter_context,schemeCode}',
financial_summary ->> 'schemeCode'
) IS NOT NULL THEN jsonb_build_array(
COALESCE(
clinical_context #>> '{encounter_context,schemeCode}',
financial_summary ->> 'schemeCode'
)
)
ELSE '[]'::jsonb
END,
'evaluatedAt', NOW(),
'evaluatedBy', 'backfill@070'
))
WHERE insurance_context = '{}'::jsonb
AND (
clinical_context #>> '{encounter_context,schemeCode}' IS NOT NULL
OR financial_summary ->> 'schemeCode' IS NOT NULL
);
The backfill is additive — it does not overwrite an insurance_context an admin already populated, and it leaves untouched rows that have no scheme info today.
4.2 Edge condition on workflow_templates
The existing schema (per 004_workflow_configs.sql and the agent report) stores edges as opaque JSONB. We do not add a column — we standardize the JSONB shape:
interface WorkflowEdge {
id: string;
source: string; // node id
target: string; // node id
sourceHandle?: string; // the SchemeBranchNode emits handles like "philhealth", "default"
targetHandle?: string;
type?: 'workflowEdge';
data?: {
label?: { en?: string; th?: string; ja?: string };
/** Coverage-aware edge selection. */
condition?: EdgeCondition;
/** Higher wins when multiple edges match. Default 0. */
priority?: number;
/** Audit metadata for "why was this branch picked?" */
rationale?: string;
};
}
type EdgeCondition =
| { schemeIn: string[] } // benefitSchemeIds ∩ schemeIn ≠ ∅
| { schemeNotIn: string[] }
| { entitlementHas: string } // 'preauth_required'
| { entitlementNot: string }
| { coverageStatus: Array<'eligible' | 'pending' | 'expired' | 'none'> }
| { all: EdgeCondition[] } // AND
| { any: EdgeCondition[] } // OR
| { not: EdgeCondition };
When no edge matches, the orchestrator falls back to the edge with condition === undefined (the “default” edge). That edge must exist for any branching node — saving a SchemeBranchNode without a default edge is a validation error in the editor.
4.3 scheme_overlay_of — per-scheme template overlays
For schemes that need substantially different graphs (e.g. Kaigo’s care-plan-bound flow vs. PhilHealth’s case-rate flow), we want overlay templates instead of branch-explosion in one template:
-- Migration: 071_workflow_template_scheme_overlay.sql
ALTER TABLE public.workflow_templates
ADD COLUMN IF NOT EXISTS scheme_overlay_of UUID NULL
REFERENCES public.workflow_templates(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS scheme_codes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
CREATE INDEX IF NOT EXISTS idx_workflow_templates_overlay
ON public.workflow_templates (scheme_overlay_of)
WHERE scheme_overlay_of IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_workflow_templates_scheme_codes
ON public.workflow_templates USING GIN (scheme_codes);
A child overlay declares scheme_overlay_of = <parent> and scheme_codes = ['kaigo']; at resolve time the orchestrator merges parent + overlay (overlay wins on conflicting node ids).
4.4 benefit_schemes lookup table
The SchemeBranchNode validates that every schemeIn code exists. Same code is also referenced by policy_gates.scope_json.benefit_scheme_ids, market-pack manifests, and frontend extension registrations. Today these strings float untyped — let’s centralize:
-- Migration: 073_benefit_schemes_lookup.sql
CREATE TABLE IF NOT EXISTS public.benefit_schemes (
code TEXT PRIMARY KEY, -- 'philhealth'
display_name JSONB NOT NULL, -- { en, th, ja, fil, … }
payer_name JSONB, -- { en: "Philippine Health Insurance Corp" }
market_pack TEXT, -- 'medos-philippines' | 'medos-japan' | 'medos-thailand' | NULL=global
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','inactive','draft')),
/** What the scheme grants by default. */
default_entitlements TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
/** Hex color for badges/decorators. Optional. */
color TEXT,
/** Slug of the optional companion frontend package, e.g. '@scheme/philhealth'. */
package_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_benefit_schemes_market
ON public.benefit_schemes (market_pack) WHERE status = 'active';
ALTER TABLE public.benefit_schemes ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Read benefit_schemes" ON public.benefit_schemes;
CREATE POLICY "Read benefit_schemes" ON public.benefit_schemes
FOR SELECT TO authenticated USING (true);
DROP POLICY IF EXISTS "Service role writes benefit_schemes" ON public.benefit_schemes;
CREATE POLICY "Service role writes benefit_schemes" ON public.benefit_schemes
FOR ALL TO service_role USING (true) WITH CHECK (true);
-- Realtime so admin edits propagate to active editors.
DO $$ BEGIN
ALTER PUBLICATION supabase_realtime ADD TABLE public.benefit_schemes;
EXCEPTION WHEN duplicate_object THEN NULL; WHEN undefined_object THEN NULL; END $$;
-- Seed the four payers we know about today. ON CONFLICT DO NOTHING so market
-- packs may seed additional rows without colliding.
INSERT INTO public.benefit_schemes (code, display_name, payer_name, market_pack, default_entitlements, color, package_id)
VALUES
('self_pay', '{"en":"Self-pay","th":"ชำระเอง","fil":"Bayad-Mismong","ja":"自費"}'::jsonb, NULL, NULL, ARRAY[]::TEXT[], '#9CA3AF', NULL),
('philhealth', '{"en":"PhilHealth","fil":"PhilHealth"}'::jsonb,
'{"en":"Philippine Health Insurance Corp"}'::jsonb,
'medos-philippines', ARRAY['preauth_required'], '#0EA5E9', '@scheme/philhealth'),
('kaigo', '{"en":"Kaigo (Long-Term Care)","ja":"介護保険"}'::jsonb,
'{"en":"Long-Term Care Insurance","ja":"長期介護保険"}'::jsonb,
'medos-japan', ARRAY['ltc_certified'], '#7C3AED', '@scheme/kaigo'),
('nhso', '{"en":"NHSO (UC / 30-baht)","th":"บัตรทอง"}'::jsonb,
'{"en":"National Health Security Office","th":"สำนักงานหลักประกันสุขภาพแห่งชาติ"}'::jsonb,
'medos-thailand', ARRAY['referral_required'], '#10B981', NULL),
('sss', '{"en":"SSS (Social Security)"}'::jsonb,
'{"en":"Social Security System"}'::jsonb,
'medos-philippines', ARRAY[]::TEXT[], '#F59E0B', NULL),
('private', '{"en":"Private insurance","th":"ประกันเอกชน"}'::jsonb,
NULL, NULL, ARRAY['preauth_required'], '#6366F1', NULL)
ON CONFLICT (code) DO NOTHING;
policy_gates.scope_json.benefit_scheme_ids[] and workflow_templates.scheme_codes[] should both reference these codes; validation on save (frontend, since CHECK constraints on JSONB array contents are clumsy) ensures every code exists in benefit_schemes with status = 'active'.
4.5 Template resolver — RPC function
Both the orchestrator and the worklist-editor need the same answer to “what template applies for (entity_type, scheme)?”. Encapsulate in a Postgres function so neither has to re-implement:
-- Migration: 074_resolve_workflow_template.sql
CREATE OR REPLACE FUNCTION public.resolve_workflow_template(
p_entity_type TEXT,
p_scheme_code TEXT
) RETURNS TABLE (
template_id UUID,
initial_node TEXT,
parent_id UUID,
resolution TEXT -- 'overlay' | 'scheme-default' | 'entity-default'
) LANGUAGE plpgsql STABLE AS $$
DECLARE
v_default_template UUID;
v_default_node TEXT;
v_scheme_template UUID;
v_scheme_node TEXT;
BEGIN
-- Most specific: (entity, scheme) in workflow_entity_defaults
SELECT wed.template_id, wed.initial_node_id
INTO v_scheme_template, v_scheme_node
FROM public.workflow_entity_defaults wed
WHERE wed.entity_type = p_entity_type
AND wed.scheme_code = p_scheme_code
AND wed.active = true
LIMIT 1;
IF v_scheme_template IS NOT NULL THEN
RETURN QUERY SELECT
v_scheme_template,
v_scheme_node,
(SELECT scheme_overlay_of FROM public.workflow_templates WHERE id = v_scheme_template),
CASE
WHEN (SELECT scheme_overlay_of FROM public.workflow_templates WHERE id = v_scheme_template) IS NOT NULL THEN 'overlay'
ELSE 'scheme-default'
END;
RETURN;
END IF;
-- Fallback: entity default (scheme_code IS NULL)
SELECT wed.template_id, wed.initial_node_id
INTO v_default_template, v_default_node
FROM public.workflow_entity_defaults wed
WHERE wed.entity_type = p_entity_type
AND wed.scheme_code IS NULL
AND wed.active = true
LIMIT 1;
IF v_default_template IS NOT NULL THEN
RETURN QUERY SELECT v_default_template, v_default_node, NULL::UUID, 'entity-default'::TEXT;
RETURN;
END IF;
-- No mapping at all — return empty set.
RETURN;
END;
$$;
Orchestrator usage (Deno):
const { data } = await db.rpc('resolve_workflow_template', {
p_entity_type: 'lab',
p_scheme_code: insuranceContext.schemeCode ?? null,
});
const { template_id, initial_node, resolution } = data?.[0] ?? {};
Frontend usage (worklist-editor preview):
const { data } = await supabase.rpc('resolve_workflow_template', {
p_entity_type: entityType,
p_scheme_code: schemeCode,
});
When resolution === 'overlay', the orchestrator merges parent and child template nodes/edges before walking — child wins on shared node.id; child edges replace parent edges that share (source, target, sourceHandle). Implementation in §7.7.
4.6 RLS — read-only for clinical roles, write only via service role
-- insurance_context is part of the existing encounter_journey_cache RLS; no
-- new policies needed. Per web/CLAUDE.md, frontend NEVER writes the read
-- model. Confirm policy denies UPDATE on this column for authenticated:
REVOKE UPDATE (insurance_context)
ON public.encounter_journey_cache
FROM authenticated;
GRANT UPDATE (insurance_context)
ON public.encounter_journey_cache
TO service_role;
benefit_schemes and workflow_entity_defaults.scheme_code follow existing patterns (read for authenticated, write for service_role only). Admins editing scheme overlays go through a backend API endpoint that forwards to service_role — the same pattern policy-gates already uses.
5. Frontend — four new ReactFlow node types
The canvas already has 200+ node types in BlockEnum (web/src/common/components/medical/builder/main-flow-editor/components/workflow/types.ts:20-200+). Each new node ships a node component + panel component wired into NodeComponentMap and PanelComponentMap (nodes/constants.ts). Per the editor’s CLAUDE.md, node panels use Tailwind, not MUI.
5.1 BlockEnum additions
// types.ts — add to BlockEnum
export enum BlockEnum {
// … existing entries …
// ─── Coverage / Insurance Workflow Nodes ────────────────────────────
SchemeBranch = 'scheme-branch', // multi-output router on benefit scheme
CoverageCheck = 'coverage-check', // calls eligibility API, sets coverageStatus
PreauthGate = 'preauth-gate', // pauses node until preauthRefs[] populated
SchemeSlot = 'scheme-slot', // declares a UI slot to fill at this step
}
These four cover ~95% of insurance branching. The existing EligibilityVerification / EligibilityApprovedDenied / ClaimSubmissionByScheme nodes are kept — they represent specific lifecycle states; the new four are routing primitives.
5.2 SchemeBranchNode
Routes one input to N outputs based on insurance_context.benefitSchemeIds. UX: drag from palette, configure N output handles, label each.
// nodes/scheme-branch/types.ts
export type SchemeBranchHandle = {
id: string; // 'philhealth', 'kaigo', 'self_pay', 'default'
label: { en: string; th?: string; ja?: string; fil?: string };
schemeIn?: string[]; // matches if insurance_context.benefitSchemeIds ∩ schemeIn ≠ ∅
isDefault?: boolean; // true for the fallback handle
};
export interface SchemeBranchNodeData {
name: string;
handles: SchemeBranchHandle[]; // 2..N
evaluation: 'first-match' | 'all-match';
/** When all-match, the orchestrator follows every matching edge in parallel. */
}
UI shape (Tailwind, Dify-style — matches existing nodes):
┌───────────────────────────────────────┐
│ ◇ Scheme Branch │
│ ─────────────────────────────────── │
│ ▷ PhilHealth (philhealth) ●│
│ ▷ Kaigo (kaigo) ●│
│ ▷ Self-pay (self_pay) ●│
│ ▷ Default ●│
└───────────────────────────────────────┘
Each output handle is one WorkflowEdge.sourceHandle. The panel lets admins add/remove/reorder handles; saving runs validation:
- At least one
isDefault: truehandle. - No duplicate
id. - All
schemeIncodes exist in thebenefit_schemeslookup.
5.3 CoverageCheckNode
Pauses the workflow while eligibility is verified asynchronously. Internally calls services/public-api/.../eligibility (or HL7 270/271 via interoperability). On success, updates insurance_context.coverageStatus.
export interface CoverageCheckNodeData {
name: string;
payerEndpoint: 'philhealth' | 'kaigo-ltc' | 'sss' | 'private-271' | 'mock';
timeoutMs: number; // 30000 default
onTimeout: 'fail' | 'pass-as-pending';
cacheTtlMinutes: number; // 60 default — re-use prior eligibility within the window
}
Output handles: eligible, pending, expired, none, error. Each is a normal WorkflowEdge — no condition needed; the node itself routes by call result.
5.4 PreauthGateNode
Pure gate — does not call out, does not write. Holds the encounter at this node until insurance_context.preauthRefs[] contains a ref whose scope matches the configured product/category.
export interface PreauthGateNodeData {
name: string;
/** Product or category we need preauth for. */
scopeMatch: { productIds?: string[]; productCategories?: string[] };
/** Which entitlements satisfy this gate without an explicit preauth ref. */
bypassEntitlements?: string[]; // e.g. ['copay_waived']
/** What user-facing affordance to show while waiting. */
affordance: 'block' | 'warn' | 'silent';
}
Composes with policy_gates — the gate evaluator at web/src/services/policy-gate.service.ts:258 is what surfaces the modal; this node just declares “stop here”.
5.5 SchemeSlotNode
The visual marker that says “at this step, the table for this dept exposes a SchemeSlot named X; whichever scheme the patient has determines which component renders”. The node carries no execution; it is a declaration that workflow-store + worklist-editor + `` host all agree on.
export interface SchemeSlotNodeData {
name: string;
slot: string; // 'discharge.modal' | 'orders.row-actions' | 'patient-header.tabs'
required: boolean; // if true, missing extension is an error not a no-op
fallbackComponent?: string; // module specifier, used when no scheme matches
}
5.6 Saved JSON fixtures — what the canvas writes
The editor saves nodes/edges into workflow_templates.nodes and workflow_templates.edges JSONB. The shape per node type:
// SchemeBranch — minimal saved form
{
"id": "scheme-branch-1",
"type": "workflowNode",
"position": { "x": 600, "y": 240 },
"data": {
"name": "Branch by scheme",
"nodeType": "scheme-branch",
"i18n": { "en": "Branch by scheme", "th": "แยกตามสิทธิ์", "ja": "保険別に分岐", "fil": "Hatiin sa scheme" },
"ui": { "color": "#0EA5E9", "icon": "call_split" },
"handles": [
{ "id": "philhealth", "label": { "en": "PhilHealth", "fil": "PhilHealth" }, "schemeIn": ["philhealth"] },
{ "id": "kaigo", "label": { "en": "Kaigo", "ja": "介護" }, "schemeIn": ["kaigo"] },
{ "id": "self_pay", "label": { "en": "Self-pay" }, "schemeIn": ["self_pay"] },
{ "id": "default", "label": { "en": "Default" }, "isDefault": true }
],
"evaluation": "first-match"
}
}
// Companion edge from SchemeBranch → "PhilHealth Z-Benefit Picker"
{
"id": "edge-1",
"source": "scheme-branch-1",
"sourceHandle": "philhealth",
"target": "ph-zbenefit-picker",
"type": "workflowEdge",
"data": {
"label": { "en": "PhilHealth", "fil": "PhilHealth" },
"condition": { "schemeIn": ["philhealth"] },
"priority": 10,
"rationale": "Z-Benefit case rates are PhilHealth-only."
}
}
// CoverageCheck — calls eligibility, has 5 fixed output handles
{
"id": "coverage-check-1",
"type": "workflowNode",
"position": { "x": 200, "y": 200 },
"data": {
"name": "Verify PhilHealth eligibility",
"nodeType": "coverage-check",
"i18n": { "en": "Verify Eligibility", "fil": "I-verify ang Eligibility" },
"ui": { "color": "#0EA5E9", "icon": "verified_user" },
"payerEndpoint": "philhealth",
"timeoutMs": 30000,
"onTimeout": "pass-as-pending",
"cacheTtlMinutes": 60,
"handles": ["eligible", "pending", "expired", "none", "error"]
}
}
// PreauthGate — pure pause, no output branching
{
"id": "preauth-gate-1",
"type": "workflowNode",
"position": { "x": 800, "y": 320 },
"data": {
"name": "Wait for MRI preauth",
"nodeType": "preauth-gate",
"i18n": { "en": "Wait for preauth", "th": "รออนุมัติประกัน" },
"ui": { "color": "#F59E0B", "icon": "lock_clock" },
"scopeMatch": { "productCategories": ["imaging"], "productIds": ["MRI-BRAIN-3T"] },
"bypassEntitlements": ["copay_waived"],
"affordance": "block"
}
}
// SchemeSlot — declarative; orchestrator does not branch on it.
// The frontend reads this node and mounts <SchemeSlot name="discharge.modal"/>.
{
"id": "scheme-slot-1",
"type": "workflowNode",
"position": { "x": 1100, "y": 400 },
"data": {
"name": "Discharge modal slot",
"nodeType": "scheme-slot",
"i18n": { "en": "Discharge UI", "th": "หน้าจอ Discharge" },
"ui": { "color": "#8B5CF6", "icon": "extension" },
"slot": "discharge.modal",
"required": false,
"fallbackComponent": null
}
}
These fixtures double as Jest snapshot tests (web/src/__fixtures__/scheme-nodes.json) so any future refactor that breaks the persisted shape fails CI.
5.7 Panel wireframes (Tailwind / Dify-style)
Per main-flow-editor/CLAUDE.md, panels are Tailwind, not MUI. ASCII sketches for the four new panels — one per node:
┌─ SchemeBranchPanel ───────────────────────────────────────┐
│ Name [Branch by scheme ] │
│ Evaluation ◉ first-match ○ all-match │
│ │
│ Output handles │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ⠿ philhealth PhilHealth [×] │ │
│ │ schemeIn: [philhealth] priority: 10 │ │
│ │ ⠿ kaigo Kaigo [×] │ │
│ │ schemeIn: [kaigo] priority: 10 │ │
│ │ ⠿ self_pay Self-pay [×] │ │
│ │ schemeIn: [self_pay] priority: 10 │ │
│ │ ⠿ default Default [✓] priority: 0 │ │
│ └──────────────────────────────────────────────────────┘ │
│ + Add handle │
│ │
│ ⚠ Validation │
│ • All scheme codes exist in benefit_schemes table │
│ • Exactly one isDefault: true │
│ • No duplicate handle ids │
└────────────────────────────────────────────────────────────┘
┌─ CoverageCheckPanel ──────────────────────────────────────┐
│ Name [Verify PhilHealth eligibility ] │
│ Payer endpoint [philhealth ▼] │
│ Timeout (ms) [30000] │
│ On timeout ◉ pass-as-pending ○ fail │
│ Cache eligibility for [60] minutes │
│ │
│ Output handles (fixed) │
│ ─ eligible ● │
│ ─ pending ● │
│ ─ expired ● │
│ ─ none ● │
│ ─ error ● │
└────────────────────────────────────────────────────────────┘
┌─ PreauthGatePanel ────────────────────────────────────────┐
│ Name [Wait for MRI preauth ] │
│ Match orders by │
│ ☑ Product category [imaging +] │
│ ☑ Product id [MRI-BRAIN-3T +] │
│ Bypass entitlements │
│ ☑ copay_waived │
│ Affordance ◉ block ○ warn ○ silent │
│ │
│ ℹ The actual modal is rendered by policy_gates eval at │
│ `perform_imaging` trigger. This node only enforces the │
│ pause; the message comes from the matching gate. │
└────────────────────────────────────────────────────────────┘
┌─ SchemeSlotPanel ─────────────────────────────────────────┐
│ Name [Discharge modal slot ] │
│ Slot [discharge.modal ▼] │
│ (10 known slots — see slot taxonomy) │
│ Required ☐ │
│ Fallback [None ▼] │
│ │
│ Registered extensions for this slot │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ • philhealth → PhilHealthDischargeBundle │ │
│ │ • kaigo → KaigoCarePlanCloseout │ │
│ │ • self_pay → (no extension — uses fallback) │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↻ Refresh registry │
└────────────────────────────────────────────────────────────┘
The “Registered extensions” list in SchemeSlotPanel is the canvas-side preview of findExtensions(slot, …) — it queries the in-memory registry (built from imported scheme packages) so admins see what will mount before the encounter even runs.
5.8 Save-time validation
useNodesInteractions (existing in main-flow-editor) is hooked to a new validateInsuranceNodes(nodes, edges) step that runs on save:
| Rule | Severity |
|---|---|
SchemeBranchNode has at least one isDefault: true handle |
error |
All handle.id values are unique within a SchemeBranchNode |
error |
All schemeIn/schemeNotIn codes exist in benefit_schemes (active) |
warning |
SchemeBranchNode has at least one outbound edge per handle |
warning |
CoverageCheckNode has exactly the five fixed output handles wired (or “error” handle is optional and falls back to “none”) |
error |
PreauthGateNode.scopeMatch has at least one of productIds / productCategories |
error |
SchemeSlotNode.slot is a member of the SlotName union |
error |
Edge data.condition is a valid EdgeCondition shape |
error |
Edge data.priority is an integer in [-100, 100] |
error |
When two edges from same source share the same sourceHandle, only one may be unconditioned |
warning |
Errors block save; warnings emit a toast and write to a template_validations audit table for follow-up.
5.9 Palette + registry plumbing
// nodes/constants.ts — add to NodeComponentMap and PanelComponentMap
import SchemeBranchNode from './scheme-branch/node';
import SchemeBranchPanel from './scheme-branch/panel';
import CoverageCheckNode from './coverage-check/node';
import CoverageCheckPanel from './coverage-check/panel';
import PreauthGateNode from './preauth-gate/node';
import PreauthGatePanel from './preauth-gate/panel';
import SchemeSlotNode from './scheme-slot/node';
import SchemeSlotPanel from './scheme-slot/panel';
export const NodeComponentMap: NodeComponentMap = {
// … existing …
[BlockEnum.SchemeBranch]: SchemeBranchNode,
[BlockEnum.CoverageCheck]: CoverageCheckNode,
[BlockEnum.PreauthGate]: PreauthGateNode,
[BlockEnum.SchemeSlot]: SchemeSlotNode,
};
export const PanelComponentMap: PanelComponentMap = {
// … existing …
[BlockEnum.SchemeBranch]: SchemeBranchPanel,
[BlockEnum.CoverageCheck]: CoverageCheckPanel,
[BlockEnum.PreauthGate]: PreauthGatePanel,
[BlockEnum.SchemeSlot]: SchemeSlotPanel,
};
The block-selector palette groups them under a new “Coverage” category, with a labeled divider (Patient Journey | Clinical | Coverage | Integrations | …).
6. Frontend — `` host + scheme registry
6.1 Slot taxonomy (start with 10 well-named slots)
Slot proliferation is the failure mode. Begin with these and add only on real demand:
| Slot name | Where it mounts | Default component when no scheme matches |
|---|---|---|
patient-header.tabs |
Patient banner tabs (registration, billing, etc.) | none — render nothing extra |
patient-header.badge |
Right side of patient banner | `` |
orders.row-actions |
Per-order action menu (lab, imaging, pharmacy, blood-bank, pathology) | none |
orders.before-save |
Modal that wraps order creation | none — direct save |
billing.before-checkout |
Cashier modal pre-checkout | none |
billing.line-item-decorator |
Render extra columns on bill lines | none |
discharge.modal |
Discharge confirm modal | `` |
discharge.tabs |
Tabs on discharge cockpit | none |
worklist.row-decorator |
Worklist row chip / colour stripe | none |
appointment.create.tabs |
Appointment creation modal extra tabs | none |
These ten cover every table the user mentioned. Adding an 11th slot needs a comment in this doc explaining why none of the existing ten fit.
6.2 Registry contract
// web/src/registries/scheme-extensions.ts
import { lazy } from 'react';
export type SlotName =
| 'patient-header.tabs' | 'patient-header.badge'
| 'orders.row-actions' | 'orders.before-save'
| 'billing.before-checkout' | 'billing.line-item-decorator'
| 'discharge.modal' | 'discharge.tabs'
| 'worklist.row-decorator' | 'appointment.create.tabs';
export interface SchemeExtension<P = unknown> {
slot: SlotName;
scheme: string; // 'philhealth', 'kaigo', etc. or '*' for default
component: React.LazyExoticComponent<React.ComponentType<P>>;
/** Higher wins when multiple extensions match. Default 0. */
priority?: number;
/** Optional finer match. */
match?: (ctx: InsuranceContext) => boolean;
/** Locale gate — only register in this market pack. */
market?: 'medos-philippines' | 'medos-japan' | 'medos-thailand' | 'all';
}
const _extensions: SchemeExtension[] = [];
export const registerExtension = (ext: SchemeExtension) => { _extensions.push(ext); };
export const findExtensions = (slot: SlotName, ctx: InsuranceContext) =>
_extensions
.filter(e => e.slot === slot)
.filter(e => e.scheme === '*' || ctx.benefitSchemeIds.includes(e.scheme))
.filter(e => !e.match || e.match(ctx))
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
6.3 The host component
// web/src/common/components/SchemeSlot.tsx
import { Suspense } from 'react';
import { useInsuranceContext } from '@/hooks/useInsuranceContext';
import { findExtensions, SlotName } from '@/registries/scheme-extensions';
export function SchemeSlot<P>({ name, fallback = null, ...props }: {
name: SlotName;
fallback?: React.ReactNode;
} & Record<string, unknown>) {
const ctx = useInsuranceContext();
if (!ctx) return <>{fallback}</>;
const matches = findExtensions(name, ctx);
if (matches.length === 0) return <>{fallback}</>;
return (
<Suspense fallback={null}>
{matches.map(({ component: Cmp, scheme }, i) => (
<Cmp key={`${name}-${scheme}-${i}`} {...(props as P)} />
))}
</Suspense>
);
}
6.4 useInsuranceContext hook
// web/src/hooks/useInsuranceContext.ts
// Reads the encounter's insurance_context column from encounter_journey_cache.
// SWR-cached per encounterId; auto-refreshes via Supabase realtime.
export function useInsuranceContext(): InsuranceContext | null {
const encounterId = useActiveEncounterId();
const { data } = useSWR(
encounterId ? ['ejc', encounterId, 'insurance_context'] : null,
() => supabase
.from('encounter_journey_cache')
.select('insurance_context')
.eq('encounter_id', encounterId)
.single()
.then(r => r.data?.insurance_context as InsuranceContext | null)
);
return data ?? null;
}
6.5 First scheme package (PhilHealth) — skeleton
web/packages/scheme-philhealth/
├── package.json -- "@scheme/philhealth"
├── src/
│ ├── index.ts -- registers all extensions on import
│ ├── PhilHealthEligibilityTab.tsx (slot: patient-header.tabs)
│ ├── PhilHealthCaseRatePicker.tsx (slot: orders.before-save)
│ ├── PhilHealthPreauthBadge.tsx (slot: patient-header.badge)
│ ├── PhilHealthDischargeBundle.tsx (slot: discharge.modal)
│ ├── PhilHealthClaimRowAction.tsx (slot: orders.row-actions)
│ └── shared/
│ ├── eligibility.api.ts
│ └── caseRate.lookup.ts
└── tsconfig.json
// web/packages/scheme-philhealth/src/index.ts
import { lazy } from 'react';
import { registerExtension } from '@/registries/scheme-extensions';
registerExtension({
slot: 'patient-header.tabs', scheme: 'philhealth', priority: 10,
component: lazy(() => import('./PhilHealthEligibilityTab')),
market: 'medos-philippines',
});
registerExtension({
slot: 'orders.before-save', scheme: 'philhealth',
component: lazy(() => import('./PhilHealthCaseRatePicker')),
});
registerExtension({
slot: 'patient-header.badge', scheme: 'philhealth',
component: lazy(() => import('./PhilHealthPreauthBadge')),
});
registerExtension({
slot: 'discharge.modal', scheme: 'philhealth',
component: lazy(() => import('./PhilHealthDischargeBundle')),
});
registerExtension({
slot: 'orders.row-actions', scheme: 'philhealth',
component: lazy(() => import('./PhilHealthClaimRowAction')),
});
The package is opt-in: it’s only imported when the active market pack is medos-philippines. We import it from web/src/setup/markets/index.ts based on VITE_MARKET_PACK. Same pattern for Kaigo, NHSO, etc.
6.6 Sample slot extension — PhilHealthCaseRatePicker.tsx
Concrete reference implementation for slot: 'orders.before-save'. Shows the contract every extension follows.
// web/packages/scheme-philhealth/src/PhilHealthCaseRatePicker.tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useInsuranceContext } from '@/hooks/useInsuranceContext';
import { lookupCaseRate, type CaseRate } from './shared/caseRate.lookup';
export interface OrdersBeforeSaveProps {
/** The order being saved. */
order: {
productId: string;
productCategory: string;
quantity: number;
};
/**
* Each extension may extend the patch returned to the order before save.
* Returning {} = no-op.
*/
onPatch: (patch: Record<string, unknown>) => void;
/** Cancels the save — extension chose to abort. */
onCancel: (reason: string) => void;
}
export default function PhilHealthCaseRatePicker({ order, onPatch, onCancel }: OrdersBeforeSaveProps) {
const { t } = useTranslation('scheme.philhealth');
const ctx = useInsuranceContext();
const [selected, setSelected] = useState<CaseRate | null>(null);
if (!ctx || !ctx.benefitSchemeIds.includes('philhealth')) return null;
if (ctx.coverageStatus !== 'eligible') return null;
const rates = lookupCaseRate({ productId: order.productId });
if (rates.length === 0) return null;
return (
<div className="rounded-lg border border-sky-200 bg-sky-50 p-4 space-y-2">
<h4 className="text-sm font-semibold text-sky-900">
{t('caseRate.title', 'PhilHealth Case Rate')}
</h4>
<select
className="w-full rounded border border-sky-300 px-2 py-1"
value={selected?.code ?? ''}
onChange={(e) => {
const next = rates.find(r => r.code === e.target.value) ?? null;
setSelected(next);
if (next) onPatch({ caseRateCode: next.code, caseRateAmount: next.amount });
}}
>
<option value="">— {t('caseRate.choose', 'Choose case rate')} —</option>
{rates.map(r => (
<option key={r.code} value={r.code}>
{r.code} — {r.title} ({r.amount} PHP)
</option>
))}
</select>
{!selected && (
<button
className="text-xs text-sky-700 underline"
onClick={() => onCancel('No case rate selected')}
>
{t('caseRate.skip', 'Skip — patient will pay out-of-pocket')}
</button>
)}
</div>
);
}
Three rules in this component every extension follows:
- Self-gate with
useInsuranceContext()→ returnnullif the scheme is not active. The host filters too, but defensive self-gating means an accidental cross-mount is a no-op rather than a crash. - Only emit through
onPatch/onCancel— never write directly to backend or read model. The host page composes the final save. - Accept being unmounted — when scheme changes mid-flow (rare, but possible — admin corrects the patient’s coverage), the component disappears. No effects must persist.
6.7 Market loader
// web/src/setup/markets/index.ts
const MARKET_PACK = import.meta.env.VITE_MARKET_PACK as
| 'medos-philippines'
| 'medos-japan'
| 'medos-thailand'
| undefined;
export async function loadMarketSchemePackages(): Promise<void> {
switch (MARKET_PACK) {
case 'medos-philippines':
await import('@scheme/philhealth');
// SSS, private etc. follow when packages exist.
break;
case 'medos-japan':
await import('@scheme/kaigo');
break;
case 'medos-thailand':
// Today TH has no scheme-specific UI extensions; baseline only.
break;
default:
console.warn('[market] no market pack — only baseline UI loads.');
}
}
Called once at app boot (src/App.tsx provider tree, before the router mounts). Side-effect imports trigger registerExtension({...}) inside each package; by the time any `` mounts, the registry is populated.
6.8 Recipe — adding the 11th slot
If a real product need calls for a slot not in the taxonomy:
- Justify in this doc — append a row to §6.1 with the rationale.
- Add the slot string to the
SlotNameunion (web/src/registries/scheme-extensions.ts). - Drop a `` host call at the new mount point.
- Add a
SchemeSlotNodeoption in main-flow-editor’sSchemeSlotPaneldropdown. - Update the docs section “Registered extensions for this slot” preview list.
Steps 1 and 5 are the discipline that prevents slot proliferation. A new slot without a doc rationale is a code review reject.
6.9 Hardcoded if (scheme === …) migration
After phase 4, sweep web/src for existing scheme branches and replace with ``. Candidates already known to exist:
# Quick inventory for the migration follow-up:
rg --type tsx --type ts "scheme\\s*===\\s*['\"](?:philhealth|kaigo|sss|nhso|self_pay|private)" web/src
rg --type tsx --type ts "schemeCode\\s*===\\s*['\"]" web/src
rg --type tsx --type ts "benefit_scheme_id\\s*===\\s*['\"]" web/src
Each result becomes a candidate slot. The principle: no scheme string literal in caller code. The caller asks for a slot; the registry decides what mounts.
7. Backend — orchestrator changes
The orchestrator is infrastructure/medbase/functions/encounter-orchestrator/index.ts. Three handlers change; one new helper is added.
7.1 New helper: buildInsuranceContext(payload, existing)
// Add near buildPatientContext (around index.ts:819)
function buildInsuranceContext(
p: Record<string, unknown>,
existing?: Record<string, unknown>
): Record<string, unknown> {
const ctx: Record<string, unknown> = { ...(existing ?? {}) };
// Direct fields
const schemeCode = pickString(p, ['schemeCode', 'scheme_code', 'scheme'])
?? readPath(p, 'coverage.scheme_code')
?? readPath(p, 'patient.benefit_scheme_id');
if (schemeCode) ctx.schemeCode = schemeCode;
// Coverage stack (multi)
const stack: string[] = [];
if (Array.isArray(p.coverages)) {
for (const c of p.coverages as Array<Record<string, unknown>>) {
const code = pickString(c, ['scheme_code', 'schemeCode']);
if (code) stack.push(code);
}
}
if (typeof schemeCode === 'string' && stack.length === 0) stack.push(schemeCode);
if (stack.length > 0) ctx.benefitSchemeIds = stack;
// Member identity
for (const k of ['memberId', 'memberStatus', 'planCode', 'payerId', 'payerName', 'tier']) {
const v = pickString(p, [k, snake(k)]);
if (v !== undefined) ctx[k] = v;
}
// Entitlements
const ents = readPath(p, 'coverage.entitlements');
if (Array.isArray(ents)) ctx.entitlements = ents.filter(e => typeof e === 'string');
// Coverage status
const status = pickString(p, ['coverageStatus', 'coverage_status']);
if (status) ctx.coverageStatus = status;
ctx.evaluatedAt = new Date().toISOString();
ctx.evaluatedBy = 'orchestrator@v1';
return ctx;
}
function pickString(o: Record<string, unknown>, keys: string[]): string | undefined {
for (const k of keys) if (typeof o[k] === 'string' && o[k]) return o[k] as string;
return undefined;
}
function snake(s: string): string { return s.replace(/[A-Z]/g, m => '_' + m.toLowerCase()); }
7.2 handleEncounterSeeded — populate insurance_context
Edit infrastructure/medbase/functions/encounter-orchestrator/index.ts around line 748–815:
const enrichedClinicalContext: Record<string, unknown> = { ...clinicalContext };
if (Object.keys(patientContext).length > 0) {
enrichedClinicalContext.patient_context = patientContext;
}
if (Object.keys(encounterContext).length > 0) {
enrichedClinicalContext.encounter_context = {
...((enrichedClinicalContext.encounter_context as Record<string, unknown>) || {}),
...encounterContext,
};
}
if (Object.keys(admissionContext).length > 0) {
enrichedClinicalContext.admission_context = admissionContext;
}
+ const insuranceContext = buildInsuranceContext(p);
await ensureCacheRow(db, evt.encounterId, extractPatientId(evt.payload, evt.patientId), {
currentPhysicalLocation,
pendingTickets,
completedTickets,
deliveryId: evt.deliveryId,
clinicalContext: enrichedClinicalContext,
activeAlerts,
financialSummary,
+ insuranceContext,
});
ensureCacheRow is updated to accept and write insurance_context (single new field on its options interface).
7.3 handleFinancialUpdated — refresh on coverage change
async function handleFinancialUpdated(evt: NormalizedEvent) {
const db = getMedbaseAdminClient();
const { encounterId, payload } = evt;
// … existing financial_summary merge …
+ // If financial.updated carries coverage info, refresh insurance_context.
+ if (payload.coverage || payload.scheme_code || payload.payer_id) {
+ const cache = await ensureCacheRow(db, encounterId, evt.patientId, {
+ deliveryId: evt.deliveryId,
+ });
+ const existing = isRecord(cache.insurance_context)
+ ? cache.insurance_context : {};
+ const merged = buildInsuranceContext(payload, existing);
+ await updateCache(db, encounterId,
+ { insurance_context: merged }, evt.deliveryId);
+ }
}
7.4 handleOrdersAcknowledged — stamp scheme into queue metadata
Edit around line 1808 (the metadata object):
queueRows.push({
deptType,
ticketId,
encounterId,
patientId,
status: 'WAITING',
priority,
eventRef: evt.deliveryId,
metadata: {
...orderMetadata,
workflowState: 'pending',
acknowledgedBy: userId || null,
acknowledgedAt,
+ // Coverage axis — propagated from cache so frontend doesn't re-query.
+ schemeCode: cacheInsurance?.schemeCode ?? null,
+ benefitSchemeIds: cacheInsurance?.benefitSchemeIds ?? [],
+ coverageStatus: cacheInsurance?.coverageStatus ?? null,
+ // Slot hint — empty unless a SchemeSlotNode is the current step.
+ schemeSlot: order.schemeSlot ?? null,
},
});
Where cacheInsurance is read once per encounter at the top of the handler:
// near line 1781
const cacheRow = await ensureCacheRow(db, encounterId, patientId, {
deliveryId: evt.deliveryId,
});
const cacheInsurance = isRecord(cacheRow.insurance_context)
? cacheRow.insurance_context as Record<string, any>
: null;
7.5 handleWorkflowStepCompleted — evaluate edge condition
When transitioning to the next node, choose the edge that matches insurance_context:
// Add helper (orchestrator-side mirror of frontend evaluator)
function evaluateEdgeCondition(
cond: EdgeCondition | undefined,
ctx: { benefitSchemeIds: string[]; entitlements: string[]; coverageStatus?: string }
): boolean {
if (!cond) return true; // unconditional
if ('schemeIn' in cond)
return cond.schemeIn.some(s => ctx.benefitSchemeIds.includes(s));
if ('schemeNotIn' in cond)
return !cond.schemeNotIn.some(s => ctx.benefitSchemeIds.includes(s));
if ('entitlementHas' in cond)
return ctx.entitlements.includes(cond.entitlementHas);
if ('entitlementNot' in cond)
return !ctx.entitlements.includes(cond.entitlementNot);
if ('coverageStatus' in cond)
return cond.coverageStatus.includes(ctx.coverageStatus as any);
if ('all' in cond) return cond.all.every(c => evaluateEdgeCondition(c, ctx));
if ('any' in cond) return cond.any.some(c => evaluateEdgeCondition(c, ctx));
if ('not' in cond) return !evaluateEdgeCondition(cond.not, ctx);
return false;
}
function pickNextEdge(
edges: WorkflowEdge[],
fromNode: string,
ctx: InsuranceContextLite
): WorkflowEdge | null {
const candidates = edges.filter(e => e.source === fromNode);
const matched = candidates
.filter(e => evaluateEdgeCondition(e.data?.condition, ctx))
.sort((a, b) => (b.data?.priority ?? 0) - (a.data?.priority ?? 0));
if (matched.length > 0 && matched[0].data?.condition) return matched[0];
// Fallback: the unconditioned (default) edge.
const fallback = candidates.find(e => !e.data?.condition);
return fallback ?? matched[0] ?? null;
}
handleWorkflowStepCompleted (line 3410) calls pickNextEdge when advancing.
7.6 No new event types
We deliberately reuse existing event types — nothing on the manifest changes. Insurance context is derived from the same payloads orchestrator already sees. The change is purely projection logic.
7.7 Template overlay merge
When resolve_workflow_template returns resolution = 'overlay', the orchestrator merges parent + child before walking. The merge is deterministic, applied at load time, and does not mutate either DB row:
function mergeOverlay(
parent: { nodes: WorkflowNode[]; edges: WorkflowEdge[] },
overlay: { nodes: WorkflowNode[]; edges: WorkflowEdge[] }
): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
// Nodes: child wins on shared id; new ids from child are appended.
const byId = new Map<string, WorkflowNode>();
for (const n of parent.nodes) byId.set(n.id, n);
for (const n of overlay.nodes) byId.set(n.id, n); // child overrides
const nodes = [...byId.values()];
// Edges: replace parent edges that share (source, target, sourceHandle).
const edgeKey = (e: WorkflowEdge) =>
`${e.source}::${e.target}::${e.sourceHandle ?? ''}`;
const byEdge = new Map<string, WorkflowEdge>();
for (const e of parent.edges) byEdge.set(edgeKey(e), e);
for (const e of overlay.edges) byEdge.set(edgeKey(e), e); // child overrides
const edges = [...byEdge.values()];
return { nodes, edges };
}
Cached per (parent_id, overlay_id) for 60s in-orchestrator (same TTL pattern as resolveTicketConfig at index.ts:3243).
7.8 Resolver — single entry point used by all walks
async function loadResolvedTemplate(
db: ReturnType<typeof getMedbaseAdminClient>,
entityType: string,
schemeCode: string | null
): Promise<{ templateId: string; nodes: WorkflowNode[]; edges: WorkflowEdge[]; resolution: string } | null> {
const { data } = await db.rpc('resolve_workflow_template', {
p_entity_type: entityType,
p_scheme_code: schemeCode,
});
const row = data?.[0];
if (!row) return null;
const { data: tpl } = await db
.from('workflow_templates')
.select('id, nodes, edges, scheme_overlay_of')
.eq('id', row.template_id)
.maybeSingle();
if (!tpl) return null;
if (row.resolution !== 'overlay' || !tpl.scheme_overlay_of) {
return {
templateId: tpl.id,
nodes: tpl.nodes ?? [],
edges: tpl.edges ?? [],
resolution: row.resolution,
};
}
const { data: parent } = await db
.from('workflow_templates')
.select('nodes, edges')
.eq('id', tpl.scheme_overlay_of)
.maybeSingle();
const merged = mergeOverlay(
{ nodes: parent?.nodes ?? [], edges: parent?.edges ?? [] },
{ nodes: tpl.nodes ?? [], edges: tpl.edges ?? [] },
);
return { templateId: tpl.id, ...merged, resolution: 'overlay' };
}
handleWorkflowStepCompleted calls this; so does handleEncounterSeeded when picking the initial template; so does handleOrdersAcknowledged if a schemeSlot hint was passed.
7.9 ensureCacheRow interface change
The existing helper takes an options bag. We add one optional field:
interface EnsureCacheRowOptions {
currentPhysicalLocation?: string;
pendingTickets?: Record<string, string>;
completedTickets?: Record<string, string>;
deliveryId?: string;
clinicalContext?: Record<string, unknown>;
activeAlerts?: unknown[];
financialSummary?: Record<string, unknown>;
insuranceContext?: Record<string, unknown>; // NEW
}
Inside ensureCacheRow, when constructing the upsert payload:
if (opts.insuranceContext && Object.keys(opts.insuranceContext).length > 0) {
upsertRow.insurance_context = opts.insuranceContext;
}
If the column does not exist (orchestrator deployed before migration 070 ran), the upsert silently fails on that key. To avoid hard failures, wrap in a feature gate that probes once at startup:
let _insuranceColumnPresent: boolean | null = null;
async function hasInsuranceColumn(db: ReturnType<typeof getMedbaseAdminClient>): Promise<boolean> {
if (_insuranceColumnPresent !== null) return _insuranceColumnPresent;
const { data, error } = await db
.from('information_schema.columns' as any)
.select('column_name')
.eq('table_schema', 'public')
.eq('table_name', 'encounter_journey_cache')
.eq('column_name', 'insurance_context')
.maybeSingle();
_insuranceColumnPresent = !error && !!data;
if (!_insuranceColumnPresent) {
console.warn('[insurance] insurance_context column missing — skipping coverage projection.');
}
return _insuranceColumnPresent;
}
buildInsuranceContext becomes a no-op when hasInsuranceColumn() === false. This keeps the orchestrator forward-compatible: it can ship before, with, or after the migration.
7.10 Error handling — coverage failures must not block clinical care
Every coverage call (eligibility, preauth lookup) is soft in the orchestrator:
| Failure | Behavior |
|---|---|
CoverageCheckNode payer endpoint times out |
Take pending handle (or error if no pending is wired). Never block the workflow. |
buildInsuranceContext throws |
Cache row gets insurance_context: { schemeCode: 'unknown', evaluatedBy: 'orchestrator@v1', error: <message> }. Frontend treats unknown like self_pay for branching, but renders an admin warning badge. |
mergeOverlay parent missing |
Fall back to overlay-as-standalone; log [overlay] orphan child template for the admin console. |
resolve_workflow_template returns no rows |
Fall back to compiled MASTER_WORKFLOW_NODES (existing behavior per 004_workflow_configs.sql comment). |
The principle: coverage projection is best-effort. Clinical care never waits on a payer’s API. The hospital can always issue a manual override on the discharge cockpit if insurance_context is unknown.
8. Edge condition DSL — full reference
8.1 Grammar
EdgeCondition := SchemeIn | SchemeNotIn
| EntitlementHas | EntitlementNot
| CoverageStatus
| All | Any | Not
SchemeIn := { schemeIn: string[] } // ⊂ benefitSchemeIds
SchemeNotIn := { schemeNotIn: string[] } // ⊄ benefitSchemeIds
EntitlementHas := { entitlementHas: string }
EntitlementNot := { entitlementNot: string }
CoverageStatus := { coverageStatus: ('eligible'|'pending'|'expired'|'none')[] }
All := { all: EdgeCondition[] } // AND
Any := { any: EdgeCondition[] } // OR
Not := { not: EdgeCondition }
8.2 Conflict resolution
When multiple edges match:
- Higher
data.prioritywins. Default 0. UI shows priority on the edge label when non-zero. - Tie → editor flags it as a validation warning at save time. The orchestrator falls back to lexicographic edge id; demo behavior is deterministic but admin should fix.
- No match → unconditioned (default) edge. Editor refuses to save a
SchemeBranchNodewithout a default edge; non-branch nodes can omit it.
8.3 Why a custom DSL and not JSONLogic / SQL / lambda?
- JSONLogic: too generic; needs documenting “what fields exist” anyway.
- SQL: edge eval happens in Deno orchestrator + frontend; both would need a SQL parser.
- Lambda: cannot be admin-edited safely.
- Reuse of
policy_gatespredicate shape: tempting, butpolicy_gatespredicates target individual fields (order.payment_status equals paid); edge conditions target the coverage axis only. Mixing would force admins to learn one schema for two distinct purposes. Keep them separate, share the AST shape (all/any/not), differ in primitives.
8.4 Examples
// "PhilHealth or Kaigo only"
{ "schemeIn": ["philhealth", "kaigo"] }
// "Has preauth requirement and coverage is eligible"
{ "all": [
{ "entitlementHas": "preauth_required" },
{ "coverageStatus": ["eligible"] }
] }
// "Anything except self-pay"
{ "not": { "schemeIn": ["self_pay"] } }
8.5 JSON Schema (machine-checkable)
{
"$id": "https://medos.local/schemas/edge-condition.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EdgeCondition",
"$defs": {
"EdgeCondition": {
"oneOf": [
{ "type": "object", "required": ["schemeIn"],
"properties": {
"schemeIn": { "type": "array", "items": { "type": "string" }, "minItems": 1 }
},
"additionalProperties": false
},
{ "type": "object", "required": ["schemeNotIn"],
"properties": {
"schemeNotIn": { "type": "array", "items": { "type": "string" }, "minItems": 1 }
},
"additionalProperties": false
},
{ "type": "object", "required": ["entitlementHas"],
"properties": { "entitlementHas": { "type": "string", "minLength": 1 } },
"additionalProperties": false
},
{ "type": "object", "required": ["entitlementNot"],
"properties": { "entitlementNot": { "type": "string", "minLength": 1 } },
"additionalProperties": false
},
{ "type": "object", "required": ["coverageStatus"],
"properties": {
"coverageStatus": {
"type": "array",
"items": { "enum": ["eligible", "pending", "expired", "none"] },
"minItems": 1
}
},
"additionalProperties": false
},
{ "type": "object", "required": ["all"],
"properties": { "all": { "type": "array", "items": { "$ref": "#/$defs/EdgeCondition" }, "minItems": 1 } },
"additionalProperties": false
},
{ "type": "object", "required": ["any"],
"properties": { "any": { "type": "array", "items": { "$ref": "#/$defs/EdgeCondition" }, "minItems": 1 } },
"additionalProperties": false
},
{ "type": "object", "required": ["not"],
"properties": { "not": { "$ref": "#/$defs/EdgeCondition" } },
"additionalProperties": false
}
]
}
},
"$ref": "#/$defs/EdgeCondition"
}
Stored at web/src/registries/edge-condition.schema.json and validated with Ajv at save time. Same schema is shipped to the orchestrator (Deno can run Ajv too) so the validation rule is identical on both sides.
8.6 Truth tables
Six representative InsuranceContext values × eight condition shapes. Use as Jest fixtures.
| Context | benefitSchemeIds | entitlements | coverageStatus |
|---|---|---|---|
| C1 | ["philhealth"] |
["preauth_required"] |
eligible |
| C2 | ["kaigo"] |
["ltc_certified"] |
eligible |
| C3 | ["self_pay"] |
[] |
none |
| C4 | ["philhealth", "private"] |
["preauth_required", "copay_waived"] |
pending |
| C5 | ["nhso"] |
["referral_required"] |
expired |
| C6 | [] |
[] |
none |
| Condition | C1 | C2 | C3 | C4 | C5 | C6 |
|---|---|---|---|---|---|---|
{schemeIn:["philhealth"]} |
✓ | – | – | ✓ | – | – |
{schemeIn:["philhealth","kaigo"]} |
✓ | ✓ | – | ✓ | – | – |
{schemeNotIn:["self_pay"]} |
✓ | ✓ | – | ✓ | ✓ | ✓ |
{entitlementHas:"preauth_required"} |
✓ | – | – | ✓ | – | – |
{coverageStatus:["eligible"]} |
✓ | ✓ | – | – | – | – |
{coverageStatus:["pending","expired"]} |
– | – | – | ✓ | ✓ | – |
{all:[{schemeIn:["philhealth"]},{coverageStatus:["eligible"]}]} |
✓ | – | – | – | – | – |
{not:{schemeIn:["self_pay"]}} |
✓ | ✓ | – | ✓ | ✓ | ✓ |
These rows ship as web/src/__fixtures__/edge-condition-truth.test.ts and as a Deno test under infrastructure/medbase/functions/encounter-orchestrator/_tests/.
8.7 Condition editor — UI wireframe
The edge gets a small editor panel in main-flow-editor when the user clicks an edge. Tailwind, Dify-style, mirrors the existing if-else editor:
┌─ Edge condition ───────────────────────────────────────────┐
│ When this edge fires: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ [Match ANY ▼] │ │
│ │ ├─ Scheme is one of [philhealth, kaigo +] │ │
│ │ ├─ Entitlement contains [preauth_required ▼] │ │
│ │ └─ Coverage status is [eligible ▼] │ │
│ │ + Add condition │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Priority [10] ◐ (higher wins on ties) │
│ Why [Z-Benefit case rates are PhilHealth-only. ] │
│ │
│ Preview against sample contexts │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ✓ C1 — PhilHealth eligible → fires │ │
│ │ ✓ C4 — PhilHealth+private pending → fires (any-match) │ │
│ │ ✗ C2 — Kaigo eligible → does not fire │ │
│ │ ✗ C3 — Self-pay none → does not fire │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The “Preview” panel runs evaluateEdgeCondition against the six fixture contexts in real-time so the admin sees immediately if the condition is over- or under-matching. Powered by the JS evaluator imported from @/utils/edge-condition-evaluator.ts — same module the runtime uses, no drift.
8.8 Storage shape — round-tripping via the if-else node
if-else already exists in BlockEnum with its own evaluator. We deliberately do not reuse it for coverage:
| Aspect | if-else |
edge condition |
|---|---|---|
| Targets | Any field in clinical context | Coverage axis only |
| Evaluator | Generic JS expression | Typed AST |
| Where it runs | Workflow runtime per node | Edge selection per transition |
| Author | Power user | Worklist admin |
If a flow needs both (“if patient is PhilHealth AND fasting”), the pattern is: SchemeBranch first, then IfElse second. Two simple nodes beat one polymorphic node.
9. Workflow-store + worklist-editor integration
9.1 Workflow-store stays a catalog
web/src/containers/workflow-store/index.tsx keeps its current responsibilities (browse, search, deploy templates from a marketplace catalog). We add a single column to each card:
- Coverage badges: small chips showing which schemes the template branches on (derived from
nodes[].type === 'scheme-branch'∪edges[].data.condition.schemeIn). - Overlay indicator: a thin “overlays {parentName}” line for templates with
scheme_overlay_of !== null.
9.2 Worklist-editor — the binding layer
web/src/common/components/medical/builder/worklist-editor/ is form-based, not a canvas. We add a Coverage tab alongside the existing Department / Role / Action / Webhook tabs:
┌────────────────────────────────────────────────────────────┐
│ Worklist for: Lab — Specimen Collection │
├────────────────────────────────────────────────────────────┤
│ Department Role Action Webhook ┃ Coverage ◀ NEW │
├────────────────────────────────────────────────────────────┤
│ Active scheme overlays for this dept: │
│ ☑ medos-philhealth — uses template "Lab/PH-Z-Benefit" │
│ ☑ medos-kaigo — uses template "Lab/Kaigo-LTC" │
│ ☐ self_pay — falls back to base template │
│ │
│ Default template: "Lab — Standard" │
│ + Add scheme override │
└────────────────────────────────────────────────────────────┘
This tab edits workflow_entity_defaults rows — one per (dept, scheme) tuple — by extending the existing table:
-- Migration: 072_workflow_entity_defaults_scheme.sql
ALTER TABLE public.workflow_entity_defaults
ADD COLUMN IF NOT EXISTS scheme_code TEXT NULL;
DROP INDEX IF EXISTS workflow_entity_defaults_pkey;
ALTER TABLE public.workflow_entity_defaults
DROP CONSTRAINT IF EXISTS workflow_entity_defaults_pkey;
ALTER TABLE public.workflow_entity_defaults
ADD CONSTRAINT workflow_entity_defaults_pkey
PRIMARY KEY (entity_type, COALESCE(scheme_code, ''));
A row with scheme_code = NULL is the default for that entity_type; rows with scheme_code set are scheme-specific overrides. The orchestrator’s template resolver picks the most specific match.
9.3 Drag-and-drop UX summary
- Drag-and-drop happens in main-flow-editor, not workflow-store, not worklist-editor.
- Workflow-store is the catalog where admins discover and deploy templates (“install the Kaigo-LTC overlay”).
- Worklist-editor is where admins bind a (dept, scheme) tuple to a template (“Lab + PhilHealth → Lab/PH-Z-Benefit”).
- main-flow-editor is the canvas where they author or edit the template — adding
SchemeBranch,CoverageCheck,PreauthGate,SchemeSlotnodes; drawing edges; tagging edges withcondition.
Three views, one underlying workflow_templates row.
10. Market-pack integration
Each market pack at infrastructure/market-packs/medos-{japan,philippines,thailand}/ already ships seed SQL and a manifest.json. We extend the manifest:
{
"name": "medos-philippines",
"locale": "fil",
"currency": "PHP",
"timezone": "Asia/Manila",
"schemes": [ // ← NEW
{
"code": "philhealth",
"label": { "en": "PhilHealth", "fil": "PhilHealth" },
"package": "@scheme/philhealth",
"templateOverlays": [
{ "entity": "lab", "templateId": "uuid-…" },
{ "entity": "discharge", "templateId": "uuid-…" }
]
},
{
"code": "self_pay",
"label": { "en": "Self-pay", "fil": "Bayad-Mismong" },
"package": null,
"templateOverlays": []
}
],
"facilityTypes": ["hospital", "nursing_home"]
}
When a clinic boots:
- The market loader reads
manifest.schemes[].packageand dynamically imports each one. - Each package’s
index.tsrunsregisterExtension({...})calls → `` host can find them. - A small
seed-insurance-context.sqlper market pack populatesbenefit_schemeslookup with localized labels.
This keeps the core repo neutral — adding a new payer is a new web/packages/scheme-X/ + a manifest edit; no edits to core templates, queues, or modal containers.
11. Test plan
| Layer | Test | Tool |
|---|---|---|
| DB migration | 070_…sql runs idempotently; backfill populates insurance_context.schemeCode; idx_ejc_insurance_scheme exists. |
Run twice in CI. |
| Orchestrator | handleEncounterSeeded populates insurance_context from a payload with PhilHealth coverage. |
Deno unit test fixtures already in repo for orchestrator. |
| Edge eval | evaluateEdgeCondition truth table for 7 condition types × 6 sample contexts. |
Deno test. |
| Frontend evaluator | Same truth table on the TS-side helper used by ``. | Jest. |
SchemeSlot rendering |
Mounts PhilHealth tab when ctx.benefitSchemeIds = ['philhealth']; mounts none otherwise. |
Jest + RTL. |
| Sandbox playwright | web/sandbox/targets/SchemeSlotDemo.tsx renders 3 schemes; playwright.sandbox.config.ts smokes each. |
pnpm e2e:sandbox. |
| Workflow-store catalog | Coverage badges show on templates with scheme branches. | Jest. |
| Worklist-editor coverage tab | Adding/removing scheme override updates workflow_entity_defaults and surfaces an “unsaved changes” toast. |
Jest. |
| policy_gates regression | Existing pathology + ER bypass + insurance-preauth seeds still fire correctly when benefit_scheme_ids scope is set. |
Existing policy-gate eval tests. |
| Smoke: real shell | Open OPD encounter, switch scheme on patient profile, confirm `` swaps. | playwright.fast.config.ts. |
A golden-path E2E for each seed market pack is the bar before flipping any scheme to production:
- PH: Create OPD encounter for PhilHealth member → eligibility tab visible → place imaging order → preauth gate fires → admin satisfies preauth → workflow advances → discharge bundle modal includes PhilHealth bundle.
- JP: Create IPD admission with Kaigo-certified senior → care-plan binder modal at admission → daily NPO summary → discharge to facility branch.
- TH: Self-pay flow; no scheme nodes fire; baseline path works (regression guard).
11.1 Sandbox targets — fast iteration
Per web/CLAUDE.md, the sandbox at web/sandbox/ is the fastest loop. Add these targets:
web/sandbox/targets/
├── SchemeSlotDemo.tsx // mounts <SchemeSlot/> with 6 fixture contexts
├── SchemeBranchNodeDemo.tsx // ReactFlow canvas with a single SchemeBranchNode
├── CoverageCheckPanelDemo.tsx
├── PreauthGatePanelDemo.tsx
├── EdgeConditionEditorDemo.tsx // standalone edge condition editor with preview
└── PhilHealthCaseRatePicker.tsx // imports the actual extension
Each target registers in sandbox/registry.ts. Smoke them with playwright.sandbox.config.ts — no auth, no full provider tree. Per-target spec:
// e2e/sandbox/scheme-slot.sandbox.spec.ts
test.describe('SchemeSlot', () => {
test('mounts PhilHealth tab when scheme=philhealth', async ({ page }) => {
await page.goto('http://localhost:5179/?target=SchemeSlotDemo&fixture=philhealth-eligible');
await expect(page.getByText('PhilHealth Case Rate')).toBeVisible();
});
test('renders nothing for self-pay context', async ({ page }) => {
await page.goto('http://localhost:5179/?target=SchemeSlotDemo&fixture=self-pay');
await expect(page.getByText('PhilHealth Case Rate')).toHaveCount(0);
});
});
The fixture= query param maps to one of C1–C6 in the truth table (§8.6).
11.2 Per-market golden-path scripts
Each golden path is a playwright.fast.config.ts spec that boots the real app with storageState. Specs live at e2e/markets/{ph,jp,th}/golden-path.spec.ts.
PH script (e2e/markets/ph/golden-path.spec.ts):
1. Login (storage state). Set VITE_MARKET_PACK=medos-philippines.
2. Open patient with PhilHealth coverage (seeded HN PH-DEMO-001).
3. Verify <SchemeSlot name="patient-header.tabs"/> renders "PhilHealth" tab.
4. Click PhilHealth tab → expect eligibility status "active".
5. Place MRI imaging order.
6. Save order — expect <SchemeSlot name="orders.before-save"/> shows case rate picker.
7. Skip case rate (out-of-pocket) → save proceeds.
8. Navigate to imaging worklist → expect order in WAITING with PhilHealth badge.
9. Acknowledge order → expect PreauthGate node holds; "preauth required" modal shows.
10. Mock preauth API success; advance.
11. Imaging completes → discharge cockpit shows PhilHealth bundle in <SchemeSlot name="discharge.modal"/>.
JP script (e2e/markets/jp/golden-path.spec.ts):
1. Login. Set VITE_MARKET_PACK=medos-japan.
2. Open admission for Kaigo-certified senior (seeded HN JP-DEMO-002).
3. Verify <SchemeSlot name="patient-header.badge"/> shows "介護" badge.
4. At admission, expect Kaigo care-plan binder modal (<SchemeSlot name="appointment.create.tabs"/>).
5. Daily NPO summary appears in IPD ward round (existing flow, unchanged).
6. Discharge → KaigoCarePlanCloseout component renders in discharge modal.
TH baseline script (e2e/markets/th/golden-path.spec.ts):
1. Login. Set VITE_MARKET_PACK=medos-thailand.
2. Open self-pay encounter.
3. Confirm NO scheme tab/badge mounts.
4. Place lab order, run through standard flow end-to-end.
5. Confirm discharge runs the default discharge cockpit.
The TH baseline is the regression guard — phases 0–5 must never break the do-nothing path.
11.3 Regression matrix
Tests that must continue to pass after each phase:
| Test suite | After phase 0 | After phase 1 | After phase 2 | After phase 3 | After phase 4 |
|---|---|---|---|---|---|
policy-gate.service.test.ts |
✓ | ✓ | ✓ | ✓ | ✓ |
workflow-template.service.test.ts |
✓ | ✓ | ✓ | ✓ | ✓ |
encounter-orchestrator/_tests/* |
✓ | ✓ + new buildInsuranceContext tests | ✓ | ✓ | ✓ |
playwright.smoke.config.ts (clinic shell) |
✓ | ✓ | ✓ | ✓ | ✓ |
e2e/markets/th/golden-path.spec.ts |
✓ (default behavior) | ✓ | ✓ | ✓ | ✓ |
e2e/markets/ph/golden-path.spec.ts |
n/a | n/a | n/a | partial — extensions present | full |
e2e/markets/jp/golden-path.spec.ts |
n/a | n/a | n/a | n/a | n/a (phase 5) |
11.4 Coverage-axis fuzz test
A small property-based test in Deno: generate random EdgeCondition ASTs and random InsuranceContext values, assert that evaluateEdgeCondition is total (never throws), is monotonic in all/any, and that not(not(c)) === c for all c. Built on Deno.test + a hand-rolled generator (no external deps).
11.5 Performance budget
| Operation | Budget (p95) | Measured by |
|---|---|---|
buildInsuranceContext(payload) |
< 1 ms | Deno bench in orchestrator |
evaluateEdgeCondition(condition, ctx) |
< 50 µs | Same |
mergeOverlay(parent, overlay) |
< 5 ms for 50 nodes / 80 edges | Same |
loadResolvedTemplate (cold) |
< 80 ms incl. RPC + cache write | Same |
| `` mount-to-paint | < 16 ms (one frame) | React DevTools profiler in sandbox |
useInsuranceContext() SWR resolve |
< 100 ms cold, < 1 ms warm | Sandbox target |
If any budget breaks twice in a week, the responsible code owner ships a fix or files a deferral RFC.
12. Rollout — five phases
| Phase | Scope | Reversible? |
|---|---|---|
| 0. Foundation | Migrations 070/071/072/073/074 on Supabase. Backfill runs additively. |
Yes — drop columns + functions. |
| 1. Orchestrator wiring | buildInsuranceContext + 3 handler edits + evaluateEdgeCondition + mergeOverlay + loadResolvedTemplate. Deploy encounter-orchestrator. |
Yes — revert function deploy. |
| 2. Editor primitives | Add 4 BlockEnum values + 4 node/panel pairs + edge condition editor. No template uses them yet. |
Yes — UI-only. |
| 3. First scheme package | web/packages/scheme-philhealth/ with the 5 extensions above. PH market pack manifest gets schemes[]. Market loader imports it. |
Yes — remove import in market loader. |
| 4. Bind on a real flow | Edit Lab template in main-flow-editor: add SchemeBranch between order-acknowledged and specimen-collection; route PhilHealth orders through PBEF check. Worklist-editor binds (Lab, philhealth) → new template. |
Yes — flip back to default template. |
| 5. Repeat per scheme | Kaigo, NHSO, SSS, private. Each is one new package + manifest edit. | N/A — additive. |
Each phase ships independently. Phase 0 alone gives observability (“which schemes are seen”) with zero behavior change.
12.1 Per-phase rollback procedure
| Phase | Rollback step | Side effects |
|---|---|---|
| 0 | DROP COLUMN insurance_context, DROP TABLE benefit_schemes, DROP FUNCTION resolve_workflow_template, DROP COLUMN scheme_overlay_of, DROP COLUMN scheme_codes, DROP COLUMN scheme_code (from workflow_entity_defaults) |
Any orchestrator probes via hasInsuranceColumn() will simply skip — no clinical disruption. |
| 1 | Re-deploy previous orchestrator function bundle. | None — phase 1 only writes derived data; reverting stops new writes, existing rows remain harmless. |
| 2 | Drop the 4 BlockEnum entries + NodeComponentMap/PanelComponentMap registrations. |
Any persisted nodes of these types render as the existing _generic-medical placeholder per the editor’s CLAUDE.md fallback rules. Edges still route by condition if orchestrator phase 1 is up. |
| 3 | Remove the dynamic import in web/src/setup/markets/index.ts. |
Slot extensions disappear; baseline UI mounts instead. |
| 4 | In worklist-editor, set the (Lab, philhealth) override back to “use default”. | Within seconds (Supabase realtime), all open clinic shells revert. |
| 5 | Same as phase 4, per scheme. | Same. |
12.2 Migration ordering & dependencies
Strict migration order — 070 is required before 071, etc.:
070_encounter_journey_cache_insurance_context.sql
│
├──> 071_workflow_template_scheme_overlay.sql
│ │
│ └──> 074_resolve_workflow_template.sql (function depends on 071 columns)
│
├──> 072_workflow_entity_defaults_scheme.sql (composite PK + scheme_code)
│ │
│ └──> 074 (function depends on 072 column)
│
└──> 073_benefit_schemes_lookup.sql (independent; can ship anytime)
Apply in numeric order. Migration 074 is the only one that fails non-idempotently if applied out of order — guard with pg_proc existence check on the function name and CREATE OR REPLACE.
12.3 Pre-flight checklist (before any phase ships to production)
Per web/CLAUDE.md “Integration verification rule”:
- [ ] Trace every prop end-to-end for the new `` host on the discharge cockpit.
- [ ] Confirm field-name compatibility:
insurance_context.schemeCodevs. legacyfinancial_summary.schemeCode— both work for read. - [ ] List every button/action in the worklist-editor coverage tab and confirm which API call it makes.
- [ ] Verify TypeScript interfaces for
InsuranceContextmatch the JSONB shape exactly. - [ ] Run idempotency check: replay each migration twice; assert row counts unchanged on second run.
- [ ] Run orchestrator hot-path benchmark:
handleEncounterSeededshould add < 5 ms p95 with insurance projection enabled. - [ ] Confirm PR has not modified
vite.config.ts,tsconfig.json, or any build config. - [ ] Confirm no files were deleted; only edits and new files.
13. Observability
13.1 Health metrics — SQL views for dashboards
-- Migration: 075_insurance_observability_views.sql
-- 1. Daily distinct schemes seen — confirms backfill + orchestrator coverage.
CREATE OR REPLACE VIEW public.v_insurance_scheme_daily AS
SELECT
DATE(updated_at) AS day,
COALESCE(insurance_context ->> 'schemeCode', 'unset') AS scheme_code,
COUNT(*) AS encounter_count
FROM public.encounter_journey_cache
WHERE updated_at >= NOW() - INTERVAL '30 days'
GROUP BY 1, 2
ORDER BY 1 DESC, 3 DESC;
-- 2. Coverage status mix per scheme — spot lapsed/expired surges.
CREATE OR REPLACE VIEW public.v_insurance_coverage_mix AS
SELECT
COALESCE(insurance_context ->> 'schemeCode', 'unset') AS scheme_code,
COALESCE(insurance_context ->> 'coverageStatus', 'unset') AS coverage_status,
COUNT(*) AS encounter_count,
MIN(updated_at) AS earliest,
MAX(updated_at) AS latest
FROM public.encounter_journey_cache
WHERE updated_at >= NOW() - INTERVAL '7 days'
GROUP BY 1, 2;
-- 3. Edge-fallback rate — when no condition matched, the default fired.
-- Requires that orchestrator emit an `edge_decisions` log table.
CREATE TABLE IF NOT EXISTS public.edge_decisions (
id BIGSERIAL PRIMARY KEY,
decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
encounter_id TEXT,
template_id UUID,
from_node TEXT NOT NULL,
edge_id TEXT NOT NULL,
scheme_code TEXT,
matched BOOLEAN NOT NULL, -- true = a conditioned edge matched; false = fallback to default
rationale TEXT
);
CREATE INDEX IF NOT EXISTS idx_edge_decisions_decided_at
ON public.edge_decisions (decided_at DESC);
CREATE INDEX IF NOT EXISTS idx_edge_decisions_template_node
ON public.edge_decisions (template_id, from_node);
CREATE OR REPLACE VIEW public.v_edge_fallback_rate AS
SELECT
template_id,
from_node,
scheme_code,
COUNT(*) FILTER (WHERE NOT matched) AS fallback_count,
COUNT(*) AS total_count,
ROUND(100.0 * COUNT(*) FILTER (WHERE NOT matched) / NULLIF(COUNT(*), 0), 2) AS fallback_pct
FROM public.edge_decisions
WHERE decided_at >= NOW() - INTERVAL '24 hours'
GROUP BY 1, 2, 3
HAVING COUNT(*) >= 10 -- ignore noise
ORDER BY fallback_pct DESC;
-- 4. Slot-extension hit count — regression detector for silently-missing UI.
CREATE TABLE IF NOT EXISTS public.slot_extension_hits (
id BIGSERIAL PRIMARY KEY,
hit_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
slot_name TEXT NOT NULL,
scheme_code TEXT,
package_id TEXT,
encounter_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_slot_extension_hits_hit_at
ON public.slot_extension_hits (hit_at DESC);
CREATE OR REPLACE VIEW public.v_slot_hit_rate AS
SELECT
slot_name,
scheme_code,
package_id,
COUNT(*) AS hits_24h,
COUNT(DISTINCT encounter_id) AS distinct_encounters
FROM public.slot_extension_hits
WHERE hit_at >= NOW() - INTERVAL '24 hours'
GROUP BY 1, 2, 3
ORDER BY 4 DESC;
edge_decisions is written by the orchestrator at every pickNextEdge() call. slot_extension_hits is written by `` via a debounced server beacon (one row per (encounter, slot, scheme) per minute — never per render). Both tables get a daily partition rotation if growth becomes an issue (defer to phase 5).
13.2 Grafana panels
A new Grafana folder Insurance & Workflow with five panels:
- Scheme volume — stacked area from
v_insurance_scheme_daily, last 30d. - Coverage status mix — stacked bar per scheme from
v_insurance_coverage_mix. - Edge fallback rate — table sorted by
fallback_pct DESCfromv_edge_fallback_rate. Rows with > 50 % flagged red. - Slot hit heatmap — heatmap of
(slot_name, scheme_code) → hits_24h. - Orchestrator p95 latency — already exists for the orchestrator; add a per-handler tag so
handleEncounterSeededis distinguishable from new buildInsuranceContext overhead.
13.3 Alerts
| Alert | Condition | Severity |
|---|---|---|
| Insurance projection broken | v_insurance_scheme_daily shows < 50 % of yesterday’s coverage. |
high |
| Edge fallback spike | Any (template_id, from_node, scheme_code) with fallback_pct > 50 % for 30 minutes. |
medium |
| Slot disappearance | Any previously-active (slot_name, scheme_code) drops to 0 hits in a 30-minute window during business hours. |
medium |
| Eligibility timeouts | handleCoverageCheckResult pending outcomes > 20 % of total in last hour. |
low |
| Overlay merge orphan | [overlay] orphan child template log message appears > 10 times in 5 minutes. |
medium |
13.4 Audit trail
insurance_context.evaluatedByrecords the orchestrator version (orchestrator@v1,orchestrator@v2). When the projection logic changes, bump the suffix in code so old cache rows are visibly older — useful in incident triage (“everything from v1 was wrong”).workflow_entity_defaults.updated_byrecords who flipped a (dept, scheme) binding.policy_gates.updated_byrecords who edited which gate.edge_decisionsis, in effect, the audit log for “why did this encounter route to branch X” — answers PSO/payer dispute questions.
13.5 Per-slot diagnostic — admin tool
A /super-admin/scheme-extensions page shows a live table of all registered extensions for the current market pack: slot, scheme, component module, package id, hits today. Clicking a row tails slot_extension_hits for that pair. Built on the existing super-admin page pattern (cron-jobs, policy-gates).
14. Open questions / deferred work
- Multi-coverage rules: a patient with both PhilHealth and a private supplement — which scheme wins on conflicting branches? Initial answer:
benefitSchemeIdsis ordered, first match wins; an admin can rearrange via patient profile. Document in v2. - Eligibility caching: how long to trust a positive eligibility? Per scheme; defaults in the
CoverageCheckNodeconfig (60 min for PhilHealth, 24 h for Kaigo). - Per-scheme keyboard shortcuts in the `` registry — likely yes, but defer until phase 4 reveals real demand.
- Event-driven recompute: today, orchestrator only refreshes
insurance_contextonencounter.seededandfinancial.updated. We may need a dedicatedmanifest.coverage.changedevent when a coverage record is edited mid-encounter. Decision deferred to phase 1 review. - Permission model for editing scheme overlays: who can author a scheme package? Same role as workflow template editors initially; market-pack maintainers in v2.
- i18n: scheme labels live in market-pack manifest; package components use the existing
useTranslation('workflow')namespace plus a per-package namespace (e.g.useTranslation('scheme.philhealth')). - Hardcoded
if (scheme === …)migration: do a sweep ofweb/srcafter phase 4 and replace with `` calls. Track in a follow-up doc. - MASTER_WORKFLOW_NODES coupling:
packages/medical-kit/src/medical-worklist/workflowState.tsdeclares a runtime state machine. We do not add scheme branches there yet — the database template is authoritative; the runtime SM only gets a thin “current scheme” passthrough.
15. Concrete file diff list
Implementation guide — these are the only files to touch in phase 0–3. Exact lines are anchors for diff PRs, not literal changes.
| File | Change |
|---|---|
infrastructure/medbase/migrations/070_encounter_journey_cache_insurance_context.sql |
NEW — column + index + backfill (§4.1). |
infrastructure/medbase/migrations/071_workflow_template_scheme_overlay.sql |
NEW — overlay column + scheme_codes (§4.3). |
infrastructure/medbase/migrations/072_workflow_entity_defaults_scheme.sql |
NEW — scheme_code column + composite PK (§9.2). |
infrastructure/medbase/functions/encounter-orchestrator/index.ts |
EDIT — buildInsuranceContext helper + handler edits at lines ~748, ~1774, ~3410, ~3500 (§7). ensureCacheRow/updateCache accept insurance_context. |
web/src/common/components/medical/builder/main-flow-editor/components/workflow/types.ts |
EDIT — add 4 BlockEnum entries (§5.1). |
web/src/common/components/medical/builder/main-flow-editor/components/workflow/nodes/scheme-branch/{node,panel}.tsx |
NEW (§5.2). |
…/nodes/coverage-check/{node,panel}.tsx |
NEW (§5.3). |
…/nodes/preauth-gate/{node,panel}.tsx |
NEW (§5.4). |
…/nodes/scheme-slot/{node,panel}.tsx |
NEW (§5.5). |
…/nodes/constants.ts |
EDIT — register 4 nodes in NodeComponentMap + PanelComponentMap. |
web/src/registries/scheme-extensions.ts |
NEW — registry contract (§6.2). |
web/src/common/components/SchemeSlot.tsx |
NEW — host component (§6.3). |
web/src/hooks/useInsuranceContext.ts |
NEW — read hook (§6.4). |
web/packages/scheme-philhealth/ |
NEW — first package skeleton (§6.5). |
web/src/setup/markets/index.ts |
EDIT — dynamic import based on VITE_MARKET_PACK. |
infrastructure/market-packs/medos-philippines/manifest.json |
EDIT — add schemes[] (§10). |
web/src/containers/workflow-store/index.tsx |
EDIT — coverage badges on cards (§9.1). |
web/src/common/components/medical/builder/worklist-editor/index.tsx |
EDIT — add Coverage tab (§9.2). |
web/src/common/components/medical/builder/worklist-editor/CoverageTab.tsx |
NEW. |
16. Worked example — PhilHealth Lab order, end to end
A concrete trace of every layer for one user story:
Scenario. Mrs. R, a PhilHealth member, walks into a PH clinic. Her CBC + Creatinine order needs to be placed by Dr. C, claimed against PhilHealth case rates, and her result follows the standard flow.
Actor timeline:
T0 Receptionist registers Mrs. R (already a returning patient — coverage on file).
T1 Doctor opens encounter, places CBC + Creatinine order.
T2 Lab acknowledges, draws specimen, runs panel.
T3 Result returns; doctor reviews; encounter closes; cashier settles.
Layer 1 — Encounter creation (T0)
Backend emits manifest.encounter.seeded with payload (excerpt):
{
"encounter_id": "enc-PH-2026-0501",
"patient_id": "pat-PH-MRS-R",
"patient": { "fullName": "Mrs. R", "hn": "PH-001234" },
"coverages": [
{ "scheme_code": "philhealth", "memberId": "12-345678901-2", "coverageStatus": "eligible" }
],
"encounter_context": { "clinicId": "OPD-MAIN", "schemeCode": "philhealth" },
"financial_summary": { "schemeCode": "philhealth", "currency": "PHP" }
}
encounter-orchestrator/index.ts → handleEncounterSeeded runs:
- Existing path: builds
clinical_context.patient_context,encounter_context,admission_context. Writes toencounter_journey_cache. - New (§7.2): calls
buildInsuranceContext(payload):{ "schemeCode": "philhealth", "benefitSchemeIds": ["philhealth"], "memberId": "12-345678901-2", "coverageStatus": "eligible", "evaluatedAt": "2026-05-08T07:12:33Z", "evaluatedBy": "orchestrator@v1" } ensureCacheRowwrites that toencounter_journey_cache.insurance_contextforenc-PH-2026-0501.
Layer 2 — Doctor opens patient (T1)
Frontend mounts the patient header; useInsuranceContext() SWR-reads the cache row; `` queries the registry:
findExtensions('patient-header.tabs', ctx)
// → [ { scheme: 'philhealth', component: PhilHealthEligibilityTab } ]
A “PhilHealth” tab appears. Clicking it shows membership and entitlements (the package’s PhilHealthEligibilityTab.tsx). Same patient header also mounts `` → PhilHealthPreauthBadge (cyan badge).
Layer 3 — Doctor places lab order (T1)
Doctor opens the order modal. The save button wraps order creation in ``. The PhilHealth package contributes PhilHealthCaseRatePicker (§6.6). Doctor either picks a case rate (PhilHealth-billed) or skips (out-of-pocket).
On save, the backend service:
- Persists the order (existing path).
- Emits
manifest.order.created. - The orchestrator’s
handleOrderCreatedruns (existing) — no insurance changes here.
Layer 4 — Lab acknowledges (T2)
Lab tech acknowledges. Backend emits manifest.queue.orders_acknowledged. The orchestrator’s modified handleOrdersAcknowledged (§7.4) reads the cache row first:
const cacheRow = await ensureCacheRow(db, encounterId, patientId, { deliveryId });
const cacheInsurance = cacheRow.insurance_context;
// → { schemeCode: 'philhealth', benefitSchemeIds: ['philhealth'], coverageStatus: 'eligible', … }
Then stamps it into the queue row metadata:
queueRows.push({
deptType: 'LAB',
ticketId: 'LAB-2026-05-08-0042',
// …
metadata: {
workflowState: 'pending',
acknowledgedBy: 'lab-tech-7',
acknowledgedAt: '2026-05-08T08:01:12Z',
schemeCode: 'philhealth',
benefitSchemeIds: ['philhealth'],
coverageStatus: 'eligible',
schemeSlot: null
}
});
The lab worklist UI (/worklist/lab/specimen-collection) is now subscribed to department_queues via realtime. When the row arrives, the `` host fires for the row context — and because metadata.benefitSchemeIds = ['philhealth'], the registry yields the PhilHealth color stripe + chip.
Layer 5 — Workflow advances (T2 → T3)
The Lab template (chosen by resolve_workflow_template('lab', 'philhealth')) is the PhilHealth overlay. After specimen collection, the workflow looks like:
[order-acknowledged] ──► [scheme-branch-1] ──philhealth──► [pbef-check]
\─default────► [skip]
[pbef-check]──► [specimen-collected]
[skip]──────► [specimen-collected]
▼
[in-process]
▼
[result-ready]
▼
[doctor-review]
When handleWorkflowStepCompleted is called for the order-acknowledged → next transition, the new pickNextEdge (§7.5) runs:
pickNextEdge(edges, 'order-acknowledged', { benefitSchemeIds: ['philhealth'], … });
// candidates: [edge to scheme-branch-1 (no condition)]
// → returns the unconditioned edge.
// Then advancing scheme-branch-1 itself:
pickNextEdge(edges, 'scheme-branch-1', ctx);
// candidates: [edge sourceHandle=philhealth condition={schemeIn:['philhealth']},
// edge sourceHandle=default no condition]
// matched: [philhealth] → returns it (priority 10).
edge_decisions gets a row:
INSERT INTO edge_decisions (encounter_id, template_id, from_node, edge_id, scheme_code, matched, rationale)
VALUES ('enc-PH-2026-0501', 'tpl-lab-ph', 'scheme-branch-1', 'edge-pbef', 'philhealth', true,
'Z-Benefit case rates are PhilHealth-only.');
Layer 6 — Result ready, doctor reviews (T3)
No insurance-specific behavior here. Standard flow. Same code path runs whether the patient is PhilHealth or self-pay — the scheme axis is orthogonal to the result-handling axis.
Layer 7 — Encounter closes, cashier settles (T3)
Backend emits manifest.encounter.closed. handleEncounterClosed runs (existing, unchanged). The cashier opens checkout. `` queries the registry:
findExtensions('billing.before-checkout', ctx)
// → [ { scheme: 'philhealth', component: PhilHealthCheckoutSummary } ] (if the package ships it)
// → [] today; falls back to default checkout.
Discharge cockpit mounts `` → PhilHealthDischargeBundle (§6.5). The bundle modal includes the PhilHealth claim form prepopulated from the orchestrator’s coverage data.
Layer 8 — Observability (immediately)
v_insurance_scheme_dailyincrements2026-05-08, philhealth, +1.slot_extension_hitshas 5 rows (patient-header.tabs/badge,orders.before-save,worklist.row-decorator,discharge.modal).edge_decisionshas 1 row forscheme-branch-1.- Grafana shows everything green; admin sleeps soundly.
What we did NOT touch:
- Mongo write paths (still authoritative).
- Manifest event names (no new events).
- Existing workflow_templates schema (we only added shapes inside JSONB and one new column for overlay parents).
- Any frontend page’s render logic above `` (no
if (scheme === 'philhealth')in any caller).
This trace exists end-to-end as a Playwright fast-config spec at e2e/markets/ph/golden-path.spec.ts (§11.2).
17. Glossary
- Scheme — a benefit program a patient is enrolled in (PhilHealth, Kaigo LTC, NHSO, private XYZ, self-pay).
- Coverage status — eligibility outcome: eligible / pending / expired / none.
- Entitlement — a tag on a coverage describing what it grants (e.g.
preauth_required,copay_waived,ltc_certified). - Preauth ref — a record returned by a payer authorizing a specific service (
{ id, scope, expiresAt }). - Slot — a named UI mount point in the frontend (
patient-header.tabs,discharge.modal, …) where scheme-specific extensions render. - Extension — a registered
(slot, scheme) → componentmapping shipped by aweb/packages/scheme-X/package. - Overlay — a
workflow_templatesrow that declaresscheme_overlay_ofof a parent and replaces specific node ids per scheme. - InsuranceContext — the canonical shape on
encounter_journey_cache.insurance_contextconsumed by the orchestrator, edge evaluator, and ``.
End of design draft. Next: review with workflow + orchestrator owners; if accepted, ship phase 0 (migrations 070–075) as a single PR, then proceed phase by phase.