Multi-Workspace Architecture
Table of Contents
Overview
ORE Studio implements workspace-level data isolation as a third, orthogonal layer on top of tenant and party isolation. While tenant isolation separates organisations from each other (see Multi-Tenancy Architecture) and party isolation separates business units within a tenant (see Multi-Party Architecture), workspace isolation separates data contexts within a party. This document describes how workspace context flows through the system and how it differs fundamentally from the security layers beneath it.
This is a companion document to projects/ores.iam.core/modeling/multi_tenancy.org
and projects/ores.refdata.core/modeling/multi_party.org.
Core Concepts
Workspace
A workspace is a named, isolated data context within OreStudio. It contains a complete or partial set of data — trades, market data, conventions, curve configs, pricing engine configs, today's market definitions — and can optionally inherit missing data from a single parent workspace.
Key properties:
- Isolated by default: data in workspace A is not visible to workspace B unless an explicit parent relationship links them.
- Layered (single-parent): a workspace may declare one parent workspace. Any data not present in the workspace is resolved from the parent, then the grandparent, and so on — the same model as Docker image layers. The resolution order is the chain from the current workspace to the root.
- Trade-scoped via portfolio: a workspace optionally declares a portfolio scope. Only trades in the scoped books are visible; all other inherited trades are hidden. A trade whitelist can further narrow the scope to specific trade IDs within those books.
- Non-trade data follows trades: conventions, market data, curve configs, and pricing engine configs are either stored locally (overriding the parent's version for the same natural key) or inherited transparently.
- Durable: workspaces are long-lived; not deleted when the session ends.
- Non-promotable directly: data cannot be directly promoted from a workspace to live. A deliberate copy operation is used instead.
Workspace vs Scenario
These two concepts must not be confused.
A workspace is a data context: a stable, named collection of trades and market data. It answers "what data are we working with?" It is long-lived; multiple users can share it; its data does not change unless someone explicitly edits or imports into it.
A scenario (in the OreStudio sense) is a computation: one execution of a report definition against a workspace. Scenarios are ephemeral — they produce results (NPVs, sensitivities, PFE profiles) but do not change the workspace's data.
The common risk-system usage of "scenario" (a set of market data shocks) maps to OreStudio as a child workspace that inherits from a parent and overrides specific curves or rates. The shocks live in the workspace; the scenario is the report execution that prices the trades against those shocks.
Live Workspace — Dedicated Sentinel UUID
The Live workspace uses the well-known sentinel UUID
aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa (returned by
ores_utility_live_workspace_id_fn()) as its stable identifier. It always
exists, is the root of all inheritance chains, and cannot be archived without
workspace::live_workspace:archive (SuperAdmin only).
The nil UUID (00000000-0000-0000-0000-000000000000) is deliberately not
used as the sentinel: it is the default C++ value for an uninitialised
boost::uuids::uuid, so using it for Live would cause an unset workspace_id
to silently resolve to the Live data space. The sentinel aaaaaaaa-... has
version nibble a (decimal 10), which falls outside the valid UUID version
range 1–8; no UUID generator will ever emit it.
Live workspace ownership — per-tenant, system party
Every tenant has exactly one Live workspace row in ores_workspaces_tbl. All
of these rows share the well-known sentinel UUID
(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) but each belongs to its own tenant and
to that tenant's system party. This design allows the workspace entity to
behave as a standard party-scoped entity (tenant_id + party_id always present,
list_active returns the Live row) while preserving the sentinel UUID contract
for workspace-aware FK validation and resolution chains.
Seeding:
- The system tenant Live row is seeded once at DB initialisation by
workspace_live_populate.sql, which resolves the system tenant ID viaores_utility_system_tenant_id_fn()and the system party ID viaores_iam_account_parties_system_party_id_fn(). - Every operational tenant Live row is seeded inside
ores_iam_provision_tenant_fnimmediately after the system party for that tenant is created.
Consequences for the database layer:
- The primary key is
(tenant_id, id, valid_from, valid_to)and the gist exclusion constraint is scoped to(tenant_id, id)so that two tenants can each hold a Live row with the same UUID without conflicting. - The uniqueness indexes
workspaces_id_uniq_idxandworkspaces_version_uniq_idxare similarly scoped to(tenant_id, id). ores_workspace_resolution_order_fntakes a second parameterp_tenant_idand filters all CTE rows bytenant_id, so the chain for tenant A never crosses into tenant B's Live row.ores_workspace_validate_fn(used by workspace-aware data table triggers to validate aworkspace_idFK) short-circuits unconditionally for the Live sentinel UUID; it receives no tenant context from triggers.list_activefor any tenant returns that tenant's Live row (the sentinel UUID is in the result set), so the Qt workspace dialog displays it normally.
Workspace Hierarchy
Workspaces form a tree via parent_workspace_id. The Live workspace is always
the root. Resolution follows the chain from the active workspace to the root:
workspace 0 (Live)
└─ workspace 5 (EUR shock +50bps) parent = Live
└─ workspace 8 (EUR+credit shock) parent = 5
Resolution order for workspace 8: [8, 5, Live]
- Data in workspace 8 wins.
- Data absent from 8 falls through to workspace 5.
- Data absent from 5 falls through to Live.
Cycle prevention is enforced by a trigger that walks the parent chain before
any insert or update of parent_workspace_id.
Isolation Layers
Workspace isolation is orthogonal to, and does not replace, tenant and party isolation. All three layers are enforced simultaneously:
┌──────────────────────────────────────────────────────────────────────┐ │ PostgreSQL Database │ │ │ │ ┌────────────────────────────────────────────────────────────────┐ │ │ │ Tenant RLS Layer (app.current_tenant_id) │ │ │ │ Isolates organisations from each other │ │ │ │ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ │ │ Party RLS Layer (app.visible_party_ids) │ │ │ │ │ │ Isolates business units within a tenant │ │ │ │ │ │ │ │ │ │ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ Workspace Query Layer (workspace_id column) │ │ │ │ │ │ │ │ Scopes data to the active data context │ │ │ │ │ │ │ │ Applied via WHERE, not RLS │ │ │ │ │ │ │ │ Resolution order enables inheritance │ │ │ │ │ │ │ └────────────────────────────────────────────────────┘ │ │ │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────┘
Why workspace_id is Not in RLS
Tenant and party isolation are security boundaries: a user must never see another tenant's data, and a user must only see the party hierarchy they belong to. These are enforced at the RLS layer using session variables.
Workspace isolation is a data context boundary, not a security boundary. A user legitimately has read access to data in any workspace within their tenant and party scope — they want to view it scoped to a particular context. More importantly, the workspace inheritance model requires querying across multiple workspace IDs simultaneously:
WHERE workspace_id = ANY(:resolution_order) ORDER BY array_position(:resolution_order, workspace_id)
If workspace_id were baked into RLS as
workspace_id = current_setting('app.current_workspace_id'), this
inheritance query would break — you cannot resolve a parent chain through a
policy that restricts rows to exactly one workspace.
The correct split is:
| Concern | Mechanism | Handles |
|---|---|---|
| Tenant security | RLS | Cross-organisation data leakage |
| Party security | RLS | Cross-business-unit data leakage |
| Data context | WHERE clause | Workspace scoping + inheritance |
Workspace Visibility
Workspaces are private to the party that owns them. A user in party A cannot
see or use workspaces belonging to party B, even when both parties are within
the same tenant. This is enforced at the data layer: ores_workspaces_tbl
carries tenant_id and party_id columns and all service-layer queries filter
by both.
Within a party, all authenticated users hold workspace::workspaces:read by
default. Workspaces are not further restricted by per-user ACLs; any user in
the party can see any workspace owned by that party. Data restriction within
a workspace is handled by the scope_portfolio_id on the workspace itself
(portfolio tree scoping), not by per-workspace read grants. This keeps the
permission model simple.
Data Model
workspace_id Column
Every table that holds user or import data gains a workspace_id column:
alter table <table_name> add column workspace_id uuid not null default ores_utility_live_workspace_id_fn(); create index on <table_name> (workspace_id);
Tables that are genuinely global — users, IAM, DQ FSM definitions, workflow
definitions — do not get workspace_id. Neither does the workspace table
itself, but for a different and independent reason: a workspace cannot belong
to a workspace (self-referential membership is undefined). That exception
does not extend to tenant_id or party_id: the workspace entity is
party-scoped like any other business entity and carries both columns.
Affected categories:
| Category | Representative tables |
|---|---|
| Trading | trades, books, instruments, lifecycle events |
| Reference data | conventions (all types), book, portfolio |
| Market data | fixings, yield curves, vol surfaces |
| Curve config | yield curve configs, vol configs |
| Pricing engine | engine configs |
| Today's market | today's market definitions |
| Reporting | report definitions |
ores_workspaces_tbl
The workspace entity table carries tenant_id and party_id like every other
business entity. Key columns:
| Column | Type | Notes |
|---|---|---|
id |
uuid |
UUID PK; globally unique |
tenant_id |
uuid |
Owning tenant; validated via ores_iam_validate_tenant_fn |
party_id |
uuid |
Owning party; validated against ores_refdata_parties_tbl |
name |
text |
Unique within (tenant_id, party_id) for active records |
parent_workspace_id |
uuid null |
Parent for inheritance chain; cycle-prevention trigger |
scope_portfolio_id |
uuid null |
Optional portfolio filter for trade scoping |
owner_id |
uuid |
IAM account that created the workspace |
status_code |
text |
active or archived |
The name uniqueness index is scoped to (tenant_id, party_id, name) so two
different parties may each have a workspace called "prod" without conflict.
The workspace entity never carries a workspace_id column — a workspace cannot
belong to a workspace. That exception applies only to workspace_id; it does
not extend to tenant_id or party_id.
Every tenant has exactly one Live workspace row — see §Live Workspace — Dedicated Sentinel UUID for details.
Resolution Order Computation
When a workspace is opened, its full ancestor chain is computed once via a recursive CTE and cached in the application session as an ordered UUID array:
with recursive chain(id, depth) as ( select id, 0 from ores_workspaces_tbl where id = :target_workspace_id and tenant_id = :tenant_id and valid_to = ores_utility_infinity_timestamp_fn() union all select w.parent_workspace_id, c.depth + 1 from ores_workspaces_tbl w join chain c on c.id = w.id where w.tenant_id = :tenant_id and w.parent_workspace_id is not null and w.valid_to = ores_utility_infinity_timestamp_fn() ) select id from chain order by depth; -- Returns e.g. [ws-8, ws-5, live-uuid] for a workspace two levels deep
This array is passed as :resolution_order to all workspace-aware queries.
The function takes a second parameter p_tenant_id and filters all CTE rows
by tenant_id. This is required because the Live sentinel UUID appears once
per tenant: without the tenant filter the CTE would match multiple rows when
walking up to the Live root. Party isolation for the workspace entity itself
is enforced in the service layer (all list_active, find_by_id, and
read_all queries include a tenant_id WHERE clause).
Query Patterns
Non-trade data (conventions, market data, curve configs, etc.) uses the resolution order only — no portfolio or trade filtering:
-- List query with deduplication on natural key select distinct on (name) * from ores_curveconfig_yieldcurve_tbl where workspace_id = any(:resolution_order) order by name, array_position(:resolution_order, workspace_id);
Trade queries additionally filter by portfolio scope and (if present) the trade whitelist:
select distinct on (t.id) t.* from ores_trading_trades_tbl t join ores_trading_books_tbl b on b.id = t.book_id left join ores_trading_portfolio_books_tbl pb on pb.book_id = b.id and pb.portfolio_id = ( select scope_portfolio_id from ores_workspaces_tbl where id = :ws_id) where t.workspace_id = any(:resolution_order) and ( (select scope_portfolio_id from ores_workspaces_tbl where id = :ws_id) is null or pb.book_id is not null ) and ( not exists (select 1 from ores_workspace_trade_scope_tbl where workspace_id = :ws_id) or t.id in (select trade_id from ores_workspace_trade_scope_tbl where workspace_id = :ws_id) ) order by t.id, array_position(:resolution_order, t.workspace_id);
Note: Phase 2 repositories currently use an exact-match workspace_id = :wid
predicate. Phase 5 will upgrade these to the = ANY(:resolution_order) pattern
with DISTINCT ON deduplication for full inheritance support.
FK Validation
Every insert trigger on a workspace-aware table must validate that
NEW.workspace_id references an active workspace. Because service users hold
no SELECT on ores_workspaces_tbl (service table isolation invariant), the
validation function must be SECURITY DEFINER:
create function ores_workspace_validate_fn(p_workspace_id uuid) returns uuid language plpgsql security definer set search_path = public, pg_temp as $$ begin -- The Live sentinel always passes; it has no ordinary active row. if p_workspace_id = ores_utility_live_workspace_id_fn() then return p_workspace_id; end if; if not exists ( select 1 from ores_workspaces_tbl where id = p_workspace_id and status_code = 'active' and valid_to = ores_utility_infinity_timestamp_fn() ) then raise exception 'workspace_id % does not reference an active workspace', p_workspace_id using errcode = '23503'; end if; return p_workspace_id; end; $$;
This mirrors the pattern of ores_iam_validate_tenant_fn. The call is added
to every has_workspace_id entity's insert trigger alongside the existing
ores_iam_validate_tenant_fn call, and is generated automatically via the
has_workspace_id codegen template flag.
Context Propagation
database::context
The database context carries workspace_id_ alongside tenant_id_ and
party_id_:
class context { public: const std::string& workspace_id() const { return workspace_id_; } // Returns a copy with workspace set; does not rebuild the pool. [[nodiscard]] context with_workspace(std::string workspace_id) const { auto copy = *this; copy.workspace_id_ = std::move(workspace_id); return copy; } private: // Defaults to the Live sentinel. std::string workspace_id_ = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; };
The chain with_tenant() → with_party() → with_workspace() is applied in
make_request_context() so that workspace is set last and is never lost by
earlier builder calls.
make_request_context
After JWT validation, make_request_context() reads the X-Workspace-Id
NATS header and chains with_workspace():
const auto ws_it = msg.headers.find( std::string(ores::nats::headers::x_workspace_id)); if (ws_it != msg.headers.end() && !ws_it->second.empty()) { ctx = ctx.with_workspace(ws_it->second); } return ctx;
Absence of the header means Live workspace — the default value on
context::workspace_id_ ensures backwards compatibility.
NATS Header
X-Workspace-Id is defined in ores.nats/include/ores.nats/domain/headers.hpp:
/// Active workspace for this request. /// Set by the Qt client from WorkspaceContext.id; forwarded on inter-service /// calls. Absence means Live workspace. inline constexpr std::string_view x_workspace_id = "X-Workspace-Id";
Qt Client — WorkspaceContext
The Qt client carries the active workspace in WorkspaceContext
(projects/ores.qt.api/include/ores.qt/WorkspaceContext.hpp):
struct WorkspaceContext { inline static const QString live_workspace_id = QStringLiteral("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); QString id = live_workspace_id; QString name = QStringLiteral("Live"); QVector<QString> resolution_order = {live_workspace_id}; bool is_live() const { return id == live_workspace_id; } };
The resolution_order vector is populated at activation time by calling the
workspace.v1.workspaces.resolve NATS endpoint, which runs the recursive CTE
and returns the ancestor chain.
ClientManager holds a WorkspaceContext and injects X-Workspace-Id into
every authenticated NATS request via with_workspace_id() on the scoped
client.
Session Management
Workspace Activation Flow
┌─────────────────────────┐
│ User selects workspace │
│ in Data Workshop │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ WorkspaceMdiWindow │
│ emits workspaceActivated │
└────────────┬────────────┘
│
▼
┌─────────────────────────────────┐
│ WorkspaceController │
│ onWorkspaceActivated() │
│ │
│ Live sentinel? │
│ → set default WorkspaceContext │
│ → emit statusMessage │
│ │
│ Other workspace? │
│ → resolve_workspace_request │
│ (async, QtConcurrent) │
│ → build WorkspaceContext with │
│ id, name, resolution_order │
└────────────┬────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ mdiArea_->setProperty("ores_workspace_context", ctx) │
│ → QEvent::DynamicPropertyChange fires │
│ → EntityController::eventFilter() on every controller │
│ → clientManager_->setWorkspaceContext(ctx) │
│ → all subsequent requests carry X-Workspace-Id │
└─────────────────────────────────────────────────────────┘
Current Scope: Application-Level Context
The current implementation switches workspace for the entire session. All MDI
windows share the same ClientManager and therefore the same workspace
context. When the user activates workspace B, every open window immediately
uses workspace B on its next request.
Per-window workspace context (each MDI subwindow having an independent workspace selector, enabling side-by-side comparison of the same data in two different workspaces) is a planned future phase. See the UI section below.
UI Considerations
Workspace Badge in Window Titles
Windows opened while a non-live workspace is active display the workspace name in their title bar:
Trades [EUR shock +50bps] Market Data [EUR shock +50bps]
Live workspace windows show no badge — it is the default and adding a badge
to every window would be noisy. This is applied in
EntityController::show_managed_window().
Data Workshop
The WorkspaceMdiWindow provides a tree view of all workspaces in the current
party, grouped by parent/child relationships. Actions:
| Action | Description |
|---|---|
| Open | Activates the selected workspace session-wide |
| Add | Creates a new workspace via WorkspaceDetailDialog |
| Edit | Opens the detail dialog for an existing workspace |
| Archive | Soft-closes the workspace (bitemporal close) |
| Delete | Permanently removes the workspace record |
Per-Window Workspace Selector (Future)
The design calls for each MDI subwindow to carry its own workspace context, selectable via a compact combo box in the window toolbar:
+-------------------------------------------------------+ | TRADES [EUR shock ▼] [Filter] [Cols] | +-------------------------------------------------------+ | ...trade grid scoped to EUR shock workspace... | +-------------------------------------------------------+
This enables side-by-side comparison:
+-----------------------------+ +-----------------------------+ | TRADES [Live ▼] | | TRADES [EUR shock ▼] | |-----------------------------| |-----------------------------| | IRS_001 NPV: 1,234,500 | | IRS_001 NPV: 1,189,200 | | FXF_002 NPV: 456,100 | | FXF_002 NPV: 455,900 | +-----------------------------+ +-----------------------------+
Implementation requires either per-window ClientManager scopes or passing
workspace context through individual request calls rather than storing it on
the shared manager. This is deferred; the current MDI-area property mechanism
is designed as a foundation for this extension.
Permissions
Three tiers of workspace permission are enforced in workspace_handler.hpp:
| Permission | Default holders |
|---|---|
workspace::workspaces:read |
All authenticated users (own party) |
workspace::workspaces:write |
All authenticated users (own party) |
workspace::workspaces:archive |
All authenticated users (own ws) |
workspace::workspaces:archive_any |
TenantAdmin |
workspace::live_workspace:archive |
SuperAdmin only |
Archive is ownership-aware: a user may archive their own workspace with
workspaces:archive; archiving another user's workspace requires
workspaces:archive_any.