medOS ultra

Insurance-Aware Workflows

Coverage as a 4th orthogonal axis: insurance_context column, four ReactFlow node types, edge condition DSL, registry-driven scheme slots.

59 min read diagramsUpdated 2026-05-25docs/architecture/insurance-aware-workflows.md

Status: Design draft (2026-05-08) Branch: claude/review-insurance-worklist-config-0TEy1 Owners: 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_gates can already gate actions by benefit_scheme_ids, but the workflow itself, the queues, and the modals do not branch by scheme. We will add a single insurance_context column on encounter_journey_cache, four new ReactFlow node types in main-flow-editor, edge-level condition JSONB on workflow_templates.edges, three small additions to encounter-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:

  1. Admins drag a SchemeBranch node in the canvas and route per scheme.
  2. The orchestrator stamps insurance_context into every queue row.
  3. The frontend mounts the right modal/tab/badge on each table without per-page if.
  4. 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:

  1. A read-model column the orchestrator writes.
  2. An edge condition on workflow_templates.
  3. 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_context shape; no duplicate state.
  • Orchestrator is the only writer — frontend never writes to insurance_context (per web/CLAUDE.md rule “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: true handle.
  • No duplicate id.
  • All schemeIn codes exist in the benefit_schemes lookup.

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:

  1. Self-gate with useInsuranceContext() → return null if 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.
  2. Only emit through onPatch / onCancel — never write directly to backend or read model. The host page composes the final save.
  3. 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:

  1. Justify in this doc — append a row to §6.1 with the rationale.
  2. Add the slot string to the SlotName union (web/src/registries/scheme-extensions.ts).
  3. Drop a `` host call at the new mount point.
  4. Add a SchemeSlotNode option in main-flow-editor’s SchemeSlotPanel dropdown.
  5. 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:

  1. Higher data.priority wins. Default 0. UI shows priority on the edge label when non-zero.
  2. 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.
  3. No match → unconditioned (default) edge. Editor refuses to save a SchemeBranchNode without 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_gates predicate shape: tempting, but policy_gates predicates 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, SchemeSlot nodes; drawing edges; tagging edges with condition.

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:

  1. The market loader reads manifest.schemes[].package and dynamically imports each one.
  2. Each package’s index.ts runs registerExtension({...}) calls → `` host can find them.
  3. A small seed-insurance-context.sql per market pack populates benefit_schemes lookup 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.schemeCode vs. legacy financial_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 InsuranceContext match the JSONB shape exactly.
  • [ ] Run idempotency check: replay each migration twice; assert row counts unchanged on second run.
  • [ ] Run orchestrator hot-path benchmark: handleEncounterSeeded should 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:

  1. Scheme volume — stacked area from v_insurance_scheme_daily, last 30d.
  2. Coverage status mix — stacked bar per scheme from v_insurance_coverage_mix.
  3. Edge fallback rate — table sorted by fallback_pct DESC from v_edge_fallback_rate. Rows with > 50 % flagged red.
  4. Slot hit heatmap — heatmap of (slot_name, scheme_code) → hits_24h.
  5. Orchestrator p95 latency — already exists for the orchestrator; add a per-handler tag so handleEncounterSeeded is 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.evaluatedBy records 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_by records who flipped a (dept, scheme) binding.
  • policy_gates.updated_by records who edited which gate.
  • edge_decisions is, 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: benefitSchemeIds is 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 CoverageCheckNode config (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_context on encounter.seeded and financial.updated. We may need a dedicated manifest.coverage.changed event 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 of web/src after phase 4 and replace with `` calls. Track in a follow-up doc.
  • MASTER_WORKFLOW_NODES coupling: packages/medical-kit/src/medical-worklist/workflowState.ts declares 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.tshandleEncounterSeeded runs:

  1. Existing path: builds clinical_context.patient_context, encounter_context, admission_context. Writes to encounter_journey_cache.
  2. 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"
    }
    
  3. ensureCacheRow writes that to encounter_journey_cache.insurance_context for enc-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:

  1. Persists the order (existing path).
  2. Emits manifest.order.created.
  3. The orchestrator’s handleOrderCreated runs (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_daily increments 2026-05-08, philhealth, +1.
  • slot_extension_hits has 5 rows (patient-header.tabs/badge, orders.before-save, worklist.row-decorator, discharge.modal).
  • edge_decisions has 1 row for scheme-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) → component mapping shipped by a web/packages/scheme-X/ package.
  • Overlay — a workflow_templates row that declares scheme_overlay_of of a parent and replaces specific node ids per scheme.
  • InsuranceContext — the canonical shape on encounter_journey_cache.insurance_context consumed 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.

Ask Anything