From 0b957cb2564ca21b3c3adbba46b8ec443e3029cd Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 10:25:40 -0400 Subject: [PATCH 1/5] Trust canonical stakeholder relationship schema --- src/utils/planSecurityAliases.ts | 51 ++++-------- test/utils/ocfZodSchemas.test.ts | 103 +++++++++++++++++++++++++ test/utils/planSecurityAliases.test.ts | 20 ++++- 3 files changed, 137 insertions(+), 37 deletions(-) diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 914ddf57..38a75a53 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -1,4 +1,4 @@ -import type { CompensationType, StakeholderRelationshipType } from '../types/native'; +import type { CompensationType } from '../types/native'; import { normalizeNumericString } from './typeConversions'; /** @@ -299,24 +299,13 @@ function normalizePlanSecurityType(data: Record): Record = new Set([ - 'EMPLOYEE', - 'ADVISOR', - 'INVESTOR', - 'FOUNDER', - 'BOARD_MEMBER', - 'OFFICER', - 'OTHER', -]); - -function isStakeholderRelationshipType(value: string): value is StakeholderRelationshipType { - return VALID_STAKEHOLDER_RELATIONSHIPS.has(value as StakeholderRelationshipType); -} - /** - * Canonicalize stakeholder relationship change events to latest OCF format. + * Canonicalize raw legacy stakeholder relationship change events to latest OCF format. * - * Latest schema fields: + * Canonical inputs are returned untouched so the source-of-truth OCF schema owns + * enum, casing, required-field, and additional-property validation. + * + * Canonical schema fields: * - object_type: CE_STAKEHOLDER_RELATIONSHIP * - relationship_started?: StakeholderRelationship * - relationship_ended?: StakeholderRelationship @@ -326,6 +315,8 @@ function isStakeholderRelationshipType(value: string): value is StakeholderRelat * - new_relationships: StakeholderRelationship[] */ function normalizeStakeholderRelationshipChangeEvent(data: Record): Record { + if (data.object_type === 'CE_STAKEHOLDER_RELATIONSHIP') return data; + const normalizedObjectType = normalizeObjectType(typeof data.object_type === 'string' ? data.object_type : ''); const isRelationshipEvent = normalizedObjectType === 'CE_STAKEHOLDER_RELATIONSHIP'; if (!isRelationshipEvent) return data; @@ -340,6 +331,11 @@ function normalizeStakeholderRelationshipChangeEvent(data: Record { if (typeof relationship !== 'string') { @@ -349,27 +345,16 @@ function normalizeStakeholderRelationshipChangeEvent(data: Record 1 && - result.relationship_started === undefined && - result.relationship_ended === undefined - ) { + if (normalizedRelationships.length > 1) { throw new Error( 'Invalid stakeholder relationship change event: legacy new_relationships with multiple entries is ambiguous; provide canonical relationship_started/relationship_ended fields' ); } - if ( - normalizedRelationships.length === 1 && - result.relationship_started === undefined && - result.relationship_ended === undefined - ) { + if (normalizedRelationships.length === 1) { result.relationship_started = normalizedRelationships[0]; } @@ -391,16 +376,10 @@ function normalizeStakeholderRelationshipChangeEvent(data: Record unknown): OcpValidationError { } const entityTypes = Object.keys(ENTITY_REGISTRY) as OcfEntityType[]; +const stakeholderRelationshipTypes = [ + 'ADVISOR', + 'BOARD_MEMBER', + 'CONSULTANT', + 'EMPLOYEE', + 'EX_ADVISOR', + 'EX_CONSULTANT', + 'EX_EMPLOYEE', + 'EXECUTIVE', + 'FOUNDER', + 'INVESTOR', + 'NON_US_EMPLOYEE', + 'OFFICER', + 'OTHER', +] as const satisfies readonly StakeholderRelationshipType[]; +type MissingStakeholderRelationshipType = Exclude< + StakeholderRelationshipType, + (typeof stakeholderRelationshipTypes)[number] +>; +const stakeholderRelationshipTypeCoverageIsExhaustive: MissingStakeholderRelationshipType extends never ? true : never = + true; const entityDiscriminatorCases = entityTypes.map((entityType, index) => { const mismatchedEntityType = entityTypes[(index + 1) % entityTypes.length]; return { @@ -44,6 +66,8 @@ const entityDiscriminatorCases = entityTypes.map((entityType, index) => { }); describe('ocfZodSchemas', () => { + void stakeholderRelationshipTypeCoverageIsExhaustive; + beforeAll(() => { if (schemaAvailabilityError) { throw schemaAvailabilityError; @@ -204,6 +228,85 @@ describe('ocfZodSchemas', () => { expect(() => parseOcfObject(legacyFixture)).toThrow('legacy new_relationships with multiple entries is ambiguous'); }); + it.each(stakeholderRelationshipTypes)('strictly parses raw legacy %s relationship input', (relationship) => { + const parsed = parseOcfObject({ + object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', + id: `legacy-relationship-${relationship.toLowerCase()}`, + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + new_relationships: [relationship], + }); + + expect(parsed).toMatchObject({ + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + relationship_started: relationship, + }); + expect(parsed).not.toHaveProperty('new_relationships'); + }); + + it('rejects mixed legacy and canonical relationship representations', () => { + const parseMixedRepresentation = () => + parseOcfObject({ + object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', + id: 'mixed-relationship-event', + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + relationship_started: 'ADVISOR', + new_relationships: ['UNKNOWN_RELATIONSHIP'], + }); + + expect(parseMixedRepresentation).toThrow(OcpValidationError); + expect(parseMixedRepresentation).toThrow('cannot mix legacy new_relationships'); + }); + + describe('canonical stakeholder relationship events', () => { + it.each(stakeholderRelationshipTypes)('strictly parses the %s relationship value', (relationship) => { + const input = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: `relationship-${relationship.toLowerCase()}`, + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + relationship_started: relationship, + relationship_ended: relationship, + }; + + const parsed = parseOcfEntityInput('stakeholderRelationshipChangeEvent', input); + + expect(parsed.relationship_started).toBe(relationship); + expect(parsed.relationship_ended).toBe(relationship); + }); + + it.each(['advisor', ' ADVISOR', 'ADVISOR '])( + 'rejects the non-canonical value %j instead of coercing it', + (value) => { + const input = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-invalid-enum', + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + relationship_started: value, + }; + + expect(() => parseOcfEntityInput('stakeholderRelationshipChangeEvent', input)).toThrow(OcpValidationError); + } + ); + + it('rejects the legacy new_relationships field on a canonical event', () => { + const input = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-with-legacy-field', + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + relationship_started: 'ADVISOR', + new_relationships: ['FOUNDER'], + }; + + const parseCanonicalWithLegacyField = () => parseOcfEntityInput('stakeholderRelationshipChangeEvent', input); + expect(parseCanonicalWithLegacyField).toThrow(OcpValidationError); + expect(parseCanonicalWithLegacyField).toThrow('new_relationships'); + }); + }); + it('rejects stock consolidation legacy resulting_security_ids field', () => { const fixture = stripSourceMetadata(loadSyntheticFixture>('stockConsolidation')); const legacyFixture: Record = { diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index c29816c6..4f1eb24b 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -2,6 +2,7 @@ * Tests for PlanSecurity to EquityCompensation alias functionality. */ +import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; import { isLegacyObjectType, isPlanSecurityEntityType, @@ -487,7 +488,24 @@ describe('PlanSecurity alias utilities', () => { new_relationships: ['UNKNOWN_RELATIONSHIP'], }; - expect(() => normalizeOcfData(input)).toThrow('unknown relationship'); + expect(() => parseOcfObject(input)).toThrow('relationship_started'); + }); + + it('leaves canonical stakeholder relationship events untouched for strict validation', () => { + const input = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'event-1', + date: '2024-01-15', + stakeholder_id: 'stakeholder-1', + relationship_started: ' advisor ', + new_relationships: ['INVESTOR'], + }; + + const result = normalizeOcfData(input); + + expect(result).toBe(input); + expect(result.relationship_started).toBe(' advisor '); + expect(result.new_relationships).toEqual(['INVESTOR']); }); it('canonicalizes stock class conversion ratio legacy fields to conversion mechanism', async () => { From e18658274ffcfc9c1f3ee1ecd66daf156aa22619 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 18:34:36 -0400 Subject: [PATCH 2/5] Validate every stakeholder relationship type --- src/types/native.ts | 31 ++++++++++++----------- src/utils/entityValidators.ts | 13 ++-------- test/publicApi/rootExports.test.ts | 1 + test/utils/entityValidators.test.ts | 21 ++++++++++++++++ test/validation/boundaries.test.ts | 38 ++++++++++++++++++++--------- 5 files changed, 67 insertions(+), 37 deletions(-) diff --git a/src/types/native.ts b/src/types/native.ts index 6c983039..6d2b12b7 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -2005,20 +2005,23 @@ export interface OcfFinancing extends OcfObjectBase<'FINANCING'> { * Type - Stakeholder Relationship Type The type of relationship a stakeholder has with the issuer OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/enums/StakeholderRelationshipType.schema.json */ -export type StakeholderRelationshipType = - | 'ADVISOR' - | 'BOARD_MEMBER' - | 'CONSULTANT' - | 'EMPLOYEE' - | 'EX_ADVISOR' - | 'EX_CONSULTANT' - | 'EX_EMPLOYEE' - | 'EXECUTIVE' - | 'FOUNDER' - | 'INVESTOR' - | 'NON_US_EMPLOYEE' - | 'OFFICER' - | 'OTHER'; +export const STAKEHOLDER_RELATIONSHIP_TYPES = [ + 'ADVISOR', + 'BOARD_MEMBER', + 'CONSULTANT', + 'EMPLOYEE', + 'EX_ADVISOR', + 'EX_CONSULTANT', + 'EX_EMPLOYEE', + 'EXECUTIVE', + 'FOUNDER', + 'INVESTOR', + 'NON_US_EMPLOYEE', + 'OFFICER', + 'OTHER', +] as const; + +export type StakeholderRelationshipType = (typeof STAKEHOLDER_RELATIONSHIP_TYPES)[number]; /** * Type - Stakeholder Status The current status of a stakeholder's engagement with the issuer OCF: diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index a51eb540..43c23550 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -16,6 +16,7 @@ import { OcpErrorCodes, OcpValidationError } from '../errors'; import type { Address, Email, Monetary, Phone } from '../types'; +import { STAKEHOLDER_RELATIONSHIP_TYPES } from '../types/native'; import { validateEnum, validateOptionalArray, @@ -52,16 +53,6 @@ const STAKEHOLDER_STATUSES = [ 'TERMINATION_INVOLUNTARY_WITH_CAUSE', ] as const; -const STAKEHOLDER_RELATIONSHIPS = [ - 'EMPLOYEE', - 'ADVISOR', - 'INVESTOR', - 'FOUNDER', - 'BOARD_MEMBER', - 'OFFICER', - 'OTHER', -] as const; - // ===== Helper Validators ===== /** @@ -380,7 +371,7 @@ export function validateStakeholderData(data: unknown, fieldPath: string): void } const relationships = value.current_relationships; for (let i = 0; i < relationships.length; i++) { - validateEnum(relationships[i], `${fieldPath}.current_relationships[${i}]`, STAKEHOLDER_RELATIONSHIPS); + validateEnum(relationships[i], `${fieldPath}.current_relationships[${i}]`, STAKEHOLDER_RELATIONSHIP_TYPES); } } diff --git a/test/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts index 3426336f..c1d63276 100644 --- a/test/publicApi/rootExports.test.ts +++ b/test/publicApi/rootExports.test.ts @@ -19,6 +19,7 @@ describe('package root exports', () => { 'OcpParseError', 'OcpValidationError', 'SCRATCHNET_PRESET', + 'STAKEHOLDER_RELATIONSHIP_TYPES', 'TESTNET_PRESET', 'applyCommandContext', 'authorizeIssuer', diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 4aa1e1c5..10b4093a 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -2,6 +2,7 @@ * Unit tests for entity validators. */ import { OcpValidationError } from '../../src/errors'; +import { STAKEHOLDER_RELATIONSHIP_TYPES } from '../../src/types'; import { validateAddress, validateContactInfo, @@ -349,6 +350,26 @@ describe('Entity Validators', () => { validateStakeholderData({ ...validStakeholder, current_status: 'ACTIVE' }, 'stakeholder') ).not.toThrow(); }); + + it.each(STAKEHOLDER_RELATIONSHIP_TYPES)('passes for canonical current_relationships value %s', (relationship) => { + expect(() => + validateStakeholderData({ ...validStakeholder, current_relationships: [relationship] }, 'stakeholder') + ).not.toThrow(); + }); + + it('rejects an unknown current_relationships value with its structured field path', () => { + try { + validateStakeholderData( + { ...validStakeholder, current_relationships: ['ADVISOR', 'UNKNOWN_RELATIONSHIP'] }, + 'stakeholder' + ); + throw new Error('Expected validateStakeholderData to reject an unknown relationship'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect((error as OcpValidationError).fieldPath).toBe('stakeholder.current_relationships[1]'); + expect((error as OcpValidationError).receivedValue).toBe('UNKNOWN_RELATIONSHIP'); + } + }); }); describe('validateStockClassData', () => { diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index 76bbdfc5..55a04969 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -5,8 +5,9 @@ * special edge cases. */ +import { OcpValidationError } from '../../src/errors'; import { stakeholderDataToDaml } from '../../src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml'; -import type { OcfStakeholder } from '../../src/types'; +import { STAKEHOLDER_RELATIONSHIP_TYPES, type OcfStakeholder } from '../../src/types'; import { damlStakeholderRelationshipToNative, type DamlStakeholderRelationshipType, @@ -189,23 +190,31 @@ describe('Boundary Condition Tests', () => { expect(stakeholderDataToDaml(institution).stakeholder_type).toBe('OcfStakeholderTypeInstitution'); }); - test('relationship types are normalized correctly', () => { + test('all canonical relationship types are converted to DAML', () => { const dataWithRelationships: OcfStakeholder = { id: 'sh-relationships', object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', - current_relationships: ['EMPLOYEE', 'INVESTOR', 'FOUNDER', 'BOARD_MEMBER', 'ADVISOR', 'OFFICER', 'OTHER'], + current_relationships: [...STAKEHOLDER_RELATIONSHIP_TYPES], }; const result = stakeholderDataToDaml(dataWithRelationships); - expect(result.current_relationships).toContain('OcfRelEmployee'); - expect(result.current_relationships).toContain('OcfRelInvestor'); - expect(result.current_relationships).toContain('OcfRelFounder'); - expect(result.current_relationships).toContain('OcfRelBoardMember'); - expect(result.current_relationships).toContain('OcfRelAdvisor'); - expect(result.current_relationships).toContain('OcfRelOfficer'); - expect(result.current_relationships).toContain('OcfRelOther'); + expect(result.current_relationships).toEqual([ + 'OcfRelAdvisor', + 'OcfRelBoardMember', + 'OcfRelConsultant', + 'OcfRelEmployee', + 'OcfRelExAdvisor', + 'OcfRelExConsultant', + 'OcfRelExEmployee', + 'OcfRelExecutive', + 'OcfRelFounder', + 'OcfRelInvestor', + 'OcfRelNonUsEmployee', + 'OcfRelOfficer', + 'OcfRelOther', + ]); }); test('fails fast for invalid current_relationships values', () => { @@ -217,8 +226,13 @@ describe('Boundary Condition Tests', () => { current_relationships: ['INVALID_RELATIONSHIP' as never], }; - expect(() => stakeholderDataToDaml(invalidRelationshipArrayData)).toThrow(); - expect(() => stakeholderDataToDaml(invalidRelationshipArrayData)).toThrow('current_relationships[0]'); + try { + stakeholderDataToDaml(invalidRelationshipArrayData); + throw new Error('Expected stakeholderDataToDaml to reject an unknown relationship'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect((error as OcpValidationError).fieldPath).toBe('stakeholder.current_relationships[0]'); + } }); }); From 7c05ca8048608ec8cfd9cd05b721e8726ca9f8f7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:23:52 -0400 Subject: [PATCH 3/5] fix: preserve stakeholder relationship order --- ...holderRelationshipChangeEventDataToDaml.ts | 36 +++-- src/types/native.ts | 4 +- src/utils/entityValidators.ts | 15 +- src/utils/ocfComparison.ts | 30 ++++ src/utils/ocfZodSchemas.ts | 56 +++++++ src/utils/planSecurityAliases.ts | 148 ++++++++---------- .../stakeholderRelationships.types.ts | 35 +++++ .../functions/stakeholderEventReaders.test.ts | 101 +++++++++++- test/publicApi/rootExports.test.ts | 8 + test/types/stakeholderRelationships.types.ts | 35 +++++ test/utils/entityValidators.test.ts | 49 +++++- test/utils/ocfComparison.test.ts | 18 +++ test/utils/ocfZodSchemas.test.ts | 61 +++++++- test/utils/planSecurityAliases.test.ts | 52 ++++-- test/validation/boundaries.test.ts | 15 ++ 15 files changed, 553 insertions(+), 110 deletions(-) create mode 100644 test/declarations/stakeholderRelationships.types.ts create mode 100644 test/types/stakeholderRelationships.types.ts diff --git a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts index 42f93a9a..55400c34 100644 --- a/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts +++ b/src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml.ts @@ -2,10 +2,11 @@ * OCF to DAML converter for StakeholderRelationshipChangeEvent. */ -import { OcpValidationError } from '../../../errors'; -import type { OcfStakeholderRelationshipChangeEvent } from '../../../types/native'; +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import { STAKEHOLDER_RELATIONSHIP_TYPES, type OcfStakeholderRelationshipChangeEvent } from '../../../types/native'; import { stakeholderRelationshipTypeToDaml } from '../../../utils/enumConversions'; import { cleanComments, dateStringToDAMLTime } from '../../../utils/typeConversions'; +import { validateEnum, validateRequiredString } from '../../../utils/validation'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; /** @@ -17,22 +18,35 @@ import type { DamlDataTypeFor } from '../capTable/batchTypes'; export function stakeholderRelationshipChangeEventDataToDaml( data: OcfStakeholderRelationshipChangeEvent ): DamlDataTypeFor<'stakeholderRelationshipChangeEvent'> { - if (!data.id) { - throw new OcpValidationError('stakeholderRelationshipChangeEvent.id', 'Required field is missing or empty', { - expectedType: 'string', - receivedValue: data.id, - }); - } + const path = 'stakeholderRelationshipChangeEvent'; + validateRequiredString(data.id, `${path}.id`); + validateRequiredString(data.stakeholder_id, `${path}.stakeholder_id`); const relationshipStarted = data.relationship_started; const relationshipEnded = data.relationship_ended; + const hasRelationshipStarted = relationshipStarted !== undefined; + const hasRelationshipEnded = relationshipEnded !== undefined; + + if (!hasRelationshipStarted && !hasRelationshipEnded) { + throw new OcpValidationError(path, 'One of relationship_started or relationship_ended is required', { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'relationship_started or relationship_ended', + receivedValue: data, + }); + } + if (hasRelationshipStarted) { + validateEnum(relationshipStarted, `${path}.relationship_started`, STAKEHOLDER_RELATIONSHIP_TYPES); + } + if (hasRelationshipEnded) { + validateEnum(relationshipEnded, `${path}.relationship_ended`, STAKEHOLDER_RELATIONSHIP_TYPES); + } return { id: data.id, - date: dateStringToDAMLTime(data.date), + date: dateStringToDAMLTime(data.date, `${path}.date`), stakeholder_id: data.stakeholder_id, - relationship_started: relationshipStarted ? stakeholderRelationshipTypeToDaml(relationshipStarted) : null, - relationship_ended: relationshipEnded ? stakeholderRelationshipTypeToDaml(relationshipEnded) : null, + relationship_started: hasRelationshipStarted ? stakeholderRelationshipTypeToDaml(relationshipStarted) : null, + relationship_ended: hasRelationshipEnded ? stakeholderRelationshipTypeToDaml(relationshipEnded) : null, comments: cleanComments(data.comments), }; } diff --git a/src/types/native.ts b/src/types/native.ts index 6d2b12b7..32a9f90d 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -2005,7 +2005,7 @@ export interface OcfFinancing extends OcfObjectBase<'FINANCING'> { * Type - Stakeholder Relationship Type The type of relationship a stakeholder has with the issuer OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/enums/StakeholderRelationshipType.schema.json */ -export const STAKEHOLDER_RELATIONSHIP_TYPES = [ +export const STAKEHOLDER_RELATIONSHIP_TYPES = Object.freeze([ 'ADVISOR', 'BOARD_MEMBER', 'CONSULTANT', @@ -2019,7 +2019,7 @@ export const STAKEHOLDER_RELATIONSHIP_TYPES = [ 'NON_US_EMPLOYEE', 'OFFICER', 'OTHER', -] as const; +] as const); export type StakeholderRelationshipType = (typeof STAKEHOLDER_RELATIONSHIP_TYPES)[number]; diff --git a/src/utils/entityValidators.ts b/src/utils/entityValidators.ts index 43c23550..89915f7c 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -361,7 +361,7 @@ export function validateStakeholderData(data: unknown, fieldPath: string): void validateOptionalString(value.issuer_assigned_id, `${fieldPath}.issuer_assigned_id`); // Optional current_relationships array - if (value.current_relationships !== undefined && value.current_relationships !== null) { + if (value.current_relationships !== undefined) { if (!Array.isArray(value.current_relationships)) { throw new OcpValidationError(`${fieldPath}.current_relationships`, 'Must be an array if provided', { expectedType: 'array', @@ -370,8 +370,21 @@ export function validateStakeholderData(data: unknown, fieldPath: string): void }); } const relationships = value.current_relationships; + const seenRelationships = new Set(); for (let i = 0; i < relationships.length; i++) { validateEnum(relationships[i], `${fieldPath}.current_relationships[${i}]`, STAKEHOLDER_RELATIONSHIP_TYPES); + if (seenRelationships.has(relationships[i])) { + throw new OcpValidationError( + `${fieldPath}.current_relationships[${i}]`, + 'Stakeholder relationships must not contain duplicate values', + { + expectedType: 'unique canonical stakeholder relationship', + receivedValue: relationships[i], + code: OcpErrorCodes.INVALID_FORMAT, + } + ); + } + seenRelationships.add(relationships[i]); } } diff --git a/src/utils/ocfComparison.ts b/src/utils/ocfComparison.ts index c7dd98cd..9114ee2e 100644 --- a/src/utils/ocfComparison.ts +++ b/src/utils/ocfComparison.ts @@ -202,6 +202,28 @@ function isSchemaDefaultEquivalent(path: string, valA: unknown, valB: unknown): return (valA === false && isUndefinedLike(valB)) || (valB === false && isUndefinedLike(valA)); } +/** + * Compare canonical stakeholder relationships as an unordered set without ever + * rewriting the application payload. Duplicate-bearing arrays deliberately do + * not use set semantics: duplicates are invalid at typed write boundaries and + * must remain visible as a comparison difference. + */ +function compareStakeholderRelationshipSets( + path: string, + valA: readonly unknown[], + valB: readonly unknown[] +): boolean | undefined { + if (path !== 'current_relationships' && !path.endsWith('.current_relationships')) return undefined; + if (!valA.every((value): value is string => typeof value === 'string')) return undefined; + if (!valB.every((value): value is string => typeof value === 'string')) return undefined; + + const setA = new Set(valA); + const setB = new Set(valB); + if (setA.size !== valA.length || setB.size !== valB.length) return undefined; + if (setA.size !== setB.size) return false; + return [...setA].every((relationship) => setB.has(relationship)); +} + /** * Deep equality comparison for OCF objects with normalization. * @@ -285,6 +307,14 @@ export function ocfCompare(a: unknown, b: unknown, options?: OcfComparisonOption // Handle arrays if (Array.isArray(valA) && Array.isArray(valB)) { + const relationshipSetsEqual = compareStakeholderRelationshipSets(path, valA, valB); + if (relationshipSetsEqual !== undefined) { + if (!relationshipSetsEqual) { + differences.push(`${path}: stakeholder relationship sets differ`); + } + return relationshipSetsEqual; + } + if (valA.length !== valB.length) { // Check if difference is just undefined-like elements const filteredA = valA.filter((v) => !isUndefinedLike(v)); diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index b1bd32e8..68b2c2c6 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -10,6 +10,7 @@ import { type OcfDataTypeFor, type OcfEntityType, } from '../functions/OpenCapTable/capTable/batchTypes'; +import { STAKEHOLDER_RELATIONSHIP_TYPES } from '../types/native'; import { normalizeOcfData } from './planSecurityAliases'; /** @@ -461,6 +462,60 @@ function hasPresentField(value: Record, field: string): boolean /** Enforce canonical SDK invariants that are stricter than compatibility-oriented OCF schemas. */ function validateCanonicalSemanticRefinements(value: Record): void { + if (value.object_type === 'STAKEHOLDER') { + if (value.current_relationships === undefined) return; + if (!Array.isArray(value.current_relationships)) { + throw new OcpValidationError('current_relationships', 'current_relationships must be an array', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'array of canonical stakeholder relationships', + receivedValue: value.current_relationships, + }); + } + const seenRelationships = new Set(); + for (let index = 0; index < value.current_relationships.length; index += 1) { + const relationship = value.current_relationships[index]; + const fieldPath = `current_relationships[${index}]`; + if (typeof relationship !== 'string') { + throw new OcpValidationError(fieldPath, 'Stakeholder relationship must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'canonical stakeholder relationship string', + receivedValue: relationship, + }); + } + if (!(STAKEHOLDER_RELATIONSHIP_TYPES as readonly string[]).includes(relationship)) { + throw new OcpValidationError(fieldPath, 'Unknown stakeholder relationship value', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: STAKEHOLDER_RELATIONSHIP_TYPES.join(' | '), + receivedValue: relationship, + }); + } + if (seenRelationships.has(relationship)) { + throw new OcpValidationError(fieldPath, 'Stakeholder relationships must not contain duplicate values', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'unique canonical stakeholder relationship', + receivedValue: relationship, + }); + } + seenRelationships.add(relationship); + } + return; + } + + if (value.object_type === 'CE_STAKEHOLDER_RELATIONSHIP') { + if (value.relationship_started === undefined && value.relationship_ended === undefined) { + throw new OcpValidationError( + 'stakeholderRelationshipChangeEvent', + 'One of relationship_started or relationship_ended is required', + { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'relationship_started or relationship_ended', + receivedValue: value, + } + ); + } + return; + } + if (value.object_type !== 'TX_EQUITY_COMPENSATION_ISSUANCE') return; const compensationType = value.compensation_type; @@ -513,6 +568,7 @@ function validateCanonicalSemanticRefinements(value: Record): v const NON_CANONICAL_PUBLIC_FIELDS: Readonly> = { STAKEHOLDER: ['current_relationship'], + CE_STAKEHOLDER_RELATIONSHIP: ['new_relationships'], TX_STOCK_CONVERSION: ['quantity'], TX_EQUITY_COMPENSATION_RELEASE: ['balance_security_id'], TX_STOCK_CLASS_SPLIT: [ diff --git a/src/utils/planSecurityAliases.ts b/src/utils/planSecurityAliases.ts index 38a75a53..6d7a2d1d 100644 --- a/src/utils/planSecurityAliases.ts +++ b/src/utils/planSecurityAliases.ts @@ -1,4 +1,5 @@ -import type { CompensationType } from '../types/native'; +import { OcpErrorCodes, OcpValidationError } from '../errors'; +import { STAKEHOLDER_RELATIONSHIP_TYPES, type CompensationType } from '../types/native'; import { normalizeNumericString } from './typeConversions'; /** @@ -149,18 +150,6 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } -function hasStakeholderPayloadShape(value: Record): boolean { - const { name } = value; - return ( - isNonEmptyString(value.id) && - typeof name === 'object' && - name !== null && - isNonEmptyString((name as Record).legal_name) && - typeof value.stakeholder_type === 'string' && - ['INDIVIDUAL', 'INSTITUTION'].includes(value.stakeholder_type) - ); -} - function hasStockPlanPayloadShape(value: Record): boolean { const hasStockClassIds = Array.isArray(value.stock_class_ids) && value.stock_class_ids.length > 0; const hasDeprecatedStockClassId = isNonEmptyString(value.stock_class_id); @@ -329,28 +318,57 @@ function normalizeStakeholderRelationshipChangeEvent(data: Record { + const normalizedRelationships = legacyRelationships.map((relationship, index) => { + const fieldPath = `stakeholderRelationshipChangeEvent.new_relationships[${index}]`; if (typeof relationship !== 'string') { - throw new Error(`Invalid new_relationships entry: expected string, got ${typeof relationship}`); + throw new OcpValidationError(fieldPath, 'Legacy relationship value must be a string', { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'canonical stakeholder relationship string', + receivedValue: relationship, + }); } const trimmed = relationship.trim().toUpperCase(); - if (!trimmed) { - throw new Error('Invalid new_relationships entry: empty string'); + if (!(STAKEHOLDER_RELATIONSHIP_TYPES as readonly string[]).includes(trimmed)) { + throw new OcpValidationError(fieldPath, 'Unknown stakeholder relationship value', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: STAKEHOLDER_RELATIONSHIP_TYPES.join(' | '), + receivedValue: relationship, + }); } return trimmed; }); if (normalizedRelationships.length > 1) { - throw new Error( - 'Invalid stakeholder relationship change event: legacy new_relationships with multiple entries is ambiguous; provide canonical relationship_started/relationship_ended fields' + throw new OcpValidationError( + 'stakeholderRelationshipChangeEvent.new_relationships', + 'legacy new_relationships with multiple entries is ambiguous; provide canonical relationship_started/relationship_ended fields', + { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'array with at most one relationship', + receivedValue: legacyRelationships, + } ); } @@ -364,15 +382,37 @@ function normalizeStakeholderRelationshipChangeEvent(data: Record): Record): Record { - const isStakeholderObject = data.object_type === 'STAKEHOLDER' || hasStakeholderPayloadShape(data); - if (!isStakeholderObject) return data; - - const relationshipsValue = data.current_relationships; - if (relationshipsValue !== undefined && !Array.isArray(relationshipsValue)) { - throw new Error(`Invalid stakeholder current_relationships: expected array, got ${typeof relationshipsValue}`); - } - - if (Array.isArray(relationshipsValue)) { - const normalizedRelationships: string[] = []; - for (const value of relationshipsValue) { - if (typeof value !== 'string') { - throw new Error(`Invalid stakeholder current_relationships entry: expected string, got ${typeof value}`); - } - const trimmed = value.trim(); - if (trimmed.length === 0) { - throw new Error('Invalid stakeholder current_relationships entry: empty string'); - } - normalizedRelationships.push(trimmed); - } - - const uniqueSortedRelationships = Array.from(new Set(normalizedRelationships)).sort(); - const alreadyNormalized = - uniqueSortedRelationships.length === relationshipsValue.length && - uniqueSortedRelationships.every((value, index) => value === relationshipsValue[index]); - if (alreadyNormalized) return data; - - return { - ...data, - current_relationships: uniqueSortedRelationships, - }; - } - - return data; -} - /** * Normalize StockPlan stock class ID fields for consistent comparison. * @@ -683,10 +677,9 @@ export function deepNormalizeNumericStrings(value: unknown): unknown { * 2. Normalizes quantity_source based on quantity presence (see normalizeQuantitySource) * 3. Strips Document fields that the DAML contract does not model (e.g. `date`) * 4. Canonicalizes deprecated issuance aliases (`plan_security_type`/`option_grant_type`) - * 5. Normalizes canonical Stakeholder relationship ordering - * 6. Canonicalizes StockPlan class IDs (`stock_class_id` -> `stock_class_ids`) - * 7. Canonicalizes StockClassConversionRatioAdjustment legacy ratio fields - * 8. Normalizes numeric string formatting (strips trailing zeros from decimals) + * 5. Canonicalizes StockPlan class IDs (`stock_class_id` -> `stock_class_ids`) + * 6. Canonicalizes StockClassConversionRatioAdjustment legacy ratio fields + * 7. Normalizes numeric string formatting (strips trailing zeros from decimals) * * @param data - The OCF data object that may contain an object_type field * @returns The data with normalized fields (shallow copy if modified) @@ -735,9 +728,6 @@ export function normalizeOcfData(data: object): Record { // Canonicalize deprecated option_grant_type to compensation_type result = normalizeOptionGrantType(result); - // Normalize canonical stakeholder relationship ordering - result = normalizeStakeholderRelationships(result); - // Canonicalize deprecated/current stock plan class ID fields result = normalizeStockPlanClassIds(result); diff --git a/test/declarations/stakeholderRelationships.types.ts b/test/declarations/stakeholderRelationships.types.ts new file mode 100644 index 00000000..3c58b8b2 --- /dev/null +++ b/test/declarations/stakeholderRelationships.types.ts @@ -0,0 +1,35 @@ +/** Compile-time contracts for built stakeholder relationship declarations. */ + +import { STAKEHOLDER_RELATIONSHIP_TYPES, type StakeholderRelationshipType } from '../../dist'; + +type ExpectedRelationshipTuple = readonly [ + 'ADVISOR', + 'BOARD_MEMBER', + 'CONSULTANT', + 'EMPLOYEE', + 'EX_ADVISOR', + 'EX_CONSULTANT', + 'EX_EMPLOYEE', + 'EXECUTIVE', + 'FOUNDER', + 'INVESTOR', + 'NON_US_EMPLOYEE', + 'OFFICER', + 'OTHER', +]; +type Assert = T; +type IsExactly = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +const exactTuple: Assert> = true; +const exactUnion: Assert> = true; + +// @ts-expect-error built tuple declarations remain readonly +STAKEHOLDER_RELATIONSHIP_TYPES[0] = 'OTHER'; +// @ts-expect-error built tuple declarations cannot be extended +STAKEHOLDER_RELATIONSHIP_TYPES.push('ADVISOR'); +// @ts-expect-error built declarations reject legacy relationship aliases +const legacyRelationship: StakeholderRelationshipType = 'DIRECTOR'; + +void exactTuple; +void exactUnion; +void legacyRelationship; diff --git a/test/functions/stakeholderEventReaders.test.ts b/test/functions/stakeholderEventReaders.test.ts index 8ce789a8..47fcdfbe 100644 --- a/test/functions/stakeholderEventReaders.test.ts +++ b/test/functions/stakeholderEventReaders.test.ts @@ -8,10 +8,18 @@ import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; -import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertToOcf, getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { damlStakeholderRelationshipChangeEventToNative } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; +import { stakeholderRelationshipChangeEventDataToDaml } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; -import type { OcfStakeholderRelationshipChangeEvent, OcfStakeholderStatusChangeEvent } from '../../src/types/native'; +import { OcpClient } from '../../src/OcpClient'; +import { + STAKEHOLDER_RELATIONSHIP_TYPES, + type OcfStakeholderRelationshipChangeEvent, + type OcfStakeholderStatusChangeEvent, +} from '../../src/types/native'; +import { stakeholderRelationshipTypeToDaml } from '../../src/utils/enumConversions'; type StakeholderEventEntityType = Extract< OcfEntityType, @@ -20,6 +28,11 @@ type StakeholderEventEntityType = Extract< type StakeholderEvent = OcfStakeholderRelationshipChangeEvent | OcfStakeholderStatusChangeEvent; +const VALID_CONTEXT = { + issuer: 'issuer::party', + system_operator: 'system-operator::party', +} as const; + interface StakeholderEventReaderCase { readonly entityType: StakeholderEventEntityType; readonly contractId: string; @@ -108,7 +121,7 @@ function createMockClient( ): { readonly client: LedgerJsonApiClient; readonly getEventsByContractId: jest.Mock } { const createArgument = Object.prototype.hasOwnProperty.call(options, 'createArgument') ? options.createArgument - : { [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data }; + : { context: VALID_CONTEXT, [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data }; const templateId = options.templateId === undefined ? ENTITY_TEMPLATE_ID_MAP[testCase.entityType] : options.templateId; const getEventsByContractId = jest.fn().mockResolvedValue({ @@ -348,6 +361,88 @@ describe('decoder-backed stakeholder event readers', () => { }); describe('stakeholder relationship change semantics', () => { + it.each(STAKEHOLDER_RELATIONSHIP_TYPES)( + 'preserves canonical relationship %s through direct, generic, namespace, and object-type reads', + async (relationship) => { + const damlRelationship = stakeholderRelationshipTypeToDaml(relationship); + const data = relationshipData({ relationship_started: damlRelationship, relationship_ended: null }); + const expected = { ...relationshipCase.expectedEvent, relationship_started: relationship }; + + expect( + damlStakeholderRelationshipChangeEventToNative( + data as unknown as Parameters[0] + ) + ).toEqual(expected); + expect( + convertToOcf( + 'stakeholderRelationshipChangeEvent', + data as unknown as Parameters[0] + ) + ).toEqual(expected); + + const { client } = createMockClient(relationshipCase, data); + await expect(getEntityAsOcf(client, relationshipCase.entityType, relationshipCase.contractId)).resolves.toEqual({ + data: expected, + contractId: relationshipCase.contractId, + }); + + const ocp = new OcpClient({ ledger: client }); + await expect( + ocp.OpenCapTable.stakeholderRelationshipChangeEvent.get({ contractId: relationshipCase.contractId }) + ).resolves.toEqual({ data: expected, contractId: relationshipCase.contractId }); + await expect( + ocp.OpenCapTable.getByObjectType({ + objectType: 'CE_STAKEHOLDER_RELATIONSHIP', + contractId: relationshipCase.contractId, + }) + ).resolves.toEqual({ data: expected, contractId: relationshipCase.contractId }); + } + ); + + it.each(STAKEHOLDER_RELATIONSHIP_TYPES)('writes canonical relationship %s without alias coercion', (relationship) => { + const result = stakeholderRelationshipChangeEventDataToDaml({ + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-write', + date: '2026-07-10', + stakeholder_id: 'stakeholder-1', + relationship_started: relationship, + }); + + expect(result.relationship_started).toBe(stakeholderRelationshipTypeToDaml(relationship)); + expect(result.relationship_ended).toBeNull(); + }); + + it.each([ + [{}, OcpErrorCodes.REQUIRED_FIELD_MISSING, 'stakeholderRelationshipChangeEvent'], + [ + { relationship_started: null }, + OcpErrorCodes.INVALID_TYPE, + 'stakeholderRelationshipChangeEvent.relationship_started', + ], + [ + { relationship_started: 'advisor' }, + OcpErrorCodes.INVALID_FORMAT, + 'stakeholderRelationshipChangeEvent.relationship_started', + ], + ] as const)('rejects invalid direct relationship writer input %j', (overrides, code, fieldPath) => { + const input = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-write-invalid', + date: '2026-07-10', + stakeholder_id: 'stakeholder-1', + ...overrides, + }; + + const write = () => + stakeholderRelationshipChangeEventDataToDaml(input as unknown as OcfStakeholderRelationshipChangeEvent); + expect(write).toThrow(OcpValidationError); + try { + write(); + } catch (error) { + expect(error).toMatchObject({ code, fieldPath }); + } + }); + it('reads a started-only relationship change', async () => { const { client } = createMockClient(relationshipCase, relationshipData()); diff --git a/test/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts index c1d63276..9ffa784b 100644 --- a/test/publicApi/rootExports.test.ts +++ b/test/publicApi/rootExports.test.ts @@ -55,4 +55,12 @@ describe('package root exports', () => { 'withdrawAuthorization', ]); }); + + it('exports an immutable canonical stakeholder relationship tuple', () => { + expect(Object.isFrozen(sdk.STAKEHOLDER_RELATIONSHIP_TYPES)).toBe(true); + expect(sdk.STAKEHOLDER_RELATIONSHIP_TYPES).toHaveLength(13); + expect(() => (sdk.STAKEHOLDER_RELATIONSHIP_TYPES as unknown as string[]).push('LEGACY_RELATIONSHIP')).toThrow( + TypeError + ); + }); }); diff --git a/test/types/stakeholderRelationships.types.ts b/test/types/stakeholderRelationships.types.ts new file mode 100644 index 00000000..59c2a217 --- /dev/null +++ b/test/types/stakeholderRelationships.types.ts @@ -0,0 +1,35 @@ +/** Compile-time contracts for canonical source stakeholder relationships. */ + +import { STAKEHOLDER_RELATIONSHIP_TYPES, type StakeholderRelationshipType } from '../../src'; + +type ExpectedRelationshipTuple = readonly [ + 'ADVISOR', + 'BOARD_MEMBER', + 'CONSULTANT', + 'EMPLOYEE', + 'EX_ADVISOR', + 'EX_CONSULTANT', + 'EX_EMPLOYEE', + 'EXECUTIVE', + 'FOUNDER', + 'INVESTOR', + 'NON_US_EMPLOYEE', + 'OFFICER', + 'OTHER', +]; +type Assert = T; +type IsExactly = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +const exactTuple: Assert> = true; +const exactUnion: Assert> = true; + +// @ts-expect-error the canonical tuple is readonly +STAKEHOLDER_RELATIONSHIP_TYPES[0] = 'OTHER'; +// @ts-expect-error the canonical tuple cannot be extended +STAKEHOLDER_RELATIONSHIP_TYPES.push('ADVISOR'); +// @ts-expect-error legacy relationship aliases are not part of the canonical union +const legacyRelationship: StakeholderRelationshipType = 'DIRECTOR'; + +void exactTuple; +void exactUnion; +void legacyRelationship; diff --git a/test/utils/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 10b4093a..3d03f2a5 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for entity validators. */ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { STAKEHOLDER_RELATIONSHIP_TYPES } from '../../src/types'; import { validateAddress, @@ -370,6 +370,53 @@ describe('Entity Validators', () => { expect((error as OcpValidationError).receivedValue).toBe('UNKNOWN_RELATIONSHIP'); } }); + + it.each([ + [null, 'stakeholder.current_relationships'], + ['ADVISOR', 'stakeholder.current_relationships'], + [['ADVISOR', 7], 'stakeholder.current_relationships[1]'], + ] as const)('rejects invalid current_relationships shape %j as INVALID_TYPE', (relationships, fieldPath) => { + try { + validateStakeholderData({ ...validStakeholder, current_relationships: relationships }, 'stakeholder'); + throw new Error('Expected invalid current_relationships to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, fieldPath }); + } + }); + + it.each([' advisor ', 'advisor', 'UNKNOWN_RELATIONSHIP'])( + 'rejects non-canonical current_relationships value %j as INVALID_FORMAT', + (relationship) => { + try { + validateStakeholderData({ ...validStakeholder, current_relationships: [relationship] }, 'stakeholder'); + throw new Error('Expected a non-canonical relationship to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stakeholder.current_relationships[0]', + receivedValue: relationship, + }); + } + } + ); + + it('rejects duplicate current_relationships without changing their order', () => { + const relationships = ['INVESTOR', 'FOUNDER', 'INVESTOR']; + try { + validateStakeholderData({ ...validStakeholder, current_relationships: relationships }, 'stakeholder'); + throw new Error('Expected duplicate relationships to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stakeholder.current_relationships[2]', + receivedValue: 'INVESTOR', + }); + } + expect(relationships).toEqual(['INVESTOR', 'FOUNDER', 'INVESTOR']); + }); }); describe('validateStockClassData', () => { diff --git a/test/utils/ocfComparison.test.ts b/test/utils/ocfComparison.test.ts index c49e9b06..5dfefa1a 100644 --- a/test/utils/ocfComparison.test.ts +++ b/test/utils/ocfComparison.test.ts @@ -30,6 +30,24 @@ describe('ocfDeepEqual', () => { expect(ocfDeepEqual({ quantity: '100' }, { quantity: '200' })).toBe(false); }); + test('compares canonical stakeholder relationships as an unordered set', () => { + expect( + ocfDeepEqual( + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR', 'FOUNDER'] }, + { object_type: 'STAKEHOLDER', current_relationships: ['FOUNDER', 'INVESTOR'] } + ) + ).toBe(true); + }); + + test('does not hide duplicate stakeholder relationships behind set comparison', () => { + expect( + ocfDeepEqual( + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR', 'INVESTOR'] }, + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR'] } + ) + ).toBe(false); + }); + test('returns true for number vs equivalent numeric string (DB JSONB vs DAML readback)', () => { // DB JSONB stores numbers as JS numbers, DAML readback returns strings expect(ocfDeepEqual({ value: 100 }, { value: '100' })).toBe(true); diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index d1e8aad5..728712b9 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -1,4 +1,4 @@ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { ENTITY_OBJECT_TYPE_MAP, ENTITY_REGISTRY, @@ -256,7 +256,64 @@ describe('ocfZodSchemas', () => { }); expect(parseMixedRepresentation).toThrow(OcpValidationError); - expect(parseMixedRepresentation).toThrow('cannot mix legacy new_relationships'); + expect(captureValidationError(parseMixedRepresentation)).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stakeholderRelationshipChangeEvent', + }); + }); + + it.each([ + [null, 'stakeholderRelationshipChangeEvent.new_relationships'], + ['ADVISOR', 'stakeholderRelationshipChangeEvent.new_relationships'], + [[7], 'stakeholderRelationshipChangeEvent.new_relationships[0]'], + ] as const)('rejects invalid raw legacy relationship array %j as INVALID_TYPE', (newRelationships, fieldPath) => { + const parse = () => + parseOcfObject({ + object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', + id: 'legacy-invalid-shape', + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + new_relationships: newRelationships, + }); + + expect(captureValidationError(parse)).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE, fieldPath }); + }); + + it('rejects an unknown raw legacy relationship at its exact indexed path', () => { + const parse = () => + parseOcfObject({ + object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', + id: 'legacy-invalid-enum', + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + new_relationships: ['UNKNOWN_RELATIONSHIP'], + }); + + expect(captureValidationError(parse)).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stakeholderRelationshipChangeEvent.new_relationships[0]', + receivedValue: 'UNKNOWN_RELATIONSHIP', + }); + }); + + it.each([ + { + object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT', + id: 'legacy-missing-relationship', + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + }, + { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'canonical-missing-relationship', + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + }, + ])('reports missing relationship semantics as REQUIRED_FIELD_MISSING', (input) => { + expect(captureValidationError(() => parseOcfObject(input))).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stakeholderRelationshipChangeEvent', + }); }); describe('canonical stakeholder relationship events', () => { diff --git a/test/utils/planSecurityAliases.test.ts b/test/utils/planSecurityAliases.test.ts index 4f1eb24b..c48f2a6f 100644 --- a/test/utils/planSecurityAliases.test.ts +++ b/test/utils/planSecurityAliases.test.ts @@ -2,7 +2,8 @@ * Tests for PlanSecurity to EquityCompensation alias functionality. */ -import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { parseOcfEntityInput, parseOcfObject } from '../../src/utils/ocfZodSchemas'; import { isLegacyObjectType, isPlanSecurityEntityType, @@ -207,7 +208,7 @@ describe('PlanSecurity alias utilities', () => { expect(result).toBe(input); // Same reference - no copy needed }); - it('normalizes stakeholder current_relationships ordering and duplicates', async () => { + it('preserves stakeholder current_relationships order and duplicate visibility', () => { const input = { object_type: 'STAKEHOLDER', id: 'sh-1', @@ -217,12 +218,23 @@ describe('PlanSecurity alias utilities', () => { }; const result = normalizeOcfData(input); - await validateOcfObject(result); - expect(result.current_relationships).toEqual(['FOUNDER', 'INVESTOR']); + expect(result).toBe(input); + expect(result.current_relationships).toEqual(['INVESTOR', 'FOUNDER', 'INVESTOR']); + + expect(() => parseOcfEntityInput('stakeholder', input)).toThrow(OcpValidationError); + try { + parseOcfEntityInput('stakeholder', input); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'current_relationships[2]', + receivedValue: 'INVESTOR', + }); + } }); - it('throws for non-string entries in stakeholder current_relationships', () => { + it('rejects non-string entries in stakeholder current_relationships at the parser boundary', () => { const input = { object_type: 'STAKEHOLDER', id: 'sh-1', @@ -231,10 +243,16 @@ describe('PlanSecurity alias utilities', () => { current_relationships: ['INVESTOR', 7], }; - expect(() => normalizeOcfData(input)).toThrow('Invalid stakeholder current_relationships entry'); + expect(normalizeOcfData(input)).toBe(input); + expect(() => parseOcfEntityInput('stakeholder', input)).toThrow(OcpValidationError); + try { + parseOcfEntityInput('stakeholder', input); + } catch (error) { + expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE }); + } }); - it('throws for empty-string entries in stakeholder current_relationships', () => { + it('rejects padded entries without trimming stakeholder current_relationships', () => { const input = { object_type: 'STAKEHOLDER', id: 'sh-1', @@ -243,10 +261,16 @@ describe('PlanSecurity alias utilities', () => { current_relationships: ['INVESTOR', ' '], }; - expect(() => normalizeOcfData(input)).toThrow('Invalid stakeholder current_relationships entry'); + expect(normalizeOcfData(input)).toBe(input); + expect(() => parseOcfEntityInput('stakeholder', input)).toThrow(OcpValidationError); + try { + parseOcfEntityInput('stakeholder', input); + } catch (error) { + expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT }); + } }); - it('throws when stakeholder current_relationships is not an array', () => { + it('rejects non-array stakeholder current_relationships at the parser boundary', () => { const input = { object_type: 'STAKEHOLDER', id: 'sh-1', @@ -255,7 +279,13 @@ describe('PlanSecurity alias utilities', () => { current_relationships: 'INVESTOR', }; - expect(() => normalizeOcfData(input)).toThrow('Invalid stakeholder current_relationships: expected array'); + expect(normalizeOcfData(input)).toBe(input); + expect(() => parseOcfEntityInput('stakeholder', input)).toThrow(OcpValidationError); + try { + parseOcfEntityInput('stakeholder', input); + } catch (error) { + expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_TYPE }); + } }); it('maps stock plan stock_class_id to stock_class_ids and removes deprecated field', async () => { @@ -488,7 +518,7 @@ describe('PlanSecurity alias utilities', () => { new_relationships: ['UNKNOWN_RELATIONSHIP'], }; - expect(() => parseOcfObject(input)).toThrow('relationship_started'); + expect(() => parseOcfObject(input)).toThrow('new_relationships[0]'); }); it('leaves canonical stakeholder relationship events untouched for strict validation', () => { diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index 55a04969..1e8bd8f4 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -217,6 +217,21 @@ describe('Boundary Condition Tests', () => { ]); }); + test('relationship conversion preserves caller order and therefore the primary relationship', () => { + const dataWithRelationships: OcfStakeholder = { + id: 'sh-primary-relationship', + object_type: 'STAKEHOLDER', + name: { legal_name: 'Ordered Stakeholder' }, + stakeholder_type: 'INDIVIDUAL', + current_relationships: ['INVESTOR', 'ADVISOR', 'FOUNDER'], + }; + + const result = stakeholderDataToDaml(dataWithRelationships); + expect(result.current_relationships).toEqual(['OcfRelInvestor', 'OcfRelAdvisor', 'OcfRelFounder']); + expect(result.current_relationships[0]).toBe('OcfRelInvestor'); + expect(dataWithRelationships.current_relationships).toEqual(['INVESTOR', 'ADVISOR', 'FOUNDER']); + }); + test('fails fast for invalid current_relationships values', () => { const invalidRelationshipArrayData: OcfStakeholder = { id: 'sh-invalid-array-relationship', From e2831bcbefe54918b871bf351fb33d8554dbc047 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 03:36:44 -0400 Subject: [PATCH 4/5] fix: validate stakeholder relationship event types --- src/utils/ocfZodSchemas.ts | 15 +++++++++ test/batch/CapTableBatch.test.ts | 33 +++++++++++++++++++ .../functions/stakeholderEventReaders.test.ts | 29 ++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 68b2c2c6..ab2f9bc0 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -502,6 +502,21 @@ function validateCanonicalSemanticRefinements(value: Record): v } if (value.object_type === 'CE_STAKEHOLDER_RELATIONSHIP') { + for (const field of ['relationship_started', 'relationship_ended'] as const) { + const relationship = value[field]; + if (relationship !== undefined && typeof relationship !== 'string') { + throw new OcpValidationError( + `stakeholderRelationshipChangeEvent.${field}`, + `${field} must be a canonical stakeholder relationship string`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'canonical stakeholder relationship string', + receivedValue: relationship, + } + ); + } + } + if (value.relationship_started === undefined && value.relationship_ended === undefined) { throw new OcpValidationError( 'stakeholderRelationshipChangeEvent', diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 79a2d9fe..db1976a9 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -5,6 +5,7 @@ import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { buildUpdateCapTableCommand, CapTableBatch, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable'; import type { OcfStakeholder, + OcfStakeholderRelationshipChangeEvent, OcfStockClass, OcfStockClassConversionRatioAdjustment, OcfStockClassSplit, @@ -84,6 +85,38 @@ describe('CapTableBatch', () => { expect(batch.size).toBe(0); }); + it.each([ + ['relationship_started', null], + ['relationship_started', 7], + ['relationship_ended', null], + ['relationship_ended', 7], + ] as const)('should reject non-string stakeholder event %s at the public batch boundary', (field, value) => { + const batch = new CapTableBatch({ + capTableContractId: 'cap-table-123', + actAs: ['party-1'], + }); + const invalidEvent = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-event-invalid', + date: '2026-07-10', + stakeholder_id: 'stakeholder-1', + [field]: value, + } as unknown as OcfStakeholderRelationshipChangeEvent; + + try { + batch.create('stakeholderRelationshipChangeEvent', invalidEvent); + throw new Error('Expected invalid stakeholder relationship event to fail'); + } catch (error) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `stakeholderRelationshipChangeEvent.${field}`, + receivedValue: value, + }); + } + expect(batch.size).toBe(0); + }); + it('should reject legacy stock class split ratio fields', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', diff --git a/test/functions/stakeholderEventReaders.test.ts b/test/functions/stakeholderEventReaders.test.ts index 47fcdfbe..b6ef1048 100644 --- a/test/functions/stakeholderEventReaders.test.ts +++ b/test/functions/stakeholderEventReaders.test.ts @@ -9,6 +9,7 @@ import { type OcfEntityType, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { convertToOcf, getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { damlStakeholderRelationshipChangeEventToNative } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/damlToOcf'; import { getStakeholderRelationshipChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/getStakeholderRelationshipChangeEventAsOcf'; import { stakeholderRelationshipChangeEventDataToDaml } from '../../src/functions/OpenCapTable/stakeholderRelationshipChangeEvent/stakeholderRelationshipChangeEventDataToDaml'; @@ -443,6 +444,34 @@ describe('stakeholder relationship change semantics', () => { } }); + it.each([ + ['relationship_started', null], + ['relationship_started', 7], + ['relationship_ended', null], + ['relationship_ended', 7], + ] as const)('rejects non-string %s through the typed write dispatcher', (field, value) => { + const input = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'relationship-dispatch-invalid', + date: '2026-07-10', + stakeholder_id: 'stakeholder-1', + [field]: value, + }; + + const write = () => + convertToDaml('stakeholderRelationshipChangeEvent', input as unknown as OcfStakeholderRelationshipChangeEvent); + expect(write).toThrow(OcpValidationError); + try { + write(); + } catch (error) { + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: `stakeholderRelationshipChangeEvent.${field}`, + receivedValue: value, + }); + } + }); + it('reads a started-only relationship change', async () => { const { client } = createMockClient(relationshipCase, relationshipData()); From a3a1953314f889474e9eec4be60b9dac31b4e801 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 11:40:16 -0400 Subject: [PATCH 5/5] Harden stakeholder relationship snapshots --- scripts/check-built-cardinality.cjs | 26 +++++ src/OcpClient.ts | 6 +- .../OpenCapTable/capTable/batchTypes.ts | 1 + .../OpenCapTable/capTable/damlEntityData.ts | 2 +- .../OpenCapTable/capTable/damlToOcf.ts | 21 ++-- .../OpenCapTable/capTable/entityTypes.ts | 11 +- .../stakeholder/getStakeholderAsOcf.ts | 101 +++++++++--------- src/types/common.ts | 9 ++ src/types/output.ts | 4 +- src/utils/cantonOcfExtractor.ts | 5 +- src/utils/enumConversions.ts | 4 +- src/utils/ocfComparison.ts | 46 +++++--- src/utils/replicationHelpers.ts | 18 ++-- .../coreObjectReadValidation.test.ts | 85 +++++++++++++++ test/declarations/damlReadDispatch.types.ts | 4 +- test/declarations/publicApi.types.ts | 4 +- test/declarations/replicationHelpers.types.ts | 4 +- .../stakeholderRelationships.types.ts | 24 ++++- .../entities/stakeholder.integration.test.ts | 4 +- ...roductionDataRoundtrip.integration.test.ts | 6 +- .../batchOperations.integration.test.ts | 10 +- .../capTableWorkflow.integration.test.ts | 4 +- .../publicEntrypoint.types.ts | 12 +++ .../canonicalOcfObjectInventory.json | 4 +- test/schemaAlignment/enumAlignment.test.ts | 13 +++ test/types/capTableBatch.types.ts | 4 +- test/types/damlReadDispatch.types.ts | 4 +- test/types/replicationHelpers.types.ts | 4 +- test/types/stakeholderRelationships.types.ts | 24 ++++- test/utils/ocfComparison.test.ts | 47 ++++++++ 30 files changed, 395 insertions(+), 116 deletions(-) diff --git a/scripts/check-built-cardinality.cjs b/scripts/check-built-cardinality.cjs index 5e16f058..0a6347c2 100644 --- a/scripts/check-built-cardinality.cjs +++ b/scripts/check-built-cardinality.cjs @@ -10,8 +10,10 @@ function loadBuiltModules() { console.log = () => undefined; return { errors: require('../dist/errors'), + enumConversions: require('../dist/utils/enumConversions'), readDispatcher: require('../dist/functions/OpenCapTable/capTable/damlToOcf'), root: require('../dist'), + stakeholderReader: require('../dist/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'), stockConsolidationWriter: require('../dist/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'), stockTransferWriter: require('../dist/functions/OpenCapTable/stockTransfer/createStockTransfer'), typeConversions: require('../dist/utils/typeConversions'), @@ -24,8 +26,10 @@ function loadBuiltModules() { const built = loadBuiltModules(); const { OcpErrorCodes, OcpValidationError } = built.errors; +const { STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML } = built.enumConversions; const { convertToOcf } = built.readDispatcher; const { STAKEHOLDER_RELATIONSHIP_TYPES } = built.root; +const { damlStakeholderDataToNative } = built.stakeholderReader; const { stockConsolidationDataToDaml } = built.stockConsolidationWriter; const { stockTransferDataToDaml } = built.stockTransferWriter; const { toNonEmptyStringArray } = built.typeConversions; @@ -48,6 +52,28 @@ const expectedStakeholderRelationships = [ assert.deepEqual(STAKEHOLDER_RELATIONSHIP_TYPES, expectedStakeholderRelationships); assert.equal(Object.isFrozen(STAKEHOLDER_RELATIONSHIP_TYPES), true); assert.throws(() => STAKEHOLDER_RELATIONSHIP_TYPES.push('LEGACY_RELATIONSHIP'), TypeError); +assert.equal(Object.isFrozen(STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML), true); +assert.deepEqual(Object.keys(STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML), expectedStakeholderRelationships); + +const builtStakeholder = damlStakeholderDataToNative({ + id: 'built-stakeholder', + name: { legal_name: 'Built Stakeholder', first_name: null, last_name: null }, + stakeholder_type: 'OcfStakeholderTypeIndividual', + issuer_assigned_id: null, + current_relationships: ['OcfRelInvestor', 'OcfRelAdvisor', 'OcfRelInvestor'], + current_status: null, + primary_contact: null, + contact_info: null, + addresses: [], + tax_ids: [], + comments: ['immutable'], +}); +assert.deepEqual(builtStakeholder.current_relationships, ['INVESTOR', 'ADVISOR', 'INVESTOR']); +assert.equal(Object.isFrozen(builtStakeholder), true); +assert.equal(Object.isFrozen(builtStakeholder.name), true); +assert.equal(Object.isFrozen(builtStakeholder.current_relationships), true); +assert.equal(Object.isFrozen(builtStakeholder.comments), true); +assert.throws(() => builtStakeholder.current_relationships.reverse(), TypeError); function expectValidationError(action, expectedPath, expectedCode) { assert.throws(action, (error) => { diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 419c38aa..ece40c32 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -86,10 +86,10 @@ import { CapTableBatch, type CapTableBatchParams } from './functions/OpenCapTabl import { getEntityAsOcf } from './functions/OpenCapTable/capTable/damlToOcf'; import { mapOcfObjectTypeToEntityType, - type OcfDataTypeFor, type OcfEntityType, type OcfReadableDataForObjectType, type OcfReadableObjectType, + type OcfReadDataTypeFor, } from './functions/OpenCapTable/capTable/entityTypes'; import { classifyIssuerCapTables, @@ -370,7 +370,7 @@ function toContractResult(data: T, contractId: string): ContractResult { function makeGenericEntityReader( client: LedgerJsonApiClient, entityType: T -): EntityReader> { +): EntityReader> { return { get: async ({ contractId, ...options }) => { const r = await getEntityAsOcf(client, entityType, contractId, options); @@ -818,7 +818,7 @@ export class OcpClient { private createOpenCapTableMethods(): OpenCapTableMethods { const client = this.ledger; - const genericEntity = (entityType: T): EntityReader> => + const genericEntity = (entityType: T): EntityReader> => makeGenericEntityReader(client, entityType); const methods = { diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 5e4b37c9..d5e14240 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -40,6 +40,7 @@ export { type OcfEntityTypeForObjectType, type OcfReadableDataForObjectType, type OcfReadableObjectType, + type OcfReadDataTypeFor, } from './entityTypes'; // Re-export DAML types for convenience diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index 20d53fb0..a38c50d4 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -109,7 +109,7 @@ function createEntityDataDecoder( if (isComplexIssuanceEntityType(entityType)) { validateComplexIssuanceDamlDataInput(entityType, input); } - if (isStakeholderEventEntityType(entityType)) { + if (entityType === 'stakeholder' || isStakeholderEventEntityType(entityType)) { assertSafeGeneratedDamlJson(input, entityType); } else { assertCanonicalJsonGraph(input, entityType); diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 890a54f4..9244fde2 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -14,7 +14,12 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; -import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfDataTypeFor, type OcfEntityType } from './batchTypes'; +import { + ENTITY_TEMPLATE_ID_MAP, + type DamlDataTypeFor, + type OcfEntityType, + type OcfReadDataTypeFor, +} from './batchTypes'; import { extractAndDecodeDamlEntityData } from './damlEntityData'; // Import converters from entity folders @@ -103,11 +108,11 @@ export type SupportedOcfReadType = OcfEntityType; export function convertToOcf( type: EntityType, damlData: DamlDataTypeFor -): OcfDataTypeFor; +): OcfReadDataTypeFor; export function convertToOcf( type: SupportedOcfReadType, data: DamlDataTypeFor -): OcfDataTypeFor { +): OcfReadDataTypeFor { // Transfer converters perform their own parse-error preflight before generated // decoding. Dispatch them before the generic writer-oriented JSON validator so // every direct and dispatcher transfer read reports the same public error family. @@ -224,6 +229,12 @@ export function convertToOcf( data as Parameters[0] ); } + // Stakeholder decoding owns a bounded generated-JSON preflight and returns a + // detached immutable snapshot. Dispatch it before the generic graph walk so + // hostile relationship arrays cannot make that weaker boundary do unbounded work. + if (type === 'stakeholder') { + return damlStakeholderDataToNative(data as Parameters[0]); + } assertCanonicalJsonGraph(data, type); switch (type) { @@ -232,8 +243,6 @@ export function convertToOcf( return damlDocumentDataToNative(data); case 'issuer': return damlIssuerDataToNative(data); - case 'stakeholder': - return damlStakeholderDataToNative(data as Parameters[0]); case 'stockClass': return damlStockClassDataToNative(data); case 'stockLegendTemplate': @@ -305,7 +314,7 @@ export { extractCreateArgument } from '../shared/singleContractRead'; */ export interface GetEntityAsOcfResult { /** The native OCF data */ - data: OcfDataTypeFor; + data: OcfReadDataTypeFor; /** The contract ID */ contractId: string; } diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index bcc94622..61658db3 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -6,6 +6,7 @@ * of the package's root declaration graph. */ +import type { DeepReadonly } from '../../../types/common'; import type { OcfConvertibleAcceptance, OcfConvertibleCancellation, @@ -118,6 +119,14 @@ export type OcfEntityType = keyof OcfEntityDataMap; /** Canonical OCF data for one entity kind. */ export type OcfDataTypeFor = OcfEntityDataMap[T]; +/** Entity kinds whose read results are recursively frozen snapshots. */ +export type ImmutableOcfReadEntityType = 'stakeholder'; + +/** Canonical data returned by a reader, including immutable entity snapshots. */ +export type OcfReadDataTypeFor = T extends ImmutableOcfReadEntityType + ? DeepReadonly> + : OcfDataTypeFor; + /** Entity kinds that can be created through UpdateCapTable. */ export type OcfCreatableEntityType = Exclude; @@ -247,7 +256,7 @@ export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; export type OcfEntityTypeForObjectType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[T]; /** Canonical data returned by one object-type reader. */ -export type OcfReadableDataForObjectType = OcfDataTypeFor< +export type OcfReadableDataForObjectType = OcfReadDataTypeFor< OcfEntityTypeForObjectType >; diff --git a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts index bb631548..b73307c7 100644 --- a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts +++ b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts @@ -1,16 +1,16 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpValidationError } from '../../../errors'; -import type { GetByContractIdParams } from '../../../types/common'; +import type { DeepReadonly, GetByContractIdParams } from '../../../types/common'; import type { ContactInfo, ContactInfoWithoutName, Email, Name, - OcfStakeholder, Phone, StakeholderRelationshipType, } from '../../../types/native'; +import type { OcfStakeholderOutput } from '../../../types/output'; import { damlEmailTypeToNative, damlPhoneTypeToNative, @@ -19,24 +19,27 @@ import { damlStakeholderTypeToNative, } from '../../../utils/enumConversions'; import { damlAddressToNative, isRecord } from '../../../utils/typeConversions'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; -function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): Email { - return { +function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): DeepReadonly { + return Object.freeze({ email_type: damlEmailTypeToNative(damlEmail.email_type), email_address: damlEmail.email_address, - }; + }); } -function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): Phone { - return { +function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): DeepReadonly { + return Object.freeze({ phone_type: damlPhoneTypeToNative(phone.phone_type), phone_number: phone.phone_number, - }; + }); } -function damlContactInfoToNative(damlInfo: Fairmint.OpenCapTable.OCF.Stakeholder.OcfContactInfo): ContactInfo { +function damlContactInfoToNative( + damlInfo: Fairmint.OpenCapTable.OCF.Stakeholder.OcfContactInfo +): DeepReadonly { // Validate required field if (!damlInfo.name.legal_name) { throw new OcpValidationError('contactInfo.name.legal_name', 'Required field is missing', { @@ -45,35 +48,34 @@ function damlContactInfoToNative(damlInfo: Fairmint.OpenCapTable.OCF.Stakeholder }); } - const name: Name = { + const name = Object.freeze({ legal_name: damlInfo.name.legal_name, ...(damlInfo.name.first_name ? { first_name: damlInfo.name.first_name } : {}), ...(damlInfo.name.last_name ? { last_name: damlInfo.name.last_name } : {}), - }; - const phones: Phone[] = damlInfo.phone_numbers.map(damlPhoneToNative); - const emails: Email[] = damlInfo.emails.map(damlEmailToNative); - return { + }) satisfies DeepReadonly; + const phones = Object.freeze(damlInfo.phone_numbers.map(damlPhoneToNative)); + const emails = Object.freeze(damlInfo.emails.map(damlEmailToNative)); + return Object.freeze({ name, phone_numbers: phones, emails, - }; + }); } function damlContactInfoWithoutNameToNative( damlInfo: Fairmint.OpenCapTable.OCF.Stakeholder.OcfContactInfoWithoutName -): ContactInfoWithoutName { - const phones: Phone[] = damlInfo.phone_numbers.map(damlPhoneToNative); - const emails: Email[] = damlInfo.emails.map(damlEmailToNative); - return { +): DeepReadonly { + const phones = Object.freeze(damlInfo.phone_numbers.map(damlPhoneToNative)); + const emails = Object.freeze(damlInfo.emails.map(damlEmailToNative)); + return Object.freeze({ phone_numbers: phones, emails, - }; + }); } -export function damlStakeholderDataToNative( - damlData: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData -): OcfStakeholder { - const { id: generatedId } = damlData; +export function damlStakeholderDataToNative(damlData: DamlDataTypeFor<'stakeholder'>): OcfStakeholderOutput { + const data = decodeDamlEntityData('stakeholder', damlData); + const { id: generatedId } = data; const id: unknown = generatedId; if (typeof id !== 'string' || id.length === 0) { throw new OcpValidationError('stakeholder.id', 'Required field is missing or invalid', { @@ -82,7 +84,7 @@ export function damlStakeholderDataToNative( }); } - const nameData: unknown = damlData.name; + const nameData: unknown = data.name; if (!isRecord(nameData)) { throw new OcpValidationError('stakeholder.name', 'Required field is missing or invalid', { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, @@ -96,7 +98,7 @@ export function damlStakeholderDataToNative( receivedValue: legalName, }); } - const name: Name = { + const name = Object.freeze({ legal_name: legalName, ...(typeof nameData.first_name === 'string' && nameData.first_name.length > 0 ? { first_name: nameData.first_name } @@ -104,40 +106,43 @@ export function damlStakeholderDataToNative( ...(typeof nameData.last_name === 'string' && nameData.last_name.length > 0 ? { last_name: nameData.last_name } : {}), - }; - const relationships: StakeholderRelationshipType[] = damlData.current_relationships.map( - damlStakeholderRelationshipToNative - ); - const native: OcfStakeholder = { + }) satisfies DeepReadonly; + const relationships = Object.freeze( + data.current_relationships.map(damlStakeholderRelationshipToNative) + ) satisfies readonly StakeholderRelationshipType[]; + const addresses = Object.freeze(data.addresses.map((address) => Object.freeze(damlAddressToNative(address)))); + const taxIds = Object.freeze(data.tax_ids.map((taxId) => Object.freeze({ ...taxId }))); + const comments = Object.freeze([...data.comments]); + const native = { object_type: 'STAKEHOLDER', id, name, - stakeholder_type: damlStakeholderTypeToNative(damlData.stakeholder_type), - ...(damlData.issuer_assigned_id ? { issuer_assigned_id: damlData.issuer_assigned_id } : {}), + stakeholder_type: damlStakeholderTypeToNative(data.stakeholder_type), + ...(data.issuer_assigned_id ? { issuer_assigned_id: data.issuer_assigned_id } : {}), current_relationships: relationships, - ...(damlData.current_status + ...(data.current_status ? { - current_status: damlStakeholderStatusToNative(damlData.current_status), + current_status: damlStakeholderStatusToNative(data.current_status), } : {}), - ...(damlData.primary_contact && { - primary_contact: damlContactInfoToNative(damlData.primary_contact), + ...(data.primary_contact && { + primary_contact: damlContactInfoToNative(data.primary_contact), }), - ...(damlData.contact_info && { - contact_info: damlContactInfoWithoutNameToNative(damlData.contact_info), + ...(data.contact_info && { + contact_info: damlContactInfoWithoutNameToNative(data.contact_info), }), - addresses: damlData.addresses.map(damlAddressToNative), - tax_ids: damlData.tax_ids, - ...(damlData.comments.length > 0 ? { comments: damlData.comments } : {}), - }; - return native; + addresses, + tax_ids: taxIds, + ...(comments.length > 0 ? { comments } : {}), + } satisfies OcfStakeholderOutput; + return Object.freeze(native); } export interface GetStakeholderAsOcfParams extends GetByContractIdParams {} export interface GetStakeholderAsOcfResult { - stakeholder: OcfStakeholder; - contractId: string; + readonly stakeholder: OcfStakeholderOutput; + readonly contractId: string; } /** @@ -160,5 +165,5 @@ export async function getStakeholderAsOcf( const stakeholderData = extractAndDecodeDamlEntityData('stakeholder', createArgument); const stakeholder = damlStakeholderDataToNative(stakeholderData); - return { stakeholder, contractId: params.contractId }; + return Object.freeze({ stakeholder, contractId: params.contractId }); } diff --git a/src/types/common.ts b/src/types/common.ts index 087f8c9b..3195d358 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -115,3 +115,12 @@ export interface ContractResult { * ``` */ export type WithObjectType = T & { readonly object_type: OT }; + +/** Recursively make an SDK result immutable while preserving tuples and discriminated unions. */ +export type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends readonly unknown[] + ? { readonly [Index in keyof T]: DeepReadonly } + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; diff --git a/src/types/output.ts b/src/types/output.ts index 173c6182..2d771377 100644 --- a/src/types/output.ts +++ b/src/types/output.ts @@ -22,7 +22,7 @@ * @module */ -import type { WithObjectType } from './common'; +import type { DeepReadonly, WithObjectType } from './common'; import type { OcfConvertibleAcceptance, OcfConvertibleCancellation, @@ -81,7 +81,7 @@ import type { export type OcfIssuerOutput = WithObjectType; /** Stakeholder output with `object_type: 'STAKEHOLDER'` discriminant */ -export type OcfStakeholderOutput = WithObjectType; +export type OcfStakeholderOutput = DeepReadonly; /** Stock Class output with `object_type: 'STOCK_CLASS'` discriminant */ export type OcfStockClassOutput = WithObjectType; diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 44cb2710..237e3883 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -20,14 +20,13 @@ import { getIssuerAsOcf } from '../functions/OpenCapTable/issuer'; import type { OcfDocument, OcfIssuer, - OcfStakeholder, OcfStockClass, OcfStockLegendTemplate, OcfStockPlan, OcfValuation, OcfVestingTerms, } from '../types/native'; -import type { OcfTransaction } from '../types/output'; +import type { OcfStakeholderOutput, OcfTransaction } from '../types/output'; import { analyzeContractReadFailure, contractReadFailureCode, @@ -685,7 +684,7 @@ export interface OcfManifest { issuer: OcfIssuer | null; stockClasses: OcfStockClass[]; stockPlans: OcfStockPlan[]; - stakeholders: OcfStakeholder[]; + stakeholders: OcfStakeholderOutput[]; transactions: OcfTransaction[]; vestingTerms: OcfVestingTerms[]; valuations: OcfValuation[]; diff --git a/src/utils/enumConversions.ts b/src/utils/enumConversions.ts index 28ccf0b2..52400e31 100644 --- a/src/utils/enumConversions.ts +++ b/src/utils/enumConversions.ts @@ -268,7 +268,7 @@ export type DamlStakeholderRelationshipType = * error here until its DAML representation is defined, preventing validator and * converter support from drifting apart. */ -export const STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML = { +export const STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML = Object.freeze({ ADVISOR: 'OcfRelAdvisor', BOARD_MEMBER: 'OcfRelBoardMember', CONSULTANT: 'OcfRelConsultant', @@ -282,7 +282,7 @@ export const STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML = { NON_US_EMPLOYEE: 'OcfRelNonUsEmployee', OFFICER: 'OcfRelOfficer', OTHER: 'OcfRelOther', -} as const satisfies Record; +} as const satisfies Record); const STAKEHOLDER_RELATIONSHIP_TYPE_SET: ReadonlySet = new Set(STAKEHOLDER_RELATIONSHIP_TYPES); diff --git a/src/utils/ocfComparison.ts b/src/utils/ocfComparison.ts index b7d71bd8..155a0803 100644 --- a/src/utils/ocfComparison.ts +++ b/src/utils/ocfComparison.ts @@ -90,8 +90,21 @@ function isCurrentRelationshipElementPath(path: string): boolean { return /(?:^|\.)current_relationships\[\d+\]$/.test(path); } -function isStrictCurrentRelationshipPath(path: string): boolean { - return isCurrentRelationshipsPath(path) || isCurrentRelationshipElementPath(path); +function isRelationshipChangeFieldPath(path: string): boolean { + return ( + path === 'relationship_started' || + path.endsWith('.relationship_started') || + path === 'relationship_ended' || + path.endsWith('.relationship_ended') + ); +} + +function isStakeholderRelationshipValuePath(path: string): boolean { + return isCurrentRelationshipElementPath(path) || isRelationshipChangeFieldPath(path); +} + +function isStrictStakeholderRelationshipPath(path: string): boolean { + return isCurrentRelationshipsPath(path) || isStakeholderRelationshipValuePath(path); } function areEquivalentEmptyCurrentRelationships(path: string, left: unknown, right: unknown): boolean { @@ -105,7 +118,7 @@ function areEquivalentEmptyCurrentRelationships(path: string, left: unknown, rig function normalizeOcfValue(value: unknown, path = ''): unknown { // Stakeholder relationships are closed canonical enum values. Comparison is // raw and type-sensitive at these positions. - if (isCurrentRelationshipElementPath(path)) return value; + if (isStakeholderRelationshipValuePath(path)) return value; // Normalize JS numbers to the same fixed-precision string format as numeric strings. // DB JSONB can store amounts as numbers (e.g., 22500) while DAML readback returns @@ -278,7 +291,7 @@ export function ocfCompare(a: unknown, b: unknown, options?: OcfComparisonOption if (areEquivalentEmptyCurrentRelationships(path, valA, valB)) return true; // Consider empty arrays equivalent to undefined - if (!isStrictCurrentRelationshipPath(path) && isUndefinedLike(valA) && isUndefinedLike(valB)) return true; + if (!isStrictStakeholderRelationshipPath(path) && isUndefinedLike(valA) && isUndefinedLike(valB)) return true; // Handle null/undefined if (valA === valB) return true; @@ -351,12 +364,19 @@ export function ocfCompare(a: unknown, b: unknown, options?: OcfComparisonOption if (areEquivalentEmptyCurrentRelationships(childPath, childValA, childValB)) continue; // Treat empty arrays as undefined-like and skip if both are undefined-like - if (!isStrictCurrentRelationshipPath(childPath) && isUndefinedLike(childValA) && isUndefinedLike(childValB)) { + if ( + !isStrictStakeholderRelationshipPath(childPath) && + isUndefinedLike(childValA) && + isUndefinedLike(childValB) + ) { continue; } // If one is undefined-like and the other isn't, they don't match - if (!isStrictCurrentRelationshipPath(childPath) && isUndefinedLike(childValA) !== isUndefinedLike(childValB)) { + if ( + !isStrictStakeholderRelationshipPath(childPath) && + isUndefinedLike(childValA) !== isUndefinedLike(childValB) + ) { differences.push(`${childPath}: one side is empty/undefined`); allMatch = false; continue; @@ -425,7 +445,7 @@ export function diffOcfObjects(a: unknown, b: unknown, path = ''): string[] { if (areEquivalentEmptyCurrentRelationships(path, a, b)) return diffs; // Consider empty arrays equivalent to undefined - if (!isStrictCurrentRelationshipPath(path) && isUndefinedLike(a) && isUndefinedLike(b)) { + if (!isStrictStakeholderRelationshipPath(path) && isUndefinedLike(a) && isUndefinedLike(b)) { return diffs; } @@ -457,12 +477,12 @@ export function diffOcfObjects(a: unknown, b: unknown, path = ''): string[] { const av = a[i]; const bv = b[i]; - if (!isStrictCurrentRelationshipPath(subPath) && isUndefinedLike(av) && isUndefinedLike(bv)) continue; - if (!isStrictCurrentRelationshipPath(subPath) && !isUndefinedLike(av) && isUndefinedLike(bv)) { + if (!isStrictStakeholderRelationshipPath(subPath) && isUndefinedLike(av) && isUndefinedLike(bv)) continue; + if (!isStrictStakeholderRelationshipPath(subPath) && !isUndefinedLike(av) && isUndefinedLike(bv)) { diffs.push(`${subPath}: present in ledger only -> ${JSON.stringify(av)}`); continue; } - if (!isStrictCurrentRelationshipPath(subPath) && isUndefinedLike(av) && !isUndefinedLike(bv)) { + if (!isStrictStakeholderRelationshipPath(subPath) && isUndefinedLike(av) && !isUndefinedLike(bv)) { diffs.push(`${subPath}: present in DB only -> ${JSON.stringify(bv)}`); continue; } @@ -488,12 +508,12 @@ export function diffOcfObjects(a: unknown, b: unknown, path = ''): string[] { if (isSchemaDefaultEquivalent(subPath, av, bv)) continue; if (areEquivalentEmptyCurrentRelationships(subPath, av, bv)) continue; - if (!isStrictCurrentRelationshipPath(subPath) && isUndefinedLike(av) && isUndefinedLike(bv)) continue; - if (!isStrictCurrentRelationshipPath(subPath) && !isUndefinedLike(av) && isUndefinedLike(bv)) { + if (!isStrictStakeholderRelationshipPath(subPath) && isUndefinedLike(av) && isUndefinedLike(bv)) continue; + if (!isStrictStakeholderRelationshipPath(subPath) && !isUndefinedLike(av) && isUndefinedLike(bv)) { diffs.push(`${subPath}: present in ledger only -> ${JSON.stringify(av)}`); continue; } - if (!isStrictCurrentRelationshipPath(subPath) && isUndefinedLike(av) && !isUndefinedLike(bv)) { + if (!isStrictStakeholderRelationshipPath(subPath) && isUndefinedLike(av) && !isUndefinedLike(bv)) { diffs.push(`${subPath}: present in DB only -> ${JSON.stringify(bv)}`); continue; } diff --git a/src/utils/replicationHelpers.ts b/src/utils/replicationHelpers.ts index 9b4dc010..7da02832 100644 --- a/src/utils/replicationHelpers.ts +++ b/src/utils/replicationHelpers.ts @@ -17,8 +17,8 @@ import { OcpValidationError } from '../errors/OcpValidationError'; import { mapOcfObjectTypeToEntityType, OCF_OBJECT_TYPE_TO_ENTITY_TYPE, - type OcfEntityDataMap, type OcfEntityType, + type OcfReadDataTypeFor, } from '../functions/OpenCapTable/capTable/entityTypes'; import type { CapTableState } from '../functions/OpenCapTable/capTable/getCapTableState'; import type { OcfManifest } from './cantonOcfExtractor'; @@ -274,7 +274,7 @@ export function buildCantonOcfDataMap(manifest: OcfManifest): CantonOcfDataMap { // Helper to add an item to the map with validation const addItem = ( entityType: EntityType, - item: OcfEntityDataMap[EntityType], + item: OcfReadDataTypeFor, context: string ): void => { const safeContext = diagnosticText(context); @@ -308,9 +308,9 @@ export function buildCantonOcfDataMap(manifest: OcfManifest): CantonOcfDataMap { // Indexed access through a generic mapped key is correlated on reads, but // TypeScript models a later write as the intersection of every bucket. - let typeMap: Map | undefined = mutableData[entityType]; + let typeMap: Map> | undefined = mutableData[entityType]; if (!typeMap) { - typeMap = new Map(); + typeMap = new Map>(); // TypeScript loses mapped-key correlation for generic indexed writes. The // validated generic inputs above preserve it at this private builder boundary. Object.defineProperty(mutableData, entityType, { @@ -494,11 +494,11 @@ export interface ReplicationDiff { * Maps entityType to a map of canonical object ID to OCF data object. */ type CantonOcfDataByEntity = { - [EntityType in OcfEntityType]?: ReadonlyMap; + [EntityType in OcfEntityType]?: ReadonlyMap>; }; type MutableCantonOcfDataByEntity = { - [EntityType in OcfEntityType]?: Map; + [EntityType in OcfEntityType]?: Map>; }; /** @@ -509,7 +509,7 @@ type MutableCantonOcfDataByEntity = { * with a union-valued payload map and later observed through a narrower `get`. */ export type CantonOcfDataEntry = EntityType extends OcfEntityType - ? readonly [entityType: EntityType, data: ReadonlyMap] + ? readonly [entityType: EntityType, data: ReadonlyMap>] : never; /** @@ -576,12 +576,12 @@ export class CantonOcfDataMap { get( entityType: EntityType - ): ReadonlyMap | undefined { + ): ReadonlyMap> | undefined { return this.#data[entityType]; } set(...[entityType, data]: CantonOcfDataEntry): this { - const snapshot = new ImmutableMapSnapshot(data); + const snapshot = new ImmutableMapSnapshot>(data); Object.defineProperty(this.#data, entityType, { configurable: true, enumerable: true, diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index f69c9d83..c19e66c4 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -1,4 +1,5 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src'; +import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { damlDocumentDataToNative } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; import { damlStakeholderDataToNative } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; @@ -46,6 +47,90 @@ describe('core DAML read converter required fields', () => { expect(document).toMatchObject({ object_type: 'DOCUMENT', id: minimalDocument.id }); }); + test('returns a detached, deeply frozen stakeholder snapshot with ordered duplicate relationships', () => { + const input = { + ...minimalStakeholder, + current_relationships: ['OcfRelInvestor', 'OcfRelAdvisor', 'OcfRelInvestor'], + primary_contact: { + name: { legal_name: 'Ada Lovelace', first_name: 'Ada', last_name: 'Lovelace' }, + phone_numbers: [{ phone_type: 'OcfPhoneMobile', phone_number: '+12025550123' }], + emails: [{ email_type: 'OcfEmailTypeBusiness', email_address: 'ada@example.com' }], + }, + contact_info: { + phone_numbers: [{ phone_type: 'OcfPhoneHome', phone_number: '+12025550124' }], + emails: [], + }, + addresses: [ + { + address_type: 'OcfAddressTypeLegal', + country: 'US', + city: 'New York', + country_subdivision: 'NY', + postal_code: '10001', + street_suite: '1 Main St', + }, + ], + tax_ids: [{ country: 'US', tax_id: '12-3456789' }], + comments: ['canonical snapshot'], + }; + + const stakeholder = damlStakeholderDataToNative(asDamlStakeholder(input)); + + expect(stakeholder.current_relationships).toEqual(['INVESTOR', 'ADVISOR', 'INVESTOR']); + expect(Object.isFrozen(stakeholder)).toBe(true); + expect(Object.isFrozen(stakeholder.name)).toBe(true); + expect(Object.isFrozen(stakeholder.current_relationships)).toBe(true); + expect(Object.isFrozen(stakeholder.primary_contact)).toBe(true); + expect(Object.isFrozen(stakeholder.primary_contact?.name)).toBe(true); + expect(Object.isFrozen(stakeholder.primary_contact?.phone_numbers)).toBe(true); + expect(Object.isFrozen(stakeholder.primary_contact?.phone_numbers?.[0])).toBe(true); + expect(Object.isFrozen(stakeholder.contact_info?.emails)).toBe(true); + expect(Object.isFrozen(stakeholder.addresses)).toBe(true); + expect(Object.isFrozen(stakeholder.addresses?.[0])).toBe(true); + expect(Object.isFrozen(stakeholder.tax_ids)).toBe(true); + expect(Object.isFrozen(stakeholder.tax_ids?.[0])).toBe(true); + expect(Object.isFrozen(stakeholder.comments)).toBe(true); + + input.current_relationships[0] = 'OcfRelFounder'; + input.comments[0] = 'mutated source'; + expect(stakeholder.current_relationships).toEqual(['INVESTOR', 'ADVISOR', 'INVESTOR']); + expect(stakeholder.comments).toEqual(['canonical snapshot']); + expect(() => (stakeholder.current_relationships as string[]).push('FOUNDER')).toThrow(TypeError); + }); + + test('rejects a proxied stakeholder before invoking any trap', () => { + const traps = { + get: jest.fn(() => { + throw new Error('stakeholder get trap invoked'); + }), + getPrototypeOf: jest.fn(() => { + throw new Error('stakeholder getPrototypeOf trap invoked'); + }), + ownKeys: jest.fn(() => { + throw new Error('stakeholder ownKeys trap invoked'); + }), + }; + const stakeholder = new Proxy(minimalStakeholder, traps); + + expect(() => damlStakeholderDataToNative(asDamlStakeholder(stakeholder))).toThrow( + expect.objectContaining({ name: OcpParseError.name, code: OcpErrorCodes.SCHEMA_MISMATCH, source: 'stakeholder' }) + ); + expect(Object.values(traps).every((trap) => trap.mock.calls.length === 0)).toBe(true); + }); + + test('bounds oversized stakeholder relationship arrays before direct or dispatched decoding', () => { + const oversizedRelationships = Array.from({ length: 100_001 }, () => 'OcfRelInvestor'); + const stakeholder = asDamlStakeholder({ ...minimalStakeholder, current_relationships: oversizedRelationships }); + const expectedError = expect.objectContaining({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'stakeholder.current_relationships', + }); + + expect(() => damlStakeholderDataToNative(stakeholder)).toThrow(expectedError); + expect(() => convertToOcf('stakeholder', stakeholder)).toThrow(expectedError); + }); + test.each([ ['OcfObjTxPlanSecurityAcceptance', 'TX_EQUITY_COMPENSATION_ACCEPTANCE'], ['OcfObjTxPlanSecurityCancellation', 'TX_EQUITY_COMPENSATION_CANCELLATION'], diff --git a/test/declarations/damlReadDispatch.types.ts b/test/declarations/damlReadDispatch.types.ts index 9e980217..9e15bf52 100644 --- a/test/declarations/damlReadDispatch.types.ts +++ b/test/declarations/damlReadDispatch.types.ts @@ -9,14 +9,14 @@ import { type DamlDataTypeFor, } from '../../dist/functions/OpenCapTable/capTable'; import type { SingleContractReadResult } from '../../dist/functions/OpenCapTable/shared/singleContractRead'; -import type { OcfStakeholder } from '../../dist/types/native'; +import type { OcfStakeholderOutput } from '../../dist/types/output'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; declare const unknownLedgerData: unknown; declare const unknownCreateArgument: unknown; -const stakeholder: OcfStakeholder = convertToOcf('stakeholder', stakeholderDamlData); +const stakeholder: OcfStakeholderOutput = convertToOcf('stakeholder', stakeholderDamlData); const decodedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = decodeDamlEntityData( 'stakeholder', unknownLedgerData diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 48c4084b..a0376591 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -28,12 +28,12 @@ import { type CreateIssuerParams, type OcfContractId, type OcfCreateOperation, - type OcfEntityDataMap, type OcfEntityType, type OcfFinancing, type OcfId, type OcfIssuer, type OcfObject, + type OcfReadDataTypeFor, type OcfStakeholder, type OcfStockAcceptance, type OcfStockClass, @@ -77,7 +77,7 @@ type RemovedRootValue = Extract< >; // This file is linted before `dist` exists in a clean checkout, so its declaration-only imports appear as error types. -type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; +type IntendedCanonicalOcfObject = OcfReadDataTypeFor | OcfFinancing; type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_ACCEPTANCE' | 'TX_PLAN_SECURITY_CANCELLATION' diff --git a/test/declarations/replicationHelpers.types.ts b/test/declarations/replicationHelpers.types.ts index cd96ac71..8d4e5e73 100644 --- a/test/declarations/replicationHelpers.types.ts +++ b/test/declarations/replicationHelpers.types.ts @@ -1,6 +1,6 @@ /** Built-declaration contracts for entity-correlated Canton replication data. */ -import type { OcfEntityType, OcfStakeholder, OcfStockClass } from '../../dist'; +import type { OcfEntityType, OcfStakeholder, OcfStakeholderOutput, OcfStockClass } from '../../dist'; import { CantonOcfDataMap, type CantonOcfDataEntry } from '../../dist/utils/replicationHelpers'; declare const stakeholder: OcfStakeholder; @@ -9,7 +9,7 @@ declare const stockClass: OcfStockClass; const cantonData = new CantonOcfDataMap(); cantonData.set('stakeholder', new Map([['stakeholder-1', stakeholder]])); -const stakeholderData: ReadonlyMap | undefined = cantonData.get('stakeholder'); +const stakeholderData: ReadonlyMap | undefined = cantonData.get('stakeholder'); // @ts-expect-error the entity key determines the exact canonical value shape cantonData.set('stakeholder', new Map([['stock-class-1', stockClass]])); diff --git a/test/declarations/stakeholderRelationships.types.ts b/test/declarations/stakeholderRelationships.types.ts index 3d116563..ae970697 100644 --- a/test/declarations/stakeholderRelationships.types.ts +++ b/test/declarations/stakeholderRelationships.types.ts @@ -1,7 +1,13 @@ /** Compile-time contracts for built stakeholder relationship declarations. */ -import { STAKEHOLDER_RELATIONSHIP_TYPES, type OcfStakeholder, type StakeholderRelationshipType } from '../../dist'; +import { + STAKEHOLDER_RELATIONSHIP_TYPES, + type OcfStakeholder, + type OcfStakeholderOutput, + type StakeholderRelationshipType, +} from '../../dist'; import type { DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import type { damlStakeholderDataToNative } from '../../dist/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; type ExpectedRelationshipTuple = readonly [ 'ADVISOR', @@ -53,6 +59,12 @@ const exactUnion: Assert > = true; +const exactReadonlyOutputField: Assert< + IsExactly +> = true; +const exactStakeholderReaderOutput: Assert< + IsExactly, OcfStakeholderOutput> +> = true; const exactGeneratedDamlUnion: Assert> = true; const relationshipTypesAreNotAny: Assert< EveryTrue< @@ -60,6 +72,7 @@ const relationshipTypesAreNotAny: Assert< IsExactly, false>, IsExactly, false>, IsExactly, false>, + IsExactly, false>, IsExactly, false>, ] > @@ -71,10 +84,19 @@ STAKEHOLDER_RELATIONSHIP_TYPES[0] = 'OTHER'; STAKEHOLDER_RELATIONSHIP_TYPES.push('ADVISOR'); // @ts-expect-error built declarations reject legacy relationship aliases const legacyRelationship: StakeholderRelationshipType = 'DIRECTOR'; +declare const stakeholderOutput: OcfStakeholderOutput; +// @ts-expect-error built stakeholder reader snapshots are deeply readonly +stakeholderOutput.id = 'mutated'; +// @ts-expect-error built stakeholder relationship snapshots cannot be reordered in place +stakeholderOutput.current_relationships?.reverse(); +// @ts-expect-error built nested stakeholder reader records are readonly +stakeholderOutput.name.legal_name = 'mutated'; void exactTuple; void exactUnion; void exactStakeholderField; +void exactReadonlyOutputField; +void exactStakeholderReaderOutput; void exactGeneratedDamlUnion; void relationshipTypesAreNotAny; void legacyRelationship; diff --git a/test/integration/entities/stakeholder.integration.test.ts b/test/integration/entities/stakeholder.integration.test.ts index c8e0e933..2ff2d564 100644 --- a/test/integration/entities/stakeholder.integration.test.ts +++ b/test/integration/entities/stakeholder.integration.test.ts @@ -51,7 +51,7 @@ createIntegrationTestSuite('Stakeholder operations', (getContext) => { expect(ocfResult.data.stakeholder_type).toBe('INDIVIDUAL'); // Validate against official OCF schema - await validateOcfObject(ocfResult.data as unknown as Record); + await validateOcfObject(ocfResult.data); }); test('creates institutional stakeholder', async () => { @@ -79,6 +79,6 @@ createIntegrationTestSuite('Stakeholder operations', (getContext) => { }); expect(ocfResult.data.stakeholder_type).toBe('INSTITUTION'); - await validateOcfObject(ocfResult.data as unknown as Record); + await validateOcfObject(ocfResult.data); }); }); diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 7a004a00..e2fb771d 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -278,11 +278,11 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); // Validate OCF schema - await validateOcfObject(readBack.data as unknown as Record); + await validateOcfObject(readBack.data); // Compare data (the ID will differ since we generated a new one) const sourceWithoutId = stripInternalFields({ ...prepared, id: readBack.data.id }); - compareOcfData(sourceWithoutId, readBack.data as unknown as Record, 'Stakeholder individual'); + compareOcfData(sourceWithoutId, readBack.data, 'Stakeholder individual'); }); test('Stakeholder (institution) round-trips correctly', async () => { @@ -310,7 +310,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { contractId: extractContractIdString(requireFirst(result.createdCids, 'created production fixture contract')), }); - await validateOcfObject(readBack.data as unknown as Record); + await validateOcfObject(readBack.data); }); test('Stock Legend Template round-trips correctly', async () => { diff --git a/test/integration/workflows/batchOperations.integration.test.ts b/test/integration/workflows/batchOperations.integration.test.ts index 841a87bc..47f7d6f7 100644 --- a/test/integration/workflows/batchOperations.integration.test.ts +++ b/test/integration/workflows/batchOperations.integration.test.ts @@ -99,7 +99,7 @@ createIntegrationTestSuite('Batch operations', (getContext) => { contractId: stakeholderContractId!, }); expect(stakeholderOcf.data.name.legal_name).toBe('Batch Stakeholder 1'); - await validateOcfObject(stakeholderOcf.data as unknown as Record); + await validateOcfObject(stakeholderOcf.data); }); /** @@ -200,7 +200,7 @@ createIntegrationTestSuite('Batch operations', (getContext) => { contractId: stakeholderContractId!, }); expect(stakeholderOcf.data.name.legal_name).toBe('Batch Stakeholder'); - await validateOcfObject(stakeholderOcf.data as unknown as Record); + await validateOcfObject(stakeholderOcf.data); const legendOcf = await ctx.ocp.OpenCapTable.stockLegendTemplate.get({ contractId: legendContractId!, @@ -283,9 +283,9 @@ createIntegrationTestSuite('Batch operations', (getContext) => { expect(sh3Ocf.data.name.legal_name).toBe('Third Stakeholder'); // Validate all stakeholders against OCF schema - await validateOcfObject(sh1Ocf.data as unknown as Record); - await validateOcfObject(sh2Ocf.data as unknown as Record); - await validateOcfObject(sh3Ocf.data as unknown as Record); + await validateOcfObject(sh1Ocf.data); + await validateOcfObject(sh2Ocf.data); + await validateOcfObject(sh3Ocf.data); }); /** diff --git a/test/integration/workflows/capTableWorkflow.integration.test.ts b/test/integration/workflows/capTableWorkflow.integration.test.ts index e14f9f86..d2f3c5a6 100644 --- a/test/integration/workflows/capTableWorkflow.integration.test.ts +++ b/test/integration/workflows/capTableWorkflow.integration.test.ts @@ -130,13 +130,13 @@ createIntegrationTestSuite('Cap Table Workflow', (getContext) => { contractId: extractContractIdString(requireDefined(result1.createdCids[0], 'first founder contract')), }); expect(founder1Ocf.data.name.legal_name).toBe('Alice Founder'); - await validateOcfObject(founder1Ocf.data as unknown as Record); + await validateOcfObject(founder1Ocf.data); const founder2Ocf = await ctx.ocp.OpenCapTable.stakeholder.get({ contractId: extractContractIdString(requireDefined(result1.createdCids[1], 'second founder contract')), }); expect(founder2Ocf.data.name.legal_name).toBe('Bob Cofounder'); - await validateOcfObject(founder2Ocf.data as unknown as Record); + await validateOcfObject(founder2Ocf.data); }); /** diff --git a/test/package-consumer/publicEntrypoint.types.ts b/test/package-consumer/publicEntrypoint.types.ts index 600d0e46..edc35920 100644 --- a/test/package-consumer/publicEntrypoint.types.ts +++ b/test/package-consumer/publicEntrypoint.types.ts @@ -6,6 +6,7 @@ import { type OcfEquityCompensationExercise, type OcfObject, type OcfStakeholder, + type OcfStakeholderOutput, type OcfStakeholderRelationshipChangeEvent, type OcfStakeholderStatusChangeEvent, type OcfStockClassConversionRatioAdjustment, @@ -107,6 +108,7 @@ const stakeholderStatusRead = client.OpenCapTable.getByObjectType({ objectType: 'CE_STAKEHOLDER_STATUS', contractId: 'contract-id', }); +const stakeholderRead = client.OpenCapTable.stakeholder.get({ contractId: 'contract-id' }); const packageConversionExerciseReadersAreExact: Assert< IsExactly['data'], OcfConvertibleConversion> > = true; @@ -162,6 +164,14 @@ const packageStakeholderRelationshipsAreExact: Assert< ] > > = true; +const packageStakeholderReaderIsExact: Assert< + IsExactly['data'], OcfStakeholderOutput> +> = true; +declare const packageStakeholderOutput: Awaited['data']; +// @ts-expect-error package-root stakeholder snapshots are deeply readonly +packageStakeholderOutput.current_relationships?.push('ADVISOR'); +// @ts-expect-error package-root stakeholder nested records are readonly +packageStakeholderOutput.name.legal_name = 'mutated'; const packageFirstConsolidationSource: string = (null as unknown as OcfStockConsolidation).security_ids[0]; const packageFirstReissuanceResult: string | undefined = (null as unknown as OcfStockReissuance) .resulting_security_ids[0]; @@ -202,6 +212,8 @@ void packageStakeholderEventReadersAreExact; void packageStakeholderEventTypesAreNotAny; void STAKEHOLDER_RELATIONSHIP_TYPES; void packageStakeholderRelationshipsAreExact; +void stakeholderRead; +void packageStakeholderReaderIsExact; void packageFirstConsolidationSource; void packageFirstReissuanceResult; void packageEmptyReissuanceResults; diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index f2e2c224..565fd9a1 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -1,5 +1,5 @@ { - "fingerprint": "aac6ba2a8f4174542f15872b8f1ff8827e86bffb6c52ca42f1a7b70651185bd7", + "fingerprint": "c984687565601456dedfc204f0db1e46ef77ed2794c75a7f232787cf37cd6cda", "objects": [ { "discriminator": "CE_STAKEHOLDER_RELATIONSHIP", @@ -39,7 +39,7 @@ }, { "discriminator": "STAKEHOLDER", - "signature": "intersection(object(\"addresses\"?:array(object(\"address_type\":union(string:\"CONTACT\"|string:\"LEGAL\"|string:\"OTHER\");\"city\"?:string;\"country\":string;\"country_subdivision\"?:string;\"postal_code\"?:string;\"street_suite\"?:string));\"comments\"?:array(string);\"contact_info\"?:union(intersection(object(\"emails\":array(object(\"email_address\":string;\"email_type\":union(string:\"BUSINESS\"|string:\"OTHER\"|string:\"PERSONAL\"))))&object(\"phone_numbers\"?:array(object(\"phone_number\":string;\"phone_type\":union(string:\"BUSINESS\"|string:\"HOME\"|string:\"MOBILE\"|string:\"OTHER\"))))&object())|intersection(object(\"emails\"?:array(object(\"email_address\":string;\"email_type\":union(string:\"BUSINESS\"|string:\"OTHER\"|string:\"PERSONAL\"))))&object(\"phone_numbers\":array(object(\"phone_number\":string;\"phone_type\":union(string:\"BUSINESS\"|string:\"HOME\"|string:\"MOBILE\"|string:\"OTHER\"))))&object()));\"current_relationships\"?:array(union(string:\"ADVISOR\"|string:\"BOARD_MEMBER\"|string:\"CONSULTANT\"|string:\"EMPLOYEE\"|string:\"EXECUTIVE\"|string:\"EX_ADVISOR\"|string:\"EX_CONSULTANT\"|string:\"EX_EMPLOYEE\"|string:\"FOUNDER\"|string:\"INVESTOR\"|string:\"NON_US_EMPLOYEE\"|string:\"OFFICER\"|string:\"OTHER\"));\"current_status\"?:union(string:\"ACTIVE\"|string:\"LEAVE_OF_ABSENCE\"|string:\"TERMINATION_INVOLUNTARY_DEATH\"|string:\"TERMINATION_INVOLUNTARY_DISABILITY\"|string:\"TERMINATION_INVOLUNTARY_OTHER\"|string:\"TERMINATION_INVOLUNTARY_WITH_CAUSE\"|string:\"TERMINATION_VOLUNTARY_GOOD_CAUSE\"|string:\"TERMINATION_VOLUNTARY_OTHER\"|string:\"TERMINATION_VOLUNTARY_RETIREMENT\");\"id\":string;\"issuer_assigned_id\"?:string;\"name\":object(\"first_name\"?:string;\"last_name\"?:string;\"legal_name\":string);readonly \"object_type\":string:\"STAKEHOLDER\";\"primary_contact\"?:union(intersection(object(\"emails\":array(object(\"email_address\":string;\"email_type\":union(string:\"BUSINESS\"|string:\"OTHER\"|string:\"PERSONAL\"))))&object(\"name\":object(\"first_name\"?:string;\"last_name\"?:string;\"legal_name\":string))&object(\"phone_numbers\"?:array(object(\"phone_number\":string;\"phone_type\":union(string:\"BUSINESS\"|string:\"HOME\"|string:\"MOBILE\"|string:\"OTHER\")))))|intersection(object(\"emails\"?:array(object(\"email_address\":string;\"email_type\":union(string:\"BUSINESS\"|string:\"OTHER\"|string:\"PERSONAL\"))))&object(\"name\":object(\"first_name\"?:string;\"last_name\"?:string;\"legal_name\":string))&object(\"phone_numbers\":array(object(\"phone_number\":string;\"phone_type\":union(string:\"BUSINESS\"|string:\"HOME\"|string:\"MOBILE\"|string:\"OTHER\"))))));\"stakeholder_type\":union(string:\"INDIVIDUAL\"|string:\"INSTITUTION\");\"tax_ids\"?:array(object(\"country\":string;\"tax_id\":string)))&object(readonly \"object_type\":string:\"STAKEHOLDER\"))" + "signature": "object(readonly \"addresses\"?:readonly-array(object(readonly \"address_type\":union(string:\"CONTACT\"|string:\"LEGAL\"|string:\"OTHER\");readonly \"city\"?:string;readonly \"country\":string;readonly \"country_subdivision\"?:string;readonly \"postal_code\"?:string;readonly \"street_suite\"?:string));readonly \"comments\"?:readonly-array(string);readonly \"contact_info\"?:union(object(readonly \"emails\":readonly-array(object(readonly \"email_address\":string;readonly \"email_type\":union(string:\"BUSINESS\"|string:\"OTHER\"|string:\"PERSONAL\")));readonly \"phone_numbers\"?:readonly-array(object(readonly \"phone_number\":string;readonly \"phone_type\":union(string:\"BUSINESS\"|string:\"HOME\"|string:\"MOBILE\"|string:\"OTHER\"))))|object(readonly \"emails\"?:readonly-array(object(readonly \"email_address\":string;readonly \"email_type\":union(string:\"BUSINESS\"|string:\"OTHER\"|string:\"PERSONAL\")));readonly \"phone_numbers\":readonly-array(object(readonly \"phone_number\":string;readonly \"phone_type\":union(string:\"BUSINESS\"|string:\"HOME\"|string:\"MOBILE\"|string:\"OTHER\")))));readonly \"current_relationships\"?:readonly-array(union(string:\"ADVISOR\"|string:\"BOARD_MEMBER\"|string:\"CONSULTANT\"|string:\"EMPLOYEE\"|string:\"EXECUTIVE\"|string:\"EX_ADVISOR\"|string:\"EX_CONSULTANT\"|string:\"EX_EMPLOYEE\"|string:\"FOUNDER\"|string:\"INVESTOR\"|string:\"NON_US_EMPLOYEE\"|string:\"OFFICER\"|string:\"OTHER\"));readonly \"current_status\"?:union(string:\"ACTIVE\"|string:\"LEAVE_OF_ABSENCE\"|string:\"TERMINATION_INVOLUNTARY_DEATH\"|string:\"TERMINATION_INVOLUNTARY_DISABILITY\"|string:\"TERMINATION_INVOLUNTARY_OTHER\"|string:\"TERMINATION_INVOLUNTARY_WITH_CAUSE\"|string:\"TERMINATION_VOLUNTARY_GOOD_CAUSE\"|string:\"TERMINATION_VOLUNTARY_OTHER\"|string:\"TERMINATION_VOLUNTARY_RETIREMENT\");readonly \"id\":string;readonly \"issuer_assigned_id\"?:string;readonly \"name\":object(readonly \"first_name\"?:string;readonly \"last_name\"?:string;readonly \"legal_name\":string);readonly \"object_type\":string:\"STAKEHOLDER\";readonly \"primary_contact\"?:union(object(readonly \"emails\":readonly-array(object(readonly \"email_address\":string;readonly \"email_type\":union(string:\"BUSINESS\"|string:\"OTHER\"|string:\"PERSONAL\")));readonly \"name\":object(readonly \"first_name\"?:string;readonly \"last_name\"?:string;readonly \"legal_name\":string);readonly \"phone_numbers\"?:readonly-array(object(readonly \"phone_number\":string;readonly \"phone_type\":union(string:\"BUSINESS\"|string:\"HOME\"|string:\"MOBILE\"|string:\"OTHER\"))))|object(readonly \"emails\"?:readonly-array(object(readonly \"email_address\":string;readonly \"email_type\":union(string:\"BUSINESS\"|string:\"OTHER\"|string:\"PERSONAL\")));readonly \"name\":object(readonly \"first_name\"?:string;readonly \"last_name\"?:string;readonly \"legal_name\":string);readonly \"phone_numbers\":readonly-array(object(readonly \"phone_number\":string;readonly \"phone_type\":union(string:\"BUSINESS\"|string:\"HOME\"|string:\"MOBILE\"|string:\"OTHER\")))));readonly \"stakeholder_type\":union(string:\"INDIVIDUAL\"|string:\"INSTITUTION\");readonly \"tax_ids\"?:readonly-array(object(readonly \"country\":string;readonly \"tax_id\":string)))" }, { "discriminator": "STOCK_CLASS", diff --git a/test/schemaAlignment/enumAlignment.test.ts b/test/schemaAlignment/enumAlignment.test.ts index 6952cc57..f5b47dd8 100644 --- a/test/schemaAlignment/enumAlignment.test.ts +++ b/test/schemaAlignment/enumAlignment.test.ts @@ -1,6 +1,8 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import fs from 'fs'; import path from 'path'; import { STAKEHOLDER_RELATIONSHIP_TYPES } from '../../src/types'; +import { STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML } from '../../src/utils/enumConversions'; import { requireDefined } from '../../src/utils/requireDefined'; const ENUM_SCHEMA_DIR = path.join(__dirname, '../../libs/Open-Cap-Format-OCF/schema/enums'); @@ -186,6 +188,17 @@ describe('OCF Enum Schema Alignment', () => { expect(STAKEHOLDER_RELATIONSHIP_TYPES).toEqual(getOcfEnumValues(schemaPath)); }); + it('maps the schema tuple bijectively onto the pinned generated DAML codec', () => { + const generatedRelationships = Fairmint.OpenCapTable.Types.Stakeholder.OcfStakeholderRelationshipType.keys; + const mappedRelationships = STAKEHOLDER_RELATIONSHIP_TYPES.map( + (relationship) => STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML[relationship] + ); + + expect(mappedRelationships).toEqual(generatedRelationships); + expect(new Set(mappedRelationships).size).toBe(STAKEHOLDER_RELATIONSHIP_TYPES.length); + expect(Object.isFrozen(STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML)).toBe(true); + }); + it('AuthorizedShares values use OCF-canonical space form', () => { const mapping = requireDefined(ENUM_MAPPINGS['AuthorizedShares'], 'AuthorizedShares enum mapping'); expect(mapping.sdkValues).toContain('NOT APPLICABLE'); diff --git a/test/types/capTableBatch.types.ts b/test/types/capTableBatch.types.ts index a9b26a87..79441112 100644 --- a/test/types/capTableBatch.types.ts +++ b/test/types/capTableBatch.types.ts @@ -15,11 +15,11 @@ import { type ConvertibleConversionTrigger, type OcfContractId, type OcfCreateOperation, - type OcfEntityDataMap, type OcfEntityType, type OcfFinancing, type OcfIssuer, type OcfObject, + type OcfReadDataTypeFor, type OcfStakeholder, type OcfStockAcceptance, type OcfStockClass, @@ -32,7 +32,7 @@ import { type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -type IntendedCanonicalOcfObject = OcfEntityDataMap[OcfEntityType] | OcfFinancing; +type IntendedCanonicalOcfObject = OcfReadDataTypeFor | OcfFinancing; type LegacyPlanSecurityObjectType = | 'TX_PLAN_SECURITY_ACCEPTANCE' | 'TX_PLAN_SECURITY_CANCELLATION' diff --git a/test/types/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts index dbc6b6b0..7ad08fdf 100644 --- a/test/types/damlReadDispatch.types.ts +++ b/test/types/damlReadDispatch.types.ts @@ -9,14 +9,14 @@ import { type DamlDataTypeFor, } from '../../src/functions/OpenCapTable/capTable'; import type { SingleContractReadResult } from '../../src/functions/OpenCapTable/shared/singleContractRead'; -import type { OcfStakeholder } from '../../src/types/native'; +import type { OcfStakeholderOutput } from '../../src/types/output'; declare const stakeholderDamlData: DamlDataTypeFor<'stakeholder'>; declare const stockClassDamlData: DamlDataTypeFor<'stockClass'>; declare const unknownLedgerData: unknown; declare const unknownCreateArgument: unknown; -const stakeholder: OcfStakeholder = convertToOcf('stakeholder', stakeholderDamlData); +const stakeholder: OcfStakeholderOutput = convertToOcf('stakeholder', stakeholderDamlData); const decodedStakeholder: Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData = decodeDamlEntityData( 'stakeholder', unknownLedgerData diff --git a/test/types/replicationHelpers.types.ts b/test/types/replicationHelpers.types.ts index 6d3ef62a..c0152f22 100644 --- a/test/types/replicationHelpers.types.ts +++ b/test/types/replicationHelpers.types.ts @@ -1,6 +1,6 @@ /** Compile-time contracts for entity-correlated Canton replication data. */ -import type { OcfEntityType, OcfStakeholder, OcfStockClass } from '../../src'; +import type { OcfEntityType, OcfStakeholder, OcfStakeholderOutput, OcfStockClass } from '../../src'; import { CantonOcfDataMap, type CantonOcfDataEntry } from '../../src/utils/replicationHelpers'; declare const stakeholder: OcfStakeholder; @@ -9,7 +9,7 @@ declare const stockClass: OcfStockClass; const cantonData = new CantonOcfDataMap(); cantonData.set('stakeholder', new Map([['stakeholder-1', stakeholder]])); -const stakeholderData: ReadonlyMap | undefined = cantonData.get('stakeholder'); +const stakeholderData: ReadonlyMap | undefined = cantonData.get('stakeholder'); // @ts-expect-error the entity key determines the exact canonical value shape cantonData.set('stakeholder', new Map([['stock-class-1', stockClass]])); diff --git a/test/types/stakeholderRelationships.types.ts b/test/types/stakeholderRelationships.types.ts index 62ccefe0..d1e3daf0 100644 --- a/test/types/stakeholderRelationships.types.ts +++ b/test/types/stakeholderRelationships.types.ts @@ -1,7 +1,13 @@ /** Compile-time contracts for canonical source stakeholder relationships. */ -import { STAKEHOLDER_RELATIONSHIP_TYPES, type OcfStakeholder, type StakeholderRelationshipType } from '../../src'; +import { + STAKEHOLDER_RELATIONSHIP_TYPES, + type OcfStakeholder, + type OcfStakeholderOutput, + type StakeholderRelationshipType, +} from '../../src'; import type { DamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import type { damlStakeholderDataToNative } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; type ExpectedRelationshipTuple = readonly [ 'ADVISOR', @@ -53,6 +59,12 @@ const exactUnion: Assert > = true; +const exactReadonlyOutputField: Assert< + IsExactly +> = true; +const exactStakeholderReaderOutput: Assert< + IsExactly, OcfStakeholderOutput> +> = true; const exactGeneratedDamlUnion: Assert> = true; const relationshipTypesAreNotAny: Assert< EveryTrue< @@ -60,6 +72,7 @@ const relationshipTypesAreNotAny: Assert< IsExactly, false>, IsExactly, false>, IsExactly, false>, + IsExactly, false>, IsExactly, false>, ] > @@ -71,10 +84,19 @@ STAKEHOLDER_RELATIONSHIP_TYPES[0] = 'OTHER'; STAKEHOLDER_RELATIONSHIP_TYPES.push('ADVISOR'); // @ts-expect-error legacy relationship aliases are not part of the canonical union const legacyRelationship: StakeholderRelationshipType = 'DIRECTOR'; +declare const stakeholderOutput: OcfStakeholderOutput; +// @ts-expect-error stakeholder reader snapshots are deeply readonly +stakeholderOutput.id = 'mutated'; +// @ts-expect-error stakeholder relationship snapshots cannot be reordered in place +stakeholderOutput.current_relationships?.reverse(); +// @ts-expect-error nested stakeholder reader records are readonly +stakeholderOutput.name.legal_name = 'mutated'; void exactTuple; void exactUnion; void exactStakeholderField; +void exactReadonlyOutputField; +void exactStakeholderReaderOutput; void exactGeneratedDamlUnion; void relationshipTypesAreNotAny; void legacyRelationship; diff --git a/test/utils/ocfComparison.test.ts b/test/utils/ocfComparison.test.ts index 13b000d3..eb8d88b3 100644 --- a/test/utils/ocfComparison.test.ts +++ b/test/utils/ocfComparison.test.ts @@ -75,6 +75,42 @@ describe('ocfDeepEqual', () => { ).toBe(false); }); + test.each(['relationship_started', 'relationship_ended'] as const)( + 'compares %s as an exact stakeholder relationship enum', + (field) => { + expect( + ocfDeepEqual( + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', [field]: 'ADVISOR' }, + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', [field]: ' ADVISOR ' } + ) + ).toBe(false); + expect( + ocfDeepEqual( + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', [field]: 'ADVISOR' }, + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', [field]: 'advisor' } + ) + ).toBe(false); + expect( + ocfDeepEqual( + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', [field]: 1 }, + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', [field]: '1.0000000000' } + ) + ).toBe(false); + } + ); + + test.each(['relationship_started', 'relationship_ended'] as const)( + 'does not treat an explicit null %s as an omitted relationship change', + (field) => { + expect( + ocfDeepEqual( + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', [field]: null }, + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP' } + ) + ).toBe(false); + } + ); + test('does not ignore removed new_relationships compatibility data', () => { expect(DEFAULT_DEPRECATED_FIELDS).not.toContain('new_relationships'); expect( @@ -313,4 +349,15 @@ describe('diffOcfObjects', () => { const diffs = diffOcfObjects({ date: '2024-08-14T00:00:00.000Z' }, { date: '2024-08-14' }); expect(diffs).toHaveLength(0); }); + + test.each(['relationship_started', 'relationship_ended'] as const)( + 'reports exact enum differences for %s without trimming', + (field) => { + const diffs = diffOcfObjects( + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', [field]: 'ADVISOR' }, + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', [field]: ' ADVISOR ' } + ); + expect(diffs).toEqual([`${field}: "ADVISOR" != " ADVISOR "`]); + } + ); });