medOS ultra

ERP/HRM Activation for the Twin

Thin read-only OperationalFacts contract + connectors + lite cost-capture form to calibrate the twin's workforce/supply/reimbursement agents.

16 min read diagramsUpdated 2026-06-04docs/architecture/operational-facts-erp-hrm-activation.md

Status: Design (not built) — 2026-06-04. Answers the user question: “Do we need to build a lite ERP/HRM that links with Odoo/SAP to support the twin? Do we need a lite version for small hospitals?”

One-line verdict: No to a lite ERP/HRM as a system of record. Yes to a thin canonical operational-facts contract + connectors (Odoo/SAP/local) + a lite manual cost-capture surface for hospitals that have no back-office at all. The twin needs cost facts, not a system that runs payroll.

Related docs:


0. The verdict (one paragraph)

A hospital twin that sees only clinical throughput + patient revenue is cost-blind — and labor is 50–60% of hospital opex. But the fix is not an ERP. The twin needs read-only aggregates (labor cost, FTE, supply cost, GL cost-center actuals per period), not a system that computes payslips, files tax, or posts journals. So we activate those facts through the exact connector/upload pattern the platform already ships for SyncHealthSummary, feed them into the same METRIC_SOURCES metric→RPC registry the twin already uses (migs 20260604d/20260604e), and thereby calibrate the already-existing-but-thin workforce, supply, and reimbursement agents (today workforce.ts literally hardcodes nurseRatio: 0.5). Building a “lite ERP/HRM” instead would explode multi-country payroll/tax/GL compliance across TH/PH/JP/CN/AU, force us to compete with mature products we’ll never match, and contradict the activation-not-rebuild architecture we just shipped. The one legitimate “lite” build is a manual cost-capture form for hospitals with no back-office — a data-entry surface feeding the same contract, not a system of record.


1. Why the twin needs this (the precise gap)

