Multi-Party Architecture
Overview
ORE Studio implements party-level data isolation within tenants. While tenant isolation separates organisations from each other (see Multi-Tenancy Architecture), party isolation separates business units within an organisation. This document describes how party context flows through the system from user authentication to database queries.
This is a companion document to projects/ores.iam/modeling/multi_tenancy.org.
The multi-tenancy architecture provides the foundation; multi-party adds a second
layer of isolation within each tenant.
Core Concepts
Party Hierarchy
Parties within a tenant form an arbitrarily nested tree. The tree structure represents a corporate group's organisational hierarchy:
┌────────────────────┐
│ System Party │ (party 0, auto-created)
│ type: system │
└────────┬───────────┘
│
┌────────┴───────────┐
│ ACME Group │ (root operational party)
│ type: operational│
└────────┬───────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌────────┴─────────┐ ┌────┴───────────┐ ┌───┴────────────┐
│ ACME Europe │ │ ACME Americas │ │ ACME Asia-Pac │
│ type: operational│ │ type: operational│ │ type: operational│
└────────┬─────────┘ └────────────────┘ └────────────────┘
│
┌────────┴─────────┐
│ ACME London │
│ type: operational│
└──────────────────┘
Party Types
Each party has a type that determines its role within the tenant.
| Type | Purpose | Auto-created |
|---|---|---|
system |
Administrative party (party 0) | Yes |
operational |
Business entity (trades, books, KYC) | No |
System Party
Every tenant has exactly one system party, created automatically during tenant provisioning. The system party:
- Is the administrative home for tenant admin accounts.
- Has full visibility over all parties in the tenant.
- Owns party-scoped data that is administrative rather than business in nature.
- In tenant 0 (system tenant), it is the only party.
The system party is analogous to the system tenant: it is an infrastructure construct, not a business entity. It serves a similar role to the max UUID system tenant but at the party level.
Operational Parties
Operational parties represent real business entities: legal entities, branches, desks, subsidiaries. They:
- Are created by users during normal business operations.
- Form an arbitrarily nested hierarchy via
parent_party_id. - Have tree-scoped visibility: a party can see its own data and all descendant party data.
- Own business data: counterparty relationships, books, portfolios, trades.
Data Ownership Patterns
Data within a tenant falls into two categories based on ownership scope.
Tenant-Scoped Data (Shared Reference Data)
Reference data defined once at the tenant level, sourced from standards bodies (ISO, FpML). All parties within the tenant share a common definition.
┌──────────────────────────────────────────────────────────────┐ │ Tenant: ACME Corp │ │ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ Shared Reference Data (tenant-scoped) │ │ │ │ Currencies: USD, EUR, GBP, JPY │ │ │ │ Countries: US, GB, DE, JP, FR │ │ │ └────────────────────────────────────────────────────────┘ │ │ │ │ Party A can use: USD, EUR, GBP (via junction table) │ │ Party B can use: USD, JPY (via junction table) │ │ Party C can use: EUR, GBP (via junction table) │ │ │ └──────────────────────────────────────────────────────────────┘
Entities in this category:
| Entity | Source | Party Relationship |
|---|---|---|
| Currencies | ISO 4217 | Visibility scoped via junction |
| Countries | ISO 3166-1 | Visibility scoped via junction |
| Business centres | ORE | Visibility scoped via junction |
| Counterparty identities | GLEIF | Shared golden source |
Party-level visibility is controlled through junction tables (e.g.
party_currencies) that define which currencies a given party can see and use.
The underlying currency definitions remain shared.
Counterparty Identity vs Relationship
Counterparties are modelled as a two-tier structure that separates identity (factual, shared) from relationship (party-specific, isolated).
Counterparty Identity (Tenant-Scoped)
The identity of a counterparty – its LEI, legal name, jurisdiction, registered address, entity status, parent hierarchy – is factual, public data sourced from registries such as GLEIF. There is no reason for Party A and Party B within the same group to maintain separate copies of "Goldman Sachs Group Inc, LEI 784F5XWPLTWKTBV3E584".
Counterparty identity records live in ores_refdata_counterparties_tbl and are
scoped to the tenant, not to any individual party. All parties within the tenant
share a single golden source of counterparty reference data, imported from GLEIF
LEI data via the data librarian.
┌──────────────────────────────────────────────────────────────┐ │ Tenant: ACME Corp │ │ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ Counterparty Identity (tenant-scoped, shared) │ │ │ │ Deutsche Bank AG LEI: 7LTWFZYICNSX8D621K86 │ │ │ │ Goldman Sachs Inc LEI: 784F5XWPLTWKTBV3E584 │ │ │ │ BNP Paribas SA LEI: R0MUWSFPU8MPRO8K5P83 │ │ │ └────────────────────────────────────────────────────────┘ │ │ │ │ All parties see the same counterparty identities. │ │ │ └──────────────────────────────────────────────────────────────┘
Counterparty Relationship (Party-Scoped, Future)
The business relationship between a party and a counterparty – KYC/AML status, onboarding date, credit limit, internal rating, netting agreements, ISDA/CSA details, trading permissions – is party-specific. Each regulated entity within a group has independent legal obligations:
- KYC/AML: A legal obligation on each regulated entity individually; it cannot be delegated up the group hierarchy.
- Credit limits and netting: Agreed per legal entity pair, not at group level.
- Ring-fencing regulations (post-2008): Require separation between e.g. a retail bank and its investment arm.
- Jurisdictional requirements: Different subsidiaries in different jurisdictions face different due diligence rules.
Even in a highly automated system where LLMs perform the KYC checks, the legal accountability for the outcome remains with each individual regulated entity. Automation makes the process fast but does not eliminate the per-party scoping requirement.
The counterparty relationship table (future ores_refdata_counterparty_relationships_tbl)
will be party-scoped, referencing the shared identity record:
┌──────────────────────────────────────────────────────────────┐ │ Tenant: ACME Corp │ │ │ │ ┌────────────────────┐ ┌────────────────────┐ │ │ │ ACME London │ │ ACME New York │ │ │ │ (party A) │ │ (party B) │ │ │ ├────────────────────┤ ├────────────────────┤ │ │ │ Relationships: │ │ Relationships: │ │ │ │ → Deutsche Bank │ │ → Deutsche Bank │ ← separate│ │ │ KYC: Approved │ │ KYC: Pending │ records │ │ │ Limit: €50M │ │ Limit: $30M │ │ │ │ → BNP Paribas │ │ → Goldman Sachs │ │ │ │ KYC: Approved │ │ KYC: Approved │ │ │ ├────────────────────┤ ├────────────────────┤ │ │ │ Books: │ │ Books: │ │ │ │ - Main Trading │ │ - Main Trading │ ← separate│ │ │ - Hedge Book │ │ - Prop Book │ records │ │ └────────────────────┘ └────────────────────┘ │ │ │ │ Identity is shared. Relationships are isolated. │ │ Party A CANNOT see Party B's relationships. │ │ │ └──────────────────────────────────────────────────────────────┘
Party-Scoped Data (Isolated Operational Data)
Operational data owned by a specific party. Each party maintains its own records independently.
Entities in this category:
| Entity | Isolation Mechanism | Notes |
|---|---|---|
| Counterparty relationships | party_id column + RLS |
KYC, credit limits, agreements |
| Books | party_id column + RLS |
Trading books |
| Portfolios | party_id column + RLS |
Position aggregations |
| Trades | party_id column + RLS |
Individual transactions |
Note: Counterparty identity (LEI, legal name, hierarchy) is tenant-scoped shared reference data; only the party-specific relationship overlay is party-scoped. See the section above for details.
Party-Level Row-Level Security
Party isolation mirrors the tenant RLS pattern but adds a second layer of filtering. Both layers are enforced simultaneously.
Session Variables
At login, three values are set on the database session:
SET app.current_tenant_id = 'tenant-uuid'; -- existing (tenant RLS) SET app.current_party_id = 'party-uuid'; -- new (user's party) SET app.visible_party_ids = '{uuid1,uuid2,...}'; -- new (subtree set)
The visible_party_ids array contains the user's party plus all its
descendants, computed at login via a recursive CTE.
Visible Party Set Computation
At login, after the user selects their party, the server computes the full set of visible party IDs:
WITH RECURSIVE party_tree AS ( -- Start with the user's party SELECT id FROM ores_refdata_parties_tbl WHERE id = $user_party_id AND tenant_id = $tenant_id AND valid_to = ores_utility_infinity_timestamp_fn() UNION ALL -- Add all descendants SELECT p.id FROM ores_refdata_parties_tbl p JOIN party_tree pt ON p.parent_party_id = pt.id WHERE p.tenant_id = $tenant_id AND p.valid_to = ores_utility_infinity_timestamp_fn() ) SELECT array_agg(id) FROM party_tree;
The result is stored as the app.visible_party_ids session variable. For the
system party, this query returns all parties in the tenant.
RLS Policy Pattern
Party-scoped tables use a restrictive RLS policy (ANDed with the permissive
tenant policy). The policy is strict: when no party context is set,
ores_iam_visible_party_ids_fn() returns NULL, so x = ANY(NULL) evaluates
to NULL (falsy) and no rows are visible. There is no null pass-through. Users
must have a party assigned.
CREATE POLICY party_isolation ON ores_refdata_books_tbl AS RESTRICTIVE FOR ALL USING ( party_id = ANY(ores_iam_visible_party_ids_fn()) ) WITH CHECK ( party_id = ANY(ores_iam_visible_party_ids_fn()) );
This covers all visibility scenarios:
┌──────────────────────────────┬──────────────────────────────────────┐ │ User's Party │ Visible Data │ ├──────────────────────────────┼──────────────────────────────────────┤ │ System party │ All parties in tenant │ │ ACME Group (root operational)│ All operational parties │ │ ACME Europe (mid-level) │ ACME Europe + ACME London │ │ ACME London (leaf) │ ACME London only │ └──────────────────────────────┴──────────────────────────────────────┘
Dual RLS Enforcement
Tenant and party RLS are enforced simultaneously. A query on a party-scoped table is filtered by both:
-- Effective filter on any party-scoped table: WHERE tenant_id = current_setting('app.current_tenant_id')::uuid -- tenant RLS AND party_id = ANY(current_setting('app.visible_party_ids')::uuid[]) -- party RLS
This provides defence in depth: even if party RLS were misconfigured, tenant RLS prevents cross-tenant data access.
Database Context
Extended context Class
The database context gains party awareness alongside tenant awareness:
class context { public: explicit context(sqlgen::ConnectionPool<connection_type> connection_pool, sqlgen::postgres::Credentials credentials, utility::uuid::tenant_id tenant_id); const utility::uuid::tenant_id& tenant_id() const; // Create a new context with a different tenant (shares connection pool) context with_tenant(utility::uuid::tenant_id tenant_id) const; // Create a new context with tenant + party (shares connection pool) context with_party(utility::uuid::tenant_id tenant_id, boost::uuids::uuid party_id, std::vector<boost::uuids::uuid> visible_party_ids) const; private: tenant_aware_pool<connection_type> connection_pool_; // existing sqlgen::postgres::Credentials credentials_; };
Extended tenant_aware_pool
The pool wrapper sets all session variables on connection acquisition:
template <class Connection> class tenant_aware_pool { public: expected<AcquiredConnection, sqlgen::Error> acquire() { auto conn = pool_.acquire(); if (!conn) { return sqlgen::error(conn.error()); } // Set tenant context (existing) (*conn)->exec(fmt::format( "SET app.current_tenant_id = '{}'", tenant_id_.to_string())); // Set party context (new) if (party_id_) { (*conn)->exec(fmt::format( "SET app.current_party_id = '{}'", boost::uuids::to_string(*party_id_))); (*conn)->exec(fmt::format( "SET app.visible_party_ids = '{{{}}}'", format_uuid_array(visible_party_ids_))); } return conn; } private: sqlgen::ConnectionPool<Connection> pool_; utility::uuid::tenant_id tenant_id_; std::optional<boost::uuids::uuid> party_id_; // new std::vector<boost::uuids::uuid> visible_party_ids_; // new };
Key properties:
- Party context is optional (not all operations require party scope).
- When set, both tenant and party session variables are configured.
- The
visible_party_idsarray is formatted as a PostgreSQL array literal.
Session Management
Extended Session Data
Each authenticated session stores party context alongside tenant context:
struct session_data { boost::uuids::uuid id; boost::uuids::uuid account_id; utility::uuid::tenant_id tenant_id; boost::uuids::uuid party_id; // new std::vector<boost::uuids::uuid> visible_party_ids; // new std::string username; std::chrono::system_clock::time_point created_at; std::chrono::system_clock::time_point last_activity; };
Session-Party Binding
Party binding follows the same principles as tenant binding:
- Resolved at login time (after party selection).
- Immutable for the session lifetime.
- Used for all subsequent requests in that session.
Login Flow
Authentication and Party Selection
┌──────────────────────┐
│ Login Request │
│ user@hostname │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Resolve tenant │ (existing)
│ from hostname │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Authenticate user │ (existing)
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Lookup parties from │ (new)
│ account_parties │
└──────────┬───────────┘
│
┌──────┴──────┐
│ 1 party │ N parties
▼ ▼
┌─────────┐ ┌───────────────┐
│ Auto- │ │ Return party │
│ select │ │ list to client│
└────┬────┘ └───────┬───────┘
│ │
│ ┌────┴────────┐
│ │ Client shows│
│ │ party picker│
│ └────┬────────┘
│ │
└───────┬───────┘
│
▼
┌──────────────────────┐
│ Compute visible │ (new)
│ party set via │
│ recursive CTE │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Create session with │
│ tenant + party + │
│ visible set │
└──────────────────────┘
Per-Request Context Creation
The make_request_context() method gains party awareness:
database::context make_request_context( const comms::service::session_data& session) { return ctx_.with_party( session.tenant_id, session.party_id, session.visible_party_ids); }
UI Considerations
Party Context Display
The active party context is displayed as a styled chip label in the application status bar so the user always knows their active party. Rules:
| State | Display | Style | Data visible? |
|---|---|---|---|
| System party active | Party: <name> [System] |
Normal chip | All party data |
| Operational party active | Party: <name> |
Normal chip | Party-scoped data |
| No party (misconfigured) | Party: [No Party] |
Warning chip | Nothing (RLS) |
The party chip is always visible when connected. A red-background warning chip
is shown when no party context is available; a QMessageBox::warning is also
shown at login to alert the user to the misconfiguration. With strict RLS
enforcement, a missing party context means all books, portfolios, and trades
are hidden — not just a cosmetic warning.
The current party category is supplied by the server in the party_summary
struct (see party_summary::party_category below). The Qt client uses
ClientManager::isSystemParty() to check if the active party is the system
party.
Login Response and Party Name
The login_response always includes the selected party in available_parties
regardless of whether there is one party or many:
- Single party:
selected_party_idis set;available_partiescontains the one party with its name and category. The client reads the name fromavailable_parties.front(). - Multiple parties:
selected_party_idis nil;available_partieslists all parties. The client shows a picker; after selection the server binds the session viaselect_party_request. - Zero parties: login is rejected server-side.
Party info is fetched using ores_refdata_get_party_info_fn to avoid inline
SQL in C++ handler code.
Multi-Party Table Views
When a user's visible set spans multiple parties, table views adapt:
| User Context | Party Column | Party Filter |
|---|---|---|
| Single party | Hidden | Not shown |
| Multiple parties | Visible | Available |
The party column shows the owning party for each record. A filter dropdown allows scoping the view to a specific party or subtree.
Intercompany Considerations
When viewing data across multiple parties, some records may appear to be duplicates (e.g. both Party A and Party B have a relationship with "Deutsche Bank"). These are distinct relationship records with independent lifecycles – different KYC status, credit limits, and trading agreements. The party column disambiguates them. The underlying counterparty identity (LEI, legal name) is shared and appears only once.
For aggregated views (group-level risk, consolidated P&L), intercompany positions between parties in the same group require special handling (elimination) which is a reporting concern, not a data isolation concern.
Performance Characteristics
Per-Login Overhead
| Operation | Cost |
|---|---|
| Recursive CTE for visible party set | ~1ms for typical hierarchies |
| Array formatting for session variable | Negligible |
Per-Request Overhead
| Operation | Cost |
|---|---|
| SET app.current_party_id | Part of query |
| SET app.visible_party_ids | Part of query |
ANY(uuid[]) check in RLS policy |
O(n), n = parties |
For realistic hierarchies (up to a few hundred parties per tenant), the performance impact is negligible.
Memory Per Session
| Component | Size |
|---|---|
| party_id (UUID) | 16 bytes |
| visible_party_ids (vector) | 16 bytes * party count |
For a tenant with 100 parties, visible_party_ids adds ~1.6 KB per session.
Relationship to Tenant Isolation
Party isolation is a strict refinement of tenant isolation:
┌─────────────────────────────────────────────────────────────┐ │ PostgreSQL Database │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Tenant RLS Layer (app.current_tenant_id) │ │ │ │ Isolates tenants from each other │ │ │ │ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │ │ Party RLS Layer (app.visible_party_ids) │ │ │ │ │ │ Isolates parties within a tenant │ │ │ │ │ │ │ │ │ │ │ │ Only applies to party-scoped tables │ │ │ │ │ │ (counterparty relationships, books, trades) │ │ │ │ │ │ │ │ │ │ │ │ Tenant-scoped tables (currencies, countries) │ │ │ │ │ │ are filtered by tenant RLS only │ │ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘
- Tenant RLS applies to all tenant-scoped tables.
- Party RLS applies only to party-scoped tables (those with a
party_idcolumn). - Both layers are enforced simultaneously on party-scoped tables.
Related Components
| Component | File | Purpose |
|---|---|---|
| design | https://github.com/OreStudio/OreStudio/blob/main/doc/plans/2026-02-09-party-isolation-and-tenant-types-design.org | Design rationale and decisions |
| iam | Multi-Tenancy Architecture | Companion tenant isolation document |
| iam | Multi-Party Login Flow | Account-party rules and login flow |
| utility | https://github.com/OreStudio/OreStudio/blob/main/projects/ores.utility/include/ores.utility/uuid/tenant_id.hpp | Tenant ID wrapper type |
| database | https://github.com/OreStudio/OreStudio/blob/main/projects/ores.database/include/ores.database/domain/tenant_aware_pool.hpp | Connection pool with tenant context |
| database | https://github.com/OreStudio/OreStudio/blob/main/projects/ores.database/include/ores.database/domain/context.hpp | Database context with tenant |
| refdata | https://github.com/OreStudio/OreStudio/blob/main/projects/ores.refdata/include/ores.refdata/domain/party.hpp | Party domain type |
| iam | ores_iam_account_parties_tbl |
Account-party junction table |