Refdata Party Domain: tenant_id Strong-Type Migration

Table of Contents

Problem

The ores.refdata.api domain contains 35 entity types. The majority already carry utility::uuid::tenant_id tenant_id = utility::uuid::tenant_id::system(); (the strong type), but 11 party-related types still use std::string tenant_id. This inconsistency:

  • Allows silently assigning arbitrary strings (e.g. "system") to a field that must be a valid UUID.
  • Was the root cause of a bad access to std::expected crash in ores.synthetic.core tests (2026-05-20): generators called tenant_id::from_string("system").value() without a fallback.
  • Makes the domain model internally inconsistent: portfolio has the strong type; party (the entity that owns portfolios) does not.

The 11 unmigrated types are all in the party/counterparty cluster, which was implemented before the strong type was introduced. Eventing and messaging types intentionally keep std::string for serialisation; they are out of scope.

Scope

In scope — 11 domain types in ores.refdata.api/domain/

File Notes
party.hpp Root entity; has special repo
business_unit.hpp  
business_unit_type.hpp  
counterparty.hpp  
counterparty_identifier.hpp  
counterparty_contact_information.hpp  
party_contact_information.hpp  
party_counterparty.hpp  
party_country.hpp  
party_currency.hpp  
party_identifier.hpp  

Out of scope

  • Eventing types under ores.refdata.api/eventing/ — intentionally std::string for NATS/JSON serialisation.
  • Messaging protocol types under ores.refdata.api/messaging/ — same reason.
  • Any type outside ores.refdata.api (IAM, DQ, reporting, etc.).

Migration Pattern

The fully migrated portfolio type is the canonical reference.

1. Domain header

-    std::string tenant_id;
+    utility::uuid::tenant_id tenant_id = utility::uuid::tenant_id::system();

Add the include if not already present:

#include "ores.utility/uuid/tenant_id.hpp"

2. Repository mapper — entity→domain (read from DB)

The DB column always contains a valid UUID string. Use .value() — a malformed row is a data corruption bug, not an expected runtime condition.

-    r.tenant_id = v.tenant_id;
+    r.tenant_id = utility::uuid::tenant_id::from_string(v.tenant_id).value();

Reference: portfolio_mapper.cpp line 38.

3. Repository mapper — domain→entity (write to DB)

-    r.tenant_id = v.tenant_id;
+    r.tenant_id = v.tenant_id.to_string();

Reference: portfolio_mapper.cpp line 68.

4. Special case: party_repository.cpp manual row mapping

party_repository::read_system_party maps rows by index rather than via the mapper (line 129). Change:

-    p.tenant_id = *row[1];
+    p.tenant_id = utility::uuid::tenant_id::from_string(*row[1]).value();

5. Generators

Generators pull tenant_id from ctx.env() with a default of "system". The literal "system" is not a UUID; use value_or to fall back to the system tenant sentinel:

-    r.tenant_id = tenant_id;
+    r.tenant_id = utility::uuid::tenant_id::from_string(tenant_id)
+        .value_or(utility::uuid::tenant_id::system());

Add the include if not already present:

#include "ores.utility/uuid/tenant_id.hpp"

Affected generators (all in ores.refdata.api/src/generators/):

  • party_generator.cpp
  • business_unit_generator.cpp
  • business_unit_type_generator.cpp
  • counterparty_generator.cpp
  • counterparty_identifier_generator.cpp
  • counterparty_contact_information_generator.cpp
  • party_contact_information_generator.cpp
  • party_counterparty_generator.cpp
  • party_country_generator.cpp
  • party_currency_generator.cpp
  • party_identifier_generator.cpp

Implementation Checklist

Domain headers

  • [X] party.hpp — change field type, add include
  • [X] business_unit.hpp — change field type, add include
  • [X] business_unit_type.hpp — change field type, add include
  • [X] counterparty.hpp — change field type, add include
  • [X] counterparty_identifier.hpp — change field type, add include
  • [X] counterparty_contact_information.hpp — change field type, add include
  • [X] party_contact_information.hpp — change field type, add include
  • [X] party_counterparty.hpp — change field type, add include
  • [X] party_country.hpp — change field type, add include
  • [X] party_currency.hpp — change field type, add include
  • [X] party_identifier.hpp — change field type, add include

Repository mappers (ores.refdata.core/src/repository/)

  • [X] party_mapper.cpp — both read and write sides
  • [X] business_unit_mapper.cpp — both read and write sides
  • [X] business_unit_type_mapper.cpp — both read and write sides
  • [X] counterparty_mapper.cpp — both read and write sides
  • [X] counterparty_identifier_mapper.cpp — both read and write sides
  • [X] counterparty_contact_information_mapper.cpp — both read and write sides
  • [X] party_contact_information_mapper.cpp — both read and write sides
  • [X] party_counterparty_mapper.cpp — both read and write sides
  • [X] party_country_mapper.cpp — both read and write sides
  • [X] party_currency_mapper.cpp — both read and write sides
  • [X] party_identifier_mapper.cpp — both read and write sides

Special repository cases

  • [X] party_repository.cpp — manual row mapping in read_system_party (line 129)

Generators (ores.refdata.api/src/generators/)

  • [X] party_generator.cpp
  • [X] business_unit_generator.cpp
  • [X] business_unit_type_generator.cpp
  • [X] counterparty_generator.cpp
  • [X] counterparty_identifier_generator.cpp
  • [X] counterparty_contact_information_generator.cpp
  • [X] party_contact_information_generator.cpp
  • [X] party_counterparty_generator.cpp
  • [X] party_country_generator.cpp
  • [X] party_currency_generator.cpp
  • [X] party_identifier_generator.cpp

Test files (ores.refdata.core/tests/ and ores.synthetic.core/tests/)

These were not in the original scope but required fixes to compile after the domain header changes. The pattern in each: update find_system_party_id helper to take const ores::utility::uuid::tenant_id&, remove spurious .to_string() at call sites, and update make_pc / make_party_* helpers that assigned h.tenant_id().to_string() to pc.tenant_id.

  • [X] party_rls_isolation_tests.cpp
  • [X] repository_party_counterparty_repository_tests.cpp
  • [X] repository_party_country_repository_tests.cpp
  • [X] repository_party_currency_repository_tests.cpp
  • [X] organisation_publisher_integration_tests.cpp (ores.synthetic.core) — moved from_string() conversion before the generic stamp lambda so all entity types receive the strong type directly

Build and test

  • [X] Build ores.refdata.api.lib and ores.refdata.core.lib cleanly
  • [X] All refdata repository tests pass (ores.refdata.core test suite)
  • [X] ores.synthetic.core tests pass (no more bad access to std::expected)

Notes

  • The mapper read side uses .value() (not .value_or) because a malformed UUID in the DB is a data integrity bug. The generator uses .value_or because the generation context allows "system" as a human-readable shorthand. This shorthand is a leaky abstraction — cleanup is tracked in 2026-05-20-refdata-cpp-codegen-activation.org (Notes section).
  • No SQL migration is needed. The DB column is already uuid; only the C++ representation changes.
  • No eventing or messaging types are touched. Downstream consumers of party_changed_event etc. already handle std::string tenant_id from the serialised form.