medOS ultra

Self-Service Workspace Composition

Design for user-composable clinical workspaces from a widget palette.

14 min read diagramsUpdated 2026-06-01docs/architecture/self-service-workspace-composition.md

Status: Draft for review — design only, nothing implemented. Date: 2026-05-29 Author: design pass (Claude) Related: miniapp-modal-override.md, department-command-center-tabs.md, web/AGENTS.md, web/public/docs/admin-config/ADMIN_WORKLIST_CONFIG_WORKFLOW_SOURCE.md


1. What this is

The ask, in the user’s words:

Can we extend admin-worklist config to support other workflow-store flows? Like IPD discharge. Also can we use it to dynamically allow for a new page / new route, save to database, show up as buttons / navigation — so a nurse can fully dynamically set up everything themselves (e.g. this screen for this laptop)?

Three distinct capabilities are bundled here:

  1. Flow coverage — make the existing worklist-config admin work for any workflow-store flow (discharge, and the rest), not just OPD/consult nodes.
  2. DB-driven pages + navigation — let an authorized user define a “page” and a nav button at runtime, persisted to the DB, with no code deploy.
  3. Per-workstation binding — “this screen for this laptop”: a given physical device boots into a chosen landing page and shows a chosen nav set.

All three are achievable. The whole design hinges on one reframe (Section 2). The rest of the doc is the data model, the runtime resolution, the phased rollout, and the guardrails.


2. The one decision that governs everything: compose, don’t author

There are two fundamentally different meanings of “let a nurse make a new page”:

Compose (recommended) Author (avoid for v1)
What the user does Arranges existing, already-built modules into a named page, pins it to a route + nav button + workstation Defines brand-new UI / fields / behavior that doesn’t exist in the bundle
Mechanism DB row → catch-all route → DynamicContentRenderer (already resolves 268 modules by string key) Runtime code-gen, a generic form/page-schema interpreter, or import() of arbitrary paths
Build/deploy needed? No Yes, or a large new interpreter
Risk Low — only wires together vetted, tested components High — this is the class of change that caused the “8,122 files destroyed” incident in web/CLAUDE.md; arbitrary FE-defined behavior is unauditable and can write anywhere

This doc designs the compose model only. A nurse can build “everything” out of the 268 existing modules and the existing workflow flows — which covers ~90% of “set it up themselves” — without ever introducing un-vetted code. Authoring genuinely new modules/fields stays a developer + deploy activity (and is explicitly out of scope, Section 11).

Concrete consequence of the mechanism: DynamicContentRenderer is a hardcoded switch over tabName (one case per DynamicCoreApp key), not a registry or glob. So “compose” = pick from keys that already have a case. Adding a new module still means adding a case in code. That switch boundary is the compose/author line, enforced by the architecture itself.


3. What exists today (verified)

3.1 Worklist-config (the admin surface to extend)

  • Route /admin/worklist-configworklist-editor.
  • Two config tables, two write paths:
    • Current path: workflow-action-config.service.ts → Supabase RPCs save_workflow_config / get_active_workflow_config → table workflow_action_configurations, keyed (department_id, subdepartment_id, workflow_id, node_id), JSONB configuration (actions, miniapps, menuActions, queueConfig), versioned + workflow_action_configuration_history.
    • Legacy path: writes rowActions straight into workflow_templates.workflow_data.nodes[].
    • Plus role_worklists keyed (org, dept, role, node) for per-role node visibility + table config.
  • Key fact: config already keys on workflow_id + node_id. It is not hardwired to OPD. Any workflow whose nodes are real worklist nodes can be targeted today.

3.2 Workflow-store (where flows like discharge live)

  • /workflow-store with phase tabs (registration, opd, ipd, discharge, cashier-pharmacy, coder).
  • Discharge is a real workflow_templates row, materialized + self-healed by DischargeFlowSection.tsx via dischargeFlowTemplate.ts (9 nodes: discharge → payments → account-close → medical-coding → claim-submission-by-scheme → wait-for-rep → claim-invoice-issue), now editable inline via the embedded ReactFlow editor.
  • Gap: those discharge nodes currently exist as canvas/visualization nodes. To be configurable worklist tabs they need to be runtime worklist nodes (Section 6).