The twin already ships seven facet agents (web/packages/intelligence-kit/src/horus/twin/agents/*.ts), all flagged live: true. But per the master doc’s GAP-4, only demand + capacity emit real DES events; the rest are KPI projections off hardcoded ratios:

Agent Today What it lacks This doc provides
demand DES (Poisson arrivals) — calibrated
capacity DES (lognormal LOS + bed queue) — calibrated
workforce projection, nurseRatio: 0.5 hardcoded (workforce.ts:21) real FTE + labor cost + nurse ratio twin_metric_labor_cost, twin_metric_fte, twin_metric_nurse_ratio
supply projection, hardcoded ratios real supply/drug cost per case twin_metric_supply_cost
reimbursement revenue projection only (reimbursement.ts:26 “No events — pure projection”) the cost side → margin twin_metric_cost_per_case, twin_metric_contribution_margin
quality, compliance thin projections (out of scope here)

So the work is not a new subsystem — it is the data layer that turns three already-shipped projection agents into calibrated ones. Everything plugs into the existing METRIC_SOURCES registry in twin/readers/index.ts; no new twin engine code.


2. The core decision: activate, don’t rebuild

medOS owns clinical + revenue operations; the customer’s ERP owns back-office finance + HR. Integration happens at the boundary.

Domain System of record Decision
Patient billing / AR / RCM medOS (financial service) Keep — this is the hospital’s order-to-cash; never hand to Odoo/SAP
Clinical supply consumption medOS (inventory-orchestrator, menu_items) Keep — native-derive supply cost; push consumption out to ERP later
Staff scheduling / rostering medOS (TOR 2.2.2, in flight) Build native — clinical-adjacent; source of labor hours
Credentialing / licensing medOS-adjacent Build lite native — healthcare-specific; no ERP does it well
GL / financial accounting External ERP Connector (read facts)
AP / procurement / vendor mgmt External ERP Connector
Payroll / tax / core HR External HRM Connector
Fixed assets / capital External ERP Connector
 Customer back-office            Operational-Facts layer              Hospital Twin
 (Odoo / SAP / local / none)        (this doc)                       (already built)
        │                              │                                  │
   connector pull ──┐                  │                                  │
   manual CSV    ───┼──▶  OperationalFactsSummary  ──▶  twin_metric_*  ──▶ workforce / supply /
   native-derive ───┘    (one canonical contract)      (METRIC_SOURCES)   reimbursement agents
        │                  PHI- & HR-safe aggregates,    registry, migs    calibrate live
        │                  fidelity-labeled              20260604h/i       (else fail-soft → seed)
        ▼                              ▼                                  ▼
   raw stays in region        de-identified roll-up only          isLive badge + margin/ROI

3. Do we need a “lite” version? — the tiered answer

Yes — but “lite” means a cost-capture form, not a lite ERP. The distinction is the whole answer.

Tier Customer Path What we ship
0a Small hospital / nursing home / clinic with no back-office system Lite manual cost-capture OperationalFactsUpload screen in HDAP (reuse SyncHealthUpload): a finance clerk types monthly labor cost + supply cost + headcount per cost-center. Feeds the same contract. This is the “lite version.”
0b Tier-0 customer who will grow / wants real accounting Bundle Odoo Community Package + deploy Odoo as a market-pack option. We deploy, we don’t build.
0c Any Tier-0 (always-on) Native-derive medOS already knows supply consumption (inventory-orchestrator) + labor hours (roster). A derive job projects them; the hospital types one blended hourly rate to turn hours→cost. Twin lights up with near-zero entry.
1 SMB running / wanting Odoo Odoo connector erp-odoo.json connector pack → ingest-operational-facts edge fn
2 Enterprise group on SAP / domestic ERP SAP / local connector erp-sap-odata.json, hrm-generic.json, or quarterly CSV upload (air-gapped)

Why “lite” must be a form, never a system

The moment a native “lite HRM” has an employee + salary field, customers expect payslips → tax tables → social-security filing → per-country compliance across TH, PH, JP, CN, AU. A native “lite GL” invites “where’s my trial balance / AP aging.” Both are scope black holes and compliance liabilities. A “type your ward’s monthly labor cost” form has none of that — it is data entry feeding one read-only contract. That is the only lite build that is safe.

Punchline: Yes to a lite cost-capture surface; no to a lite ERP. Same answer for a 20-bed nursing home as for a 1,000-bed group — only the ingestion path changes (manual → connector), never the contract, and never a native system of record.


4. Canonical contract — OperationalFactsSummary

Sibling of SyncHealthSummary (web/packages/integration-kit/src/types/). Aggregate-only (cost-center totals per period) — never a person row, never an individual salary. Reuses the FORBIDDEN_KEYS de-identification guard.

// web/packages/integration-kit/src/types/operational-facts.types.ts
export interface OperationalFactsSummary {
  costCenter: { code: string; name?: string; type: 'ward'|'clinic'|'ancillary'|'overhead'|'facility' };
  period: { start: string; end: string; grain: 'day'|'week'|'month'|'quarter' };
  labor?:  { fteWorked?: number; headcount?: number; nurseFte?: number;
             cost?: number; overtimeCost?: number; agencyCost?: number };
  supply?: { cost?: number; drugCost?: number; implantCost?: number };
  overhead?: { allocated?: number; depreciation?: number };
  gl?:     { actualTotal?: number; budgetTotal?: number };
  currency: string;
  source: 'odoo'|'sap'|'local'|'manual'|'derived';
  fidelity: 'connector'|'manual'|'derived';     // mirrors TwinAgent.dynamics des/projection
  connectorId?: string;                          // coding_connectors(module='operational-facts')
}
// validateOperationalFacts(s) — reject any forbidden/PHI/identifier key, same guard as sync-health

Read-model table (migration 20260604h):

create table operational_facts (
  id uuid primary key default gen_random_uuid(),
  cost_center_code text not null,
  cost_center_type text not null check (cost_center_type in ('ward','clinic','ancillary','overhead','facility')),
  period_start date not null, period_end date not null,
  period_grain text not null check (period_grain in ('day','week','month','quarter')),
  fte_worked numeric, headcount int, nurse_fte numeric,          -- labor (aggregate only)
  labor_cost numeric, overtime_cost numeric, agency_cost numeric,
  supply_cost numeric, drug_cost numeric, implant_cost numeric,  -- supply
  allocated_overhead numeric, depreciation numeric,              -- overhead
  gl_actual_total numeric, gl_budget_total numeric,              -- gl
  currency text not null,
  source_system text not null check (source_system in ('odoo','sap','local','manual','derived')),
  fidelity text not null check (fidelity in ('connector','manual','derived')),
  connector_id text, row_hash text,                              -- tamper-evidence, mirrors sync_event
  ingested_at timestamptz not null default now(),
  unique (cost_center_code, period_start, period_grain, source_system)   -- idempotent upsert
);

5. Three ingestion paths → one contract

Path Tier Mechanism Reuses
Connector 1, 2 ingest-operational-facts edge fn: HMAC verify → validateOperationalFacts() → idempotent UPSERT exact shape of ingest-sync-health
Manual / CSV 0a OperationalFactsUpload screen in HDAP; clerk enters or uploads per-cost-center rows SyncHealthUpload air-gapped pattern
Native-derive 0c (always-on) derive-operational-facts edge fn: supply cost from menu_items/dispense events, labor hours from roster × a stored blended rate menu-price-ledger, roster (TOR 2.2.2)

All three write the same operational_facts row with a distinct fidelity label, so the twin can always show how it knows a number.

Shipped 2026-06-04: the Connector path — ingest-operational-facts (HMAC + validate + idempotent upsert, and it logs a sync_run/sync_event so each ingest appears in the connector topology/liveness) — and the Manual path — OperationalFactsUpload at HDAP → Cost Capture (bilingual TH/EN, aggregate-only, fail-soft preview with no ingest URL). Native-derive remains P1 (needs the menu_items/roster join).


6. Read model — extend the existing twin_metric_* registry

Add RPCs and register them in METRIC_SOURCES (twin/readers/index.ts) exactly like twin_metric_los_days (mig 20260604d). Migration 20260604i:

Metric key RPC Calibrates Status
workforce.nurse_fte twin_metric_nurse_fte workforce agent — calibrates LIVE (the agent already calls readers.scalar('workforce.nurse_fte')) ✅ P0
workforce.labor_cost.month twin_metric_labor_cost workforce agent (labor cost) ✅ registered; agent edit = P4
supply.cost.month twin_metric_supply_cost supply agent ✅ registered; agent edit = P4
workforce.nurse_ratio twin_metric_nurse_ratio replaces hardcoded nurseRatio: 0.5 ⏳ P4
finance.cost_per_case.month twin_metric_cost_per_case reimbursement agent (cost side) ⏳ P4 — needs throughput join
finance.contribution_margin.month twin_metric_contribution_margin revenue − cost (operational_facts × encounter_journey_cache) ⏳ P4 — needs revenue join

P0 wires the one metric that is both agent-consumed and dimensionally safeworkforce.nurse_fte. cost_per_case/contribution_margin need a throughput/revenue join, so they’re deferred to P4 rather than fed a wrong-dimension total.

Fail-soft is preserved: no client / no RPC / empty → seed (isLive:false), identical to the existing registry contract. A hospital with no facts loaded sees today’s behavior, unchanged.


7. Connector observability — audit log + liveness per external system

Requirement (user, 2026-06-04): for EACH external system — every ERP/HRM connector, the HIS sync adapter, every national connector — we need an audit log + a liveness check to see active data sync (à la Supabase Logs & Analytics).

This is already substrate, not new infra. Every connector run is a sync_run row and every step is a tamper-evident sync_event (hash-chain) — both from migration 20260604a, built generic via a direction discriminator (it already unifies the HIS adapter + migration jobs). Migration 20260604j adds two security_invoker views (so they respect sync_run’s tenant RLS):

View Answers Key columns
connector_liveness_v Is it alive? — one row per external system last_run_at, last_success_at, freshness_lag_seconds, runs_24h, fails_24h, error_rate_24h, records_24h, liveness (live/stale/idle/failing/never)
connector_recent_runs_v The audit log — the activity stream run rows + duration_seconds; drill into sync_event (by run_id) for the per-run hash-chain

UIConnectorActivityPanel (@integration-kit/components/ConnectorActivityPanel), a Supabase-Logs-style surface at HDAP → Connector Activity (/admin/hdap/connector-activity):

  • Liveness strip — a card per external system: status dot + live/stale/failing chip + source/direction/country + “last sync N ago” + 24h runs/fails/error-rate/records.
  • Activity stream — recent runs (time · status · connector · direction·action · loaded count); click a run →
  • Detail panel — full run metadata + its tamper-evident sync_event hash-chain (the audit trail).

Two views, one toggle at /admin/hdap/connector-activity: Topology (ConnectorTopologyFlow — a ReactFlow v11 hub-and-spoke map: inbound ERP/HRM ← medOS platform → outbound HIS adapter, edges animated when live + coloured by liveness) and Activity & liveness (the list/log panel). Fail-soft to a seed dataset (both render with no client at sandbox ?target=ConnectorActivity / ?target=ConnectorTopology) — same philosophy as the twin readers. Operational-facts connectors light this up for free: an ingest-operational-facts run writes sync_run/sync_event exactly like every other connector, so Odoo/SAP/HRIS sync appears in the same audit-log + liveness view as the HIS adapter. ✅ scaffolded + verified in sandbox (2026-06-04).


8. The only things we build native (clinical-adjacent)

Build Why it’s ours, not the ERP’s Status
Staff rostering Clinical-adjacent; medOS is system of record for who’s on shift → labor hours In flight (TOR 2.2.2)
Point-of-care supply consumption Consumption happens in clinical workflow → supply cost Exists (inventory-orchestrator)
Credentialing / licensing Healthcare-specific; no generic ERP does it well New, lite
Cost-allocation bridge Maps clinical activity → cost-centers; the genuinely valuable piece no ERP does for hospitals New
Lite supply purchase request The clinical request is ours; the PO / AP / payment is the ERP’s Optional

9. Connector & adapter packs (market-pack data, zero TypeScript)

Drop alongside au-medicare-eclipse.json and the neusoft/winning/bsoft/trakcare adapters:

infrastructure/market-packs/connector-packs/
  erp-odoo.json          # XML-RPC/REST: account.move.line, hr.employee headcount, stock.valuation
  erp-sap-odata.json     # OData/BAPI: cost-center actuals, FI-CO, HR mini-master
  hrm-generic.json       # CSV/SFTP fallback: cost-center × period × {labor_cost, fte}
infrastructure/market-packs/adapters/{odoo,sap}/   # source-schema field maps + crosswalks

✅ Shipped 2026-06-04: erp-odoo.json, erp-sap-odata.json, hrm-generic.json (declarative field-maps; field paths flagged as templates to verify per tenant — no fabricated codes). Adding a new ERP = a connector-pack JSON + a field-map, no code — same discipline as the source-HIS adapter packs.

Connector Store (shipped 2026-06-04). connector-catalog.ts inventories 48 connectors across 13 categories (interoperability, EHR/HIS, claims/payer, ERP/finance, HRM/payroll, lab/LIS, imaging/RIS-PACS, pharmacy, devices/IoT, terminology, messaging, identity/SSO, payment) with honest statuseslive (built in the repo: FHIR R4, HL7v2 MLLP, FHIR subscriptions, ever-sync-adapter) · available (pack/edge-fn exists: the HIS adapters, ERP/HRM packs, NHSO e-Claim/HIPData, crossmatch receiver, cash push, the 6 new packs) · planned (designed only) — each backed by an evidence pointer (no fabricated capabilities). The ConnectorStore component (app-marketplace UI: search + category/status filters + an install/configure detail drawer) renders it at HDAP → Connector Store (sandbox ?target=ConnectorStore). Six new packs ship the breadth across categories: erp-yonyou-cn, lis-hl7v2, ris-pacs-dicom, device-hl7-oru, identity-oidc, messaging-smtp — all templated, placeholders flagged. Adding a connector = one catalog entry (+ optionally a pack JSON); it then appears in the store, and once it logs a sync_run, in the topology/liveness too. Onboarding card (shipped 2026-06-05): a Miro-style 5-slide OnboardingCarousel (generic + reusable) auto-shows once on first open of the store (localStorage gate, replayable via a Tour button) — pure DOM/SVG heroes for connect → live-sync → activate → human sign-off; sandbox ?target=ConnectorOnboarding.


10. Invariants

  1. medOS is never the system of record for payroll, GL, AP, tax, or fixed assets. No native payslip, tax computation, trial balance, or AP aging — ever.
  2. Operational facts are aggregate-only — cost-center totals per period. Never a per-employee row, never an individual salary.
  3. PHI- and HR-safe: structured aggregates only; no identifiers, no free-text — the FORBIDDEN_KEYS de-id guard from SyncHealthSummary applies unchanged.
  4. One canonical contract: connector, manual upload, and native-derive all produce the same OperationalFactsSummary. No path-specific schema.
  5. Every facts row carries a fidelity label (connector/manual/derived) + source system, mirroring TwinAgent.dynamics. The twin surfaces it.
  6. Fail-soft: twin readers fall back to seed when facts are absent (isLive:false), never blank/crash — same contract as METRIC_SOURCES.
  7. Facts flow one-directional into the twin. medOS→ERP push (revenue, consumption, hours) is a separate, later, opt-in flow. The twin never writes to the ERP.
  8. Idempotent upsert by (cost_center, period_start, period_grain, source_system); effective-period-wins on overlap.
  9. Per-tenant feature flag + role gate (consistent with VITE_HOSPITAL_TWIN_ENABLED; a finance-role gate on the upload surface is the deferred hardening, like HDAP today).
  10. Native build is limited to clinical-adjacent operations (§8). Anything back-office is connector-or-manual only.

11. Rollout (P0–P6)

Phase Ships Result External dep
P0 Contract (operational-facts.types.ts) + operational_facts table (mig 20260604h) + twin_metric_nurse_fte/_labor_cost/_supply_cost in METRIC_SOURCES (mig 20260604i) + connector liveness/audit views (mig 20260604j) + ConnectorActivityPanel at HDAP → Connector Activity workforce.nurse_fte is agent-consumed → the workforce agent calibrates live once facts land; every connector’s sync shows in the audit-log + liveness surface None — verified in sandbox
P1 derive-operational-facts edge fn (supply from menu_items, hours from roster × stored rate) Facts without monthly typing None
P2 erp-odoo.json + ingest-operational-facts (HMAC) SMB live Odoo
P3 erp-sap-odata.json + hrm-generic.json + CSV fallback Enterprise + CN-domestic SAP / local
P4 Cost-allocation bridge + twin_metric_contribution_margin + replace workforce.ts nurseRatio: 0.5 with twin_metric_nurse_ratio Twin reasons staffing ROI (e.g. cardiac step-down)
P5 medOS→ERP push (AR→GL, consumption→AP, hours→payroll), opt-in, one-directional out Closes the loop out; not required for the twin
P6 Credentialing lite + supply purchase-request Clinical-adjacent natives

P0 + the write paths + the topology view are shipped and verified (sandbox ?target=ConnectorTopology / ConnectorActivity / OperationalFactsUpload, zero console errors warm). Shipped this round: ingest-operational-facts (logs a sync_run so ingests surface in the topology), the OperationalFactsUpload cost-capture form at HDAP → Cost Capture, the polished ReactFlow ConnectorTopologyFlow (MiniMap + Panels + smoothstep edges), and the erp-odoo/erp-sap-odata/hrm-generic connector packs. Remaining: derive-operational-facts (P1) + real ERP endpoints / licensed terminology. The workforce agent calibrates live the moment operational_facts has data — no agent code change.


12. Worked examples (one engine, three segments)

  • 🇯🇵 Small nursing home, no ERP (Tier 0a + 0c). A clerk types monthly 介護 staff cost + consumables per ward into OperationalFactsUpload; native-derive adds Kaigo-unit supply from dispense. Twin shows cost-per-resident-day and Kaigo-unit contribution margin per care-level (要介護1–5). No Odoo, no SAP, no payroll built — and the same screen seeds the demo.
  • 🇵🇭 District hospital on Odoo (Tier 1). erp-odoo.json pulls GL cost-center actuals + HR FTE monthly. Twin shows PhilHealth case-rate contribution margin per service line and flags wards where labor cost outruns the case rate.
  • 🇨🇳/🌏 Hospital group on a domestic ERP (Tier 2). No API access → quarterly CSV of cost-center actuals via the generic connector / air-gapped upload. Twin calibrates cost-per-case for the cardiac step-down staffing decision. Raw stays in region; only the de-identified aggregate rolls up — same residency stance as sync-health.

13. Non-goals — what we explicitly never build

Payroll engine · payslips · tax / social-security filing · General Ledger / journals / trial balance / financial statements · Accounts Payable / vendor-invoice matching / payment runs · procurement PO lifecycle / 3-way match (we do the clinical request; the ERP does the PO) · fixed-asset register / depreciation schedules · core-HR records / contracts / leave / benefits / performance / recruiting · per-employee compensation data of any kind in the read model.

If a customer needs these, they run an ERP/HRM (Odoo for SMB, SAP for enterprise, local elsewhere) and we activate the facts from it. That is the entire strategy.

Ask Anything