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_id is set; available_parties contains the one party with its name and category. The client reads the name from available_parties.front().
  • Multiple parties: selected_party_id is nil; available_parties lists all parties. The client shows a picker; after selection the server binds the session via select_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_id column).
  • Both layers are enforced simultaneously on party-scoped tables.

11. Related Components