Multi-Party Architecture
Table of Contents
1. 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.
2. Core Concepts
2.1. 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│
└──────────────────┘
2.2. 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 |
2.3. 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.
2.4. 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.
3. Data Ownership Patterns
Within a tenant, data falls into two categories based on ownership scope. A third owner sits above the tenant, and Installation-Scoped Data below states it.
3.1. 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.
3.2. Installation-Scoped Data
The two categories above are the ones inside a tenant, and they are not the whole ownership model. The installation owns the deployment's own facts — the services roster, the hosts and wrapper nodes, the grid and node samples, the telemetry logs, the message-bus samples and the database row. Those rows carry no tenant's data and belong to no party, so neither a tenant's isolation nor a party's should narrow them: they want the policy shape that admits the installation's own rows to every session, with the permission deciding who may call the read. Naming the owner is the first step of designing a table, and the three owners and their shapes are stated in Tenant ownership and the information flow.
3.3. Counterparty Identity vs Relationship
Counterparties are modelled as a two-tier structure that separates identity (factual, shared) from relationship (party-specific, isolated).
3.3.1. 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. │ │ │ └──────────────────────────────────────────────────────────────┘
3.3.2. 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. │ │ │ └──────────────────────────────────────────────────────────────┘
3.4. 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.
4. Party-Level Row-Level Security
Party isolation mirrors the tenant RLS pattern but adds a second layer of filtering. Both layers are enforced simultaneously.
4.1. Transaction Settings
Every transaction a request opens sets three values, local to that transaction:
select set_config('app.current_tenant_id', $1, true), -- tenant RLS set_config('app.current_party_id', $2, true), -- the user's party set_config('app.visible_party_ids', $3, true); -- the subtree set
The visible_party_ids array contains the user's party plus all its
descendants, computed at login via a recursive CTE.
4.2. 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 signed into the access token as the visible_party_ids claim,
and each transaction of a request sets it as app.visible_party_ids. For the
system party, this query returns all parties in the tenant.
4.3. 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 │ └──────────────────────────────┴──────────────────────────────────────┘
4.4. 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.
5. Database Context
5.1. Party-aware context
The database context carries the party alongside the tenant:
class context { public: const utility::uuid::tenant_id& tenant_id() const; std::optional<boost::uuids::uuid> party_id() const; const std::vector<boost::uuids::uuid>& visible_party_ids() const; context with_tenant(utility::uuid::tenant_id tenant_id, std::string actor) const; context with_party(utility::uuid::tenant_id tenant_id, boost::uuids::uuid party_id, std::vector<boost::uuids::uuid> visible_party_ids, std::string actor) const; };
Key properties:
- Party context is optional: not all operations need party scope.
- Every copy shares the one pool, so a party-scoped context costs no connection of its own.
5.2. Transaction-local party settings
pq::transaction sends the context's settings with begin, in one pipeline
with the first statement. A context with a party sets app.current_party_id
and app.visible_party_ids; a context without one sets them empty, which the
party policies read as no party context (see the RLS policy pattern above). The settings are local to the
transaction, so the next transaction on the same pooled connection starts with
no party. The visible parties travel as one array parameter.
6. Access Token
6.1. Party claims
The access token carries the party alongside the tenant: the party_id claim
names the active party, and the visible_party_ids claim lists that party and
its descendants.
6.2. Token-Party Binding
Party binding follows the same principles as tenant binding:
- Resolved at login time, after party selection.
- Signed into the token, so a request cannot change it.
- Used for every request that carries the token.
7. Login Flow
7.1. 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 │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Sign a token with │
│ tenant + party + │
│ visible set │
└──────────────────────┘
7.2. Per-Request Context Creation
ores::service::service::make_request_context() narrows the service's base
context to the token's tenant and party:
return base_ctx .with_party(*tid_result, party_id, std::move(visible_ids), claims->username.value_or("")) .with_roles(claims->roles);
A token with no party gives a tenant-only context.
8. UI Considerations
8.1. 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; the removed desktop client also showed a modal warning 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).
8.2. 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.
8.3. 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.
8.4. 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.
9. Performance Characteristics
9.1. Per-Login Overhead
| Operation | Cost |
|---|---|
| Recursive CTE for visible party set | ~1ms for typical hierarchies |
| Array claim in the access token | Negligible |
9.2. Per-Request Overhead
| Operation | Cost |
|---|---|
| set_config of the party settings | Part of begin |
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.
9.3. Memory Per Request
| 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 about 1.6 KB to each request's context.
10. 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.
11. 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/pq/transaction.hpp | Transaction-local party settings |
| 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 |