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.
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:
data-activation-twin-go-live-master.md— the activation→twin path; this closes the cost/workforce half of GAP-1/GAP-4hospital-decision-twin.md— the twin; theworkforce/supply/reimbursementagents this calibratesever-sync-adapter-integration.md— theSyncHealthSummaryHMAC/de-id pattern this reuses verbatimmodular-multicountry-deployment.md— market packs / connector packs this slots intomenu-price-ledger.md—menu_items/billable_ledger, the native supply-cost source for native-derive
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 async_run/sync_eventso each ingest appears in the connector topology/liveness) — and the Manual path —OperationalFactsUploadat HDAP → Cost Capture (bilingual TH/EN, aggregate-only, fail-soft preview with no ingest URL). Native-derive remains P1 (needs themenu_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 safe —
workforce.nurse_fte.cost_per_case/contribution_marginneed 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 |
UI — ConnectorActivityPanel (@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/failingchip + 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_eventhash-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 statuses — live (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
- 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.
- Operational facts are aggregate-only — cost-center totals per period. Never a per-employee row, never an individual salary.
- PHI- and HR-safe: structured aggregates only; no identifiers, no free-text — the
FORBIDDEN_KEYSde-id guard fromSyncHealthSummaryapplies unchanged. - One canonical contract: connector, manual upload, and native-derive all produce the same
OperationalFactsSummary. No path-specific schema. - Every facts row carries a fidelity label (
connector/manual/derived) + source system, mirroringTwinAgent.dynamics. The twin surfaces it. - Fail-soft: twin readers fall back to seed when facts are absent (
isLive:false), never blank/crash — same contract asMETRIC_SOURCES. - 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.
- Idempotent upsert by
(cost_center, period_start, period_grain, source_system); effective-period-wins on overlap. - 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). - 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.jsonpulls 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.