3.3 Module rendering (the compose engine)

  • DynamicContentRenderer: mounts any of 268 DynamicCoreApp modules from a string key, injecting patientRef / encounterRef / patientContext. Already used by SplitLayoutModal for the miniapp-override system.
  • This is the existing, working seam for composed pages — no new renderer needed.

3.4 Routing & navigation (mostly static)

Concern Today Implication
Routes Static JSX in routes.tsx New routes need a catch-all, not per-page ``s
Nav menu 100% hardcoded menuData.tsx (horizontalMenuItems: IMenuItem[]) Need a DB merge layer over the static menu
“Not built yet” convention /no-page?pageAt=X placeholders already in the menu A DB nav entry can replace these by pointing at a composed page
Dormant prototype page-registry.ts + dynamic-routes.tsx + hospital-config.ts, never wired in ⚠️ Uses import(/* @vite-ignore */ runtimePath)fragile, won’t resolve reliably in a prod bundle. Treat as a cautionary reference, not the mechanism. Reuse its idea (registry → routes, kit-gating) but render via DynamicContentRenderer, not arbitrary dynamic import.

3.5 Per-device today

Only localStorage (NavigationSettingsProvider, hospital_config, theme/locale). No workstation identity, no backend sync. Net-new (Section 7) but small.


4. Architecture overview

Four layers. Layer A is Phase 1 (extend the existing admin). Layers B–D are the new self-service substrate. All four are config tables, distinct from the read models (encounter_journey_cache, department_queues) which the frontend must never write.

                       ┌─────────────────────────────────────────────┐
                       │  SCOPE PRECEDENCE (most specific wins)         │
                       │  workstation > role > department > org > global│
                       └─────────────────────────────────────────────┘
 Layer A  Flow coverage      worklist-config now targets ANY workflow-store flow
          (Phase 1)          (discharge nodes become real worklist nodes)
                                   │ keyed (dept, subdept, workflow_id, node_id) — already supported

 Layer B  Page composition   custom_pages: a named arrangement of existing modules
          (Phase 2)          rendered at  /w/:slug  →  CustomPageHost  →  DynamicContentRenderer

 Layer C  Navigation         nav_entries: DB nav merged OVER static menuData.tsx
          (Phase 2)          target = route:/path  OR  page:slug

 Layer D  Scope & device     workstations + workstation_layouts:
          (Phase 3)          "this laptop → this landing page → this nav set"

5. Data model (proposed)

All new tables are config tables (frontend may read/write via service+RPC, exactly like workflow_action_configurations). All carry a *_history companion + a save_* RPC for atomic versioned writes, mirroring the existing pattern. All scope columns are nullable; resolution precedence in Section 8.

5.1 custom_pages (Layer B)

Column Type Notes
id uuid PK
slug text unique URL segment → /w/:slug
title text display name
title_i18n jsonb { en, th, ... }bilingual required (CLAUDE.md rule 7)
composition jsonb layout spec, see below
organization_id / department_id / role text / uuid / text, nullable scope
requires_patient bool true if any slot module is patient-scoped (Section 8.3)
enabled bool
created_by / timestamps

composition JSONB:

{
  "layout": "single | tabs | grid",      // v1: keep to these three
  "slots": [
    {
      "moduleKey": "modules.LabResultsWidget", // MUST be a DynamicCoreApp key with a DCR case
      "title": "Lab Results",
      "title_i18n": { "en": "Lab Results", "th": "ผลแลป" },
      "span": 6,                               // grid only (12-col)
      "props": {}                              // static props merged into the module
    }
  ]
}

5.2 nav_entries (Layer C)

Column Type Notes
id uuid PK
parent_id uuid nullable nest under a section, or top-level
title / title_i18n text / jsonb bilingual
icon text MUI icon name or Iconify key (matches menuData convention)
target text route:/cashier?tab=ipd or page:my-ward-board (custom_pages slug)
sort_order int
organization_id / department_id / role / workstation_id nullable scope
enabled bool

5.3 workstations (Layer D)

Column Type Notes
id uuid PK
workstation_key text unique minted into localStorage on first boot (uuid)
friendly_name text “Triage Desk 2”, “Ward 4 Nurse Station”
organization_id / department_id nullable where the device lives
last_seen_at timestamptz heartbeat on boot
enabled bool

5.4 workstation_layouts (Layer D)

