Configurable Movement & Financial Gates
One pattern, three engines: clinic-policy forks, policy_gates action gates, facility-billing country cost rules.
One pattern, three engines, one schema. Admission routing (central Admission Center vs. ward self-admit), discharge gates (payment, coding, ward-ready), and country cost/claim rules (rounding, grace, day caps, midnight claim rules) are all the same thing: per-scope rule rows (ward → clinic → facility → country/scheme), resolved by priority, expressed as data — not hardcoded. The repo already standardized on
scope_json / predicate_json / action_json / priorityJSONB. This doc maps each need to the engine that already owns it, calls out the one real gap (midnight day-boundary), and — above all — guarantees the IPD work-list never breaks.Design, 2026-06-03. Companions:
policy-gates.md,universal-facility-stay-billing.md,ipd-billing-architecture.md,admission-to-ward-unified-contract.md,worklist-data-and-column-config-reconciliation.md.
0. HARD INVARIANT — /ipd/work-list always works, no matter the flow ⛑️
This is non-negotiable. Every design below is shaped to preserve it.
- Separate render path (verified).
/ipd/work-listrendersPatientInDormiroty(IpdRow) fed byuseIpdDashboard— it has zero references toWorkflowConfigProvider/ConfigDrivenTable/getColumnsForNode/policy_gates. The OPD column override (shipped) and the workflow-config column system cannot affect it. (grep-confirmed 2026-06-03.) - Fail-open data.
useIpdDashboardreads RESTlistAllAdmissionswithres?.rows || res || []and uses Supabaseipd_admissionsonly as a fallback census (.catch(() => {})). Missing/stale config or a dead read model degrades to an empty/safe list — never a crash. - Gates gate ACTIONS, never the LIST. A fired admission/discharge/payment gate disables a button and shows a reason. It must never blank, filter-to-empty, or crash the worklist rows. The list is a read surface; gates live on the buttons inside a row’s action menu / the discharge cockpit.
- Config is additive + fail-open to today’s behavior. Absent/invalid/unreachable config resolves to
the current default flow (central Admission Center required; today’s discharge; today’s pricing) —
exactly how
useClinicPoliciesalready returns all-flags-false on failure. No node is ever removed from the default path; new paths are added and gated. - No backend coupling to render. The worklist must render even if
policy_gates/facility_billing_rule/ orchestrator are unreachable. Gate evaluation is best-effort and, on error, opens (allows the action, logs) rather than blocking the screen.
Litmus test for any change below: “If this config row is missing, malformed, or the service is down, does
/ipd/work-liststill load and show rows?” The answer must be yes.
1. The one pattern → three engines
| Need | Engine | Trigger / shape | Scope keys | Status |
|---|---|---|---|---|
| Path fork (which nodes a ward sees) | useClinicPolicies + requiredPolicy |
boolean flag → node/tab visibility | location→sub_clinic→clinic→global | ✅ live (reads order_dispatch_policies, fail-open) |
| Discrete action gate (block/warn/override) | policy_gates |
trigger_action (admit_patient, discharge_patient, …) |
clinic_ids·facility_ids·department_ids·encounter_types·patient_classes·benefit_scheme_ids |
✅ live + seeded |
| Money math that varies by country | facility_billing_rule |
rounding·grace·day-cap·surcharge·scheme/package | country_code + facility/scheme/payor |
⚠️ designed, not shipped |
All three share the scope_json / predicate_json / action_json / priority resolution (most-specific +
highest-priority wins, empty array = “all”). Learn it once, apply it everywhere.
2. Admission routing — central Admission Center vs. ward self-admit (per ward)
Today: hardcoded single pipeline — every admission goes pending → Admission Center reserves bed →
ward admits. No fork. (/admission-center, admissionCenterApi, Admission.approvalStatus.)
Design (reuse only): one per-ward flag drives two layers off the same value:
- Layer 1 — path:
useClinicPoliciesderivesREQUIRE_ADMISSION_CENTER(+ complementaryWARD_SELF_ADMIT = !it, so the show-only-when-truerequiredPolicyengine needs no change). The admission workflow carries both nodes, each gated: “Send to Admission Center” (REQUIRE_ADMISSION_CENTER) and “Ward accepts / self-admit” (WARD_SELF_ADMIT). - Layer 2 — queue placement: the orchestrator / admission-create branches on the same flag — central admission-center queue (today) vs. straight to the ward’s accept/assign queue.
Storage: a sibling table mirroring order_dispatch_policies shape (scope_type/scope_id +
requires_admission_center + optional admission_center_target + priority/active). Default false-safe:
absent row → REQUIRE_ADMISSION_CENTER resolves to true (today’s flow) so nothing changes silently.
Optionally also an admit_patient policy_gate for an explicit approval block.
✅ SHIPPED (foundation, 2026-06-03):
- Table
infrastructure/medbase/migrations/20260603a_ward_admission_policies.sql(idempotent; seeds a globalrequires_admission_center=truedefault = today’s flow; commented self-admit example). - Resolver hook
web/src/hooks/useAdmissionRoutingPolicy.ts— an isolated read (its own table + query, separate fromuseClinicPolicies, so zero blast radius on OPD //ipd/work-list). Resolves location→sub_clinic→clinic→global, fail-open torequireAdmissionCenter=trueif the table is missing (migration not deployed) or the read fails. Returns{ requireAdmissionCenter, wardSelfAdmit, admissionCenterTarget }. Resolution unit-tested (8 cases: precedence, tie-break, null→default, target passthrough).
✅ WIRED (gated + country-dynamic, 2026-06-03) — design workflow wf_aca37a90 + adversarial gating proof:
- Per-row gate, NOT node-hiding. A design trace overturned the naive “hide a node via
requiredPolicy” plan: node-hiding is per-workstation but admission routing is per-row (each row = a different ward), and the admission surface (/ipd/admission-request,WorkflowConfigProvider useDefault defaultKey="admission") has no ward context at the provider. The real chokepoint isuseWorkflowConfigWorklist.getVisibleActionsForRow— it runs per row with the row in hand. The gate filters the two routing-fork actions there, guarded byisAdmissionConfig(config)(template_key === 'admission-request-default') so every other worklist is byte-identical. - The two fork actions (in
admission-request-workflow.json):reserve-bed/assign-bedcarryrequiredPolicy:"WARD_SELF_ADMIT"; a newsend-to-admission-centercarriesrequiredPolicy:"REQUIRE_ADMISSION_CENTER". Exactly one shows per row by the ward’s flag;cancel-*stay ungated (always reachable). Unit-tested (11 cases: TH central → Send-to-Center, PH self-admit → Reserve-Bed, mixed wards, fail-open central, mutual exclusivity). - Country-dynamic (DDD, data rows only): migration adds
country_code(TH|PH|JP|*); resolver does not branch on country in code. Market-pack seeds shipped:infrastructure/market-packs/medos-{thailand,philippines,japan}/seed-ward-admission-policies.sql(TH/JP centralized, PH decentralized). New country = drop a seed file, zero TypeScript. Scheme is not a routing dimension (one ward = one queue; scheme-specific gating belongs inpolicy_gates/admit_patient). - Shared pure resolver
resolveAdmissionPolicy(rows, ctx)+ per-rowuseWardAdmissionPolicyRows(fetch-all-once, cached, fail-open[]) — both inuseAdmissionRoutingPolicy.ts; precedence lives in one place.
✅ OQ#1 RESOLVED (2026-06-03, code-verified) — the ward id IS already projected; the gate is LIVE, not inert.
The earlier caveat (“orchestrator projects wardName but not a ward id”) was over-cautious. Verified chain:
Admission.create emits hospital_events { event_type:'ADMISSION_CREATED', payload:{ wardId, … } }
(services/administration/.../admission/admission.controller.mixin.ts:609,617) →
normalizeEventType('ADMISSION_CREATED') = 'manifest.admission.updated'
(infrastructure/medbase/functions/_shared/event-contract.ts:271) → handleAdmissionUpdated →
buildAdmissionContext extracts wardId (encounter-orchestrator/index.ts:1393,3074) → writes
clinical_context.admission_context.wardId (:3083). The gate reads exactly that via resolveRowWardId. The
wardId is the ward’s Mongo ObjectId, which matches ward_admission_policies.scope_id at sub_clinic scope. So a
seeded self-admit ward differentiates today (once the migration + seeds are applied). Caveats: an admission with
no chosen ward emits wardId='' → fail-open central (correct); admissions created via other paths (e.g.
createCaseAdmission) that don’t emit ADMISSION_CREATED with wardId likewise fail-open central until they do.
⏳ Remaining (operational + the actual routing side-effect): (1) deploy the migration + 3 market-pack seeds
(manual, per CLAUDE.md) — until then everything fail-opens to central = today’s flow; (2) NestJS
AdmissionRoutingPolicyService + orchestrator queue placement (central admission-center queue vs ward queue via
admission_center_target) so self-admit actually changes where the request lands, not just which button shows;
(3) the bespoke admission-request-workflow miniapp fork (today renders only cancel).
3. Discharge — a gate sequence, not one gate
Why payment-gate ≠ cost-summary-gate: they’re different steps, and the cost summary must run first (country-aware) to produce the balance the payment gate then checks.
discharge requested
├─ 1. COST SUMMARIZE facility_billing_rule (COUNTRY-AWARE) ← midnight/day rules live HERE
│ billable_ledger → resolve_menu_price() → evaluate_facility_billing_rules()
│ → close-encounter-billing → encounter.billClosed=true
├─ 2. PAYMENT GATE policy_gates: discharge_patient ← checks the number from step 1
│ outstanding_balance==0 / payment_status=paid else BLOCK
└─ 3. DISCHARGE ACTION free bed · admission=discharged · trg_sync_billing_queue
Already live (policy_gates): trigger_action: 'discharge_patient' is seeded
(20260425_policy_gates_discharge_seed.sql: “outstanding bills must be settled before discharge”),
scoped per encounter_type/ward/scheme/patient_class, enforced in adt-kit/src/discharge/Discharge.tsx
(blocks the button; supports block / warn / require_override). Demo VIP/insurance bypass rows exist.
Gate points (add as more policy_gates rules — no code per rule): payment-settled · coding-complete ·
medical-clearance · nursing-sign-off · ward-ready-for-billing tick. Each = one trigger_action + a
usePolicyGate(...) call on the relevant button.
Known gap (the smallest live win): the IPD discharge cockpit
(src/containers/ipd-discharge-cockpit/page.tsx) does not yet surface gate status — the comment says
“backend enforces; frontend should show.” Wiring usePolicyGate({ trigger:'discharge_patient' }) there +
a checklist row makes payment-before-discharge visible per ward/scheme. Per Invariant #3, this gates the
cockpit’s discharge button only — /ipd/work-list is untouched.
4. Country cost / claim rules + the midnight gap
facility_billing_rule (mirrors policy_gates schema verbatim, country_code per market-pack seed)
expresses rounding modes, grace periods, day caps, surcharges, scheme overrides, package replacements
as data rows. Worked examples in the doc: 🇹🇭 NHSO/SSO (30-min grace, hour rounding, off-hours surcharge),
🇵🇭 PhilHealth Z-Benefit (package SKU + 45-day room cap), 🇯🇵 Kaigo (per-unit × regional multiplier,
monthly co-pay cap). Status: designed, migrations pending (billable_ledger, resolve_menu_price(),
evaluate_facility_billing_rules(), close-encounter-billing edge fn = skeleton, encounter.billClosed =
not shipped).
The midnight claim rule = genuine new work, not config. Stay logs today compute elapsed minutes, not
calendar days. The token cum.facility_kind.day_count (billable days by midnight crossings) is in the
grammar spec but has no evaluation code. To express “bill per calendar day, 30-min grace, 45-day cap,
day rolls at 00:00 tenant-tz” you need:
- Calendar-day counting — implement
cum.facility_kind.day_count(midnight-crossing aware, tenant tz; the engine already hasstay.hour_of_dayin tenant tz). - (opt.)
override_day_boundaryaction — split a stay at midnight for true per-day re-billing. - (opt.) midnight summarizer — for countries that cut the claim daily (not only at discharge): a
cron_jobs-registry pg_cron job at 00:00 tenant-tz (the registry exists). - Approximation available now:
override_rounding{billing_unit:'day'}+override_grace_periodgets ~90% without the split — ship that first, add the day-boundary engine when a statutory scheme demands it.
5. Status map
| Piece | Status |
|---|---|
/ipd/work-list decoupled + fail-open |
✅ verified (Invariant #0) |
useClinicPolicies + requiredPolicy (path forks, fail-open) |
✅ live |
policy_gates engine + discharge_patient payment gate (seeded, enforced) |
✅ live |
policy_gates discharge-cockpit status surfacing |
✅ shipped 2026-06-03 — ipd-discharge-cockpit now calls usePolicyGate({trigger:'discharge_patient'}), blocks the account-close step + shows a gate banner (DischargeFinale blockedReason/warnReason). Fail-open; doctor/nurse clinical discharge unaffected |
| Admission-routing per-ward flag (central vs self-admit) | 🟡 foundation shipped (table 20260603a_ward_admission_policies.sql + isolated useAdmissionRoutingPolicy hook, fail-open, unit-tested); consumption (workflow nodes + orchestrator) + migration deploy pending |
facility_billing_rule + ledger + price resolver + close-billing |
⚠️ designed, migrations pending |
Midnight / calendar-day-boundary (day_count, day-split, midnight cron) |
❌ explicit gap (§4) |
encounter.billClosed flag |
❌ not shipped |
6. Rollout — smallest, safest first (each preserves Invariant #0)
- ✅ DONE (2026-06-03) — surfaced the live discharge payment gate in the IPD discharge cockpit
(
usePolicyGateinpage.tsx+blockedReason/warnReasonbanner inDischargeFinale). Pure additive; gates the account-close button only, not the list, not doctor/nurse discharge. Fail-open. - Admission-routing flag (§2): 🟡 foundation shipped (table + isolated
useAdmissionRoutingPolicyhook, fail-open, unit-tested). Remaining: deploy migration + 2 gated workflow nodes + orchestrator placement branch. Default-true = today’s flow throughout. - Ship
facility_billing_rule+ ledger +resolve_menu_price(the designed migrations) with rounding/grace/day-cap rules — covers ~90% of country cost summarization. - Midnight day-boundary engine (
day_count+ optional day-split + midnight cron) — only when a statutory scheme requires true calendar-day re-billing.
At every step: if the new config row is missing/malformed or its service is down, /ipd/work-list still
loads and shows rows, and the gated action fails open (allows + logs) rather than blocking the screen.