Column Type Notes
id uuid PK
workstation_id uuid FK → workstations
default_landing text route:/... or page:slug — where this laptop boots
nav_entry_ids uuid[] which nav entries show (or null = inherit dept/role)
locale / theme text nullable optional per-device overrides
timestamps

Distinction to keep loud: custom_pages, nav_entries, workstations, workstation_layouts are configuration, not clinical read models. Writing to them from the frontend is allowed (same as worklist-config). They never hold patient/encounter state.


6. Phase 1 — extend worklist-config to discharge (and any flow)

This is the small, low-risk win and reuses everything. The config layer already keys on workflow_id + node_id; the work is making discharge nodes into real worklist nodes so they show up and render.

Per the verified node-render contract, a workflow node becomes a runtime worklist tab (table + row actions) only when it has all of:

  1. An entry in MASTER_WORKFLOW_NODES (workflowState.ts) with fhir, queue.status, transitions, and an apiType (for workflowActionRouter.ts endpoint routing).
  2. Presence in the workflow JSON’s navigation.mainTabs[] (the worklist manifest) so the picker and tabs surface it.
  3. queueConfig.rowActions (default in JSON; overridable by worklist-config DB).

Steps:

  1. Register discharge nodes (payments, account-close, medical-coding, claim-submission-by-scheme, wait-for-rep, claim-invoice-issue) in MASTER_WORKFLOW_NODES with their apiType (financial/RCM endpoints) + queue.status. (Check first — some may already exist under RCM naming.)
  2. Add a discharge worklist manifest (or extend the discharge-pipeline workflow JSON) listing those nodes in navigation.mainTabs[] with baseline columns + rowActions.
  3. Worklist-config then works unchanged: select dept → select the discharge workflow_id → pick a node → configure actions / miniapp bindings, saved via save_workflow_config.
  4. Generalize the workflow picker in worklist-editor so it lists all workflow-store templates for the dept (it already reads workflow_templates), making every phase’s flow configurable by the same UI.

Deliverable: the discharge pipeline (and any other workflow-store flow) is configurable in the existing admin with no new infrastructure.

⚠️ web/AGENTS.md gate: this is workflow-sensitive. Before implementing, read the required docs (WORKFLOW_MANIFEST_WIRING_GUIDE.md + workflowState.ts SSOT — note the two master docs named in AGENTS.md don’t exist at the listed paths; use these). Backend stays write-truth; do not project transitions from the frontend.


7. Phase 2 — DB-driven pages + navigation

7.1 Pages: one catch-all route, render via DCR

  • Add one route to routes.tsx: /w/:slugCustomPageHost (inside ProtectedLayout).
  • CustomPageHost loads custom_pages by slug, reads composition, renders:
    • single → one module full-page,
    • tabs → a tab strip of modules (reuse existing tab UI),
    • grid → a 12-col BentoGrid-style layout.
  • Each slot mounts through DynamicContentRenderer with tabName = slot.moduleKey, passing patient/encounter context only when requires_patient (Section 8.3).
  • No import(/* @vite-ignore */). Everything resolves through the statically-bundled DCR switch — that’s what makes it safe and reliable.

7.2 Navigation: merge DB over static

  • New hook useNavigationMenu() returns mergeNav(horizontalMenuItems, dbNavEntries) where dbNavEntries are nav_entries filtered by current scope (org/dept/role/workstation). This mirrors the existing workflow-config-merger.ts base-over-DB pattern.
  • A nav entry’s target:
    • route:/existing/path → normal navigation (can finally replace /no-page?pageAt=… stubs),
    • page:slug/w/slug (a composed page).
  • Authoring UI: a new admin surface (/admin/workspace-composer) — page builder (pick modules → arrange → name → save) + nav editor (label, icon, target, scope) + history. Reuse Bento + existing form patterns.

8. Runtime resolution & precedence

8.1 Boot sequence (per device)

App boot
  → read workstation_key from localStorage (mint uuid if absent)
  → upsert workstations (friendly_name optional, last_seen_at=now)
  → load workstation_layouts for this workstation
       → if default_landing set → navigate there
  → build nav = mergeNav(static menuData, nav_entries scoped to {org,dept,role,workstation})
  → render

8.2 Scope precedence (most specific wins)

workstation  >  role  >  department  >  organization  >  global default

Same spirit as worklist-config’s base < dept < role merge — workstation is simply the new, most-specific layer. A nav entry / page / layout row with a workstation_id overrides the role-scoped one, which overrides dept, etc.

8.3 Patient-context constraint (important)

Many DynamicCoreApp modules expect a patient/encounter (they live inside the patient profile). A top-level page at /w/:slug has no patient unless one is chosen. Therefore:

  • The page builder tags each module as standalone-capable (worklists, dashboards, command centers) vs patient-scoped (MAR, nursing assessment, lab widget).
  • A page mixing patient-scoped modules must declare requires_patient = true and CustomPageHost renders a patient picker first (reuse the /sandbox/workspace patient-picker pattern).
  • v1 recommendation: top-level composed pages offer standalone-capable modules only; patient-scoped composition stays inside the patient profile (which already has its tab system).

9. Permissions model

Separate authoring from binding, so “a nurse sets up her own laptop” doesn’t require giving her global page-authoring rights:

Capability Suggested permission Who
Create/edit custom_pages + global nav_entries admin.workspace-composer admin / super-admin
Bind a layout to this workstation (pick landing + nav from already-allowed pages) workstation.self-config ward lead / charge nurse
View inherited all authenticated

Backend permission truth is unchanged: a composed page that mounts a module still hits the same backend APIs, which still enforce 403s. Composition cannot grant data access the user didn’t already have.


10. Guardrails & invariants

  1. Compose, never author — only DynamicCoreApp keys that already have a DCR case are selectable. No runtime code, no eval, no arbitrary import().
  2. Config tables ≠ read models — never write encounter_journey_cache / department_queues from these features.
  3. Backend stays write-truth for all workflow transitions (Phase 1). No frontend projection writes.
  4. One catch-all route, not N dynamic routes; the dormant import(@vite-ignore) prototype is not used.
  5. Bilingual labels on every page title + nav entry (title_i18n).
  6. Versioned + audited — every config table gets a *_history companion + save_* RPC (reuse workflow_action_configuration_history shape).
  7. Permission is backend-enforced — composition never widens data access.
  8. Scope precedence is fixed — workstation > role > dept > org > global, everywhere.
  9. Patient-scoped modules only render where a patient context exists.
  10. Workstation identity is a soft id (localStorage uuid) — losing it = device re-registers, no data loss; it is not an auth boundary.

11. Out of scope (explicitly)

  • Authoring brand-new modules, fields, or forms at runtime (needs deploy or a separate page-schema-interpreter project).
  • A general drag-resize dashboarding engine — v1 layouts are single | tabs | grid only.
  • Per-workstation auth / kiosk lockdown beyond the existing public kiosk routes.
  • Multi-tenancy changes (cloud tenancy is a separate cross-cutting migration, per menu-price-ledger.md D4).

12. Phased rollout

Phase Scope New migrations Risk
P1 Register discharge (+ other flow) nodes; generalize workflow picker in worklist-config none (data + JSON only) low — reuses existing admin
P2a custom_pages + CustomPageHost + /w/:slug route NNNN_custom_pages.sql (+ history + save_custom_page RPC) medium
P2b nav_entries + useNavigationMenu merge + /admin/workspace-composer NNNN_nav_entries.sql (+ history) medium
P3 workstations + workstation_layouts + boot sequence + workstation.self-config NNNN_workstations.sql low-medium

Each phase is independently shippable and demo-able. P1 alone satisfies the literal “extend worklist-config to IPD discharge” request.


13. Open questions for review

  1. Route namespace: /w/:slug acceptable, or prefer /workspace/:slug / /page/:slug? (Must not collide with the ~150 existing top-level paths.)
  2. Nurse self-config scope: should workstation.self-config let a nurse pick from any page she can view, or only from a dept-curated allow-list?
  3. Layout richness for v1: is single | tabs | grid enough, or is drag-resize needed day one? (Strongly recommend deferring drag-resize.)
  4. Workstation registration: silent auto-register on first boot, or require an admin to name/approve a device before it can hold a layout?
  5. Phase 1 node naming: do RCM/discharge nodes already exist in MASTER_WORKFLOW_NODES under other names (avoid duplicates)? Needs a grep pass before P1.
  6. Authoring surface: extend the existing /admin/worklist-config UI, or a new sibling /admin/workspace-composer? (Leaning new sibling to keep concerns clean.)
Ask Anything