diff --git a/scripts/check-built-cardinality.cjs b/scripts/check-built-cardinality.cjs index 2824ce50..0a6347c2 100644 --- a/scripts/check-built-cardinality.cjs +++ b/scripts/check-built-cardinality.cjs @@ -10,7 +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'), @@ -23,11 +26,55 @@ 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; +const expectedStakeholderRelationships = [ + 'ADVISOR', + 'BOARD_MEMBER', + 'CONSULTANT', + 'EMPLOYEE', + 'EX_ADVISOR', + 'EX_CONSULTANT', + 'EX_EMPLOYEE', + 'EXECUTIVE', + 'FOUNDER', + 'INVESTOR', + 'NON_US_EMPLOYEE', + 'OFFICER', + 'OTHER', +]; +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) => { assert(error instanceof OcpValidationError); diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index ea39ea8e..b706def9 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -155,7 +155,7 @@ function createEntityDataDecoder( if (isComplexIssuanceEntityType(entityType)) { validateComplexIssuanceDamlDataInput(entityType, decoderInput); } - if (isStakeholderEventEntityType(entityType)) { + if (entityType === 'stakeholder' || isStakeholderEventEntityType(entityType)) { assertSafeGeneratedDamlJson(decoderInput, entityType); } else { assertCanonicalJsonGraph(decoderInput, entityType); diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index 536aef77..81eeb6c9 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -242,6 +242,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); + } assertCanonicalJsonGraph(data, type); switch (type) { @@ -250,8 +256,6 @@ export function convertToOcf( return damlDocumentDataToNative(data); case 'issuer': return damlIssuerDataToNative(data); - case 'stakeholder': - return damlStakeholderDataToNative(data); case 'stockClass': return damlStockClassDataToNative(data); case 'stockLegendTemplate': diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index 336d2c40..fba040fe 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -127,6 +127,7 @@ export type ImmutableOcfReadEntityType = | 'equityCompensationExercise' | 'equityCompensationTransfer' | 'issuerAuthorizedSharesAdjustment' + | 'stakeholder' | 'stakeholderRelationshipChangeEvent' | 'stakeholderStatusChangeEvent' | 'stockClassAuthorizedSharesAdjustment' diff --git a/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts b/src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf.ts index 61cf4da0..e0615fad 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 { validateStakeholderData } from '../../../utils/entityValidators'; import { damlEmailTypeToNative, @@ -21,25 +21,27 @@ import { } from '../../../utils/enumConversions'; import { requireGeneratedRecord } from '../../../utils/generatedDamlValidation'; import { damlAddressToNative, isRecord } from '../../../utils/typeConversions'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { decodeDamlEntityData, extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { assertCanonicalJsonGraph } from '../shared/ocfValues'; 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: DeepReadonly +): DeepReadonly { // Validate required field if (!damlInfo.name.legal_name) { throw new OcpValidationError('contactInfo.name.legal_name', 'Required field is missing', { @@ -48,38 +50,40 @@ 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 { + damlInfo: DeepReadonly +): 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(value: unknown): OcfStakeholder { - assertCanonicalJsonGraph(value, 'stakeholder'); - const damlData = requireGeneratedRecord( - value, - 'stakeholder' - ) as unknown as Fairmint.OpenCapTable.OCF.Stakeholder.StakeholderOcfData; - const { id: generatedId } = damlData; +export function damlStakeholderDataToNative(value: unknown): OcfStakeholderOutput { + // Keep the established direct-converter nullish diagnostics without walking + // non-null hostile graphs before the bounded generated-DAML decoder owns them. + if (value === null || value === undefined) { + assertCanonicalJsonGraph(value, 'stakeholder'); + requireGeneratedRecord(value, 'stakeholder'); + } + const data = decodeDamlEntityData('stakeholder', value); + 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', { @@ -88,7 +92,7 @@ export function damlStakeholderDataToNative(value: unknown): OcfStakeholder { }); } - 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, @@ -102,7 +106,7 @@ export function damlStakeholderDataToNative(value: unknown): OcfStakeholder { 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 } @@ -110,41 +114,44 @@ export function damlStakeholderDataToNative(value: unknown): OcfStakeholder { ...(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 } : {}), - }; + addresses, + tax_ids: taxIds, + ...(comments.length > 0 ? { comments } : {}), + } satisfies OcfStakeholderOutput; validateStakeholderData(native, 'stakeholder'); - return native; + return Object.freeze(native); } export interface GetStakeholderAsOcfParams extends GetByContractIdParams {} export interface GetStakeholderAsOcfResult { - stakeholder: OcfStakeholder; - contractId: string; + readonly stakeholder: OcfStakeholderOutput; + readonly contractId: string; } /** @@ -167,5 +174,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/native.ts b/src/types/native.ts index 7ca24363..7621af79 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -1889,20 +1889,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 = Object.freeze([ + '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/types/output.ts b/src/types/output.ts index 16317c52..f760f075 100644 --- a/src/types/output.ts +++ b/src/types/output.ts @@ -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/entityValidators.ts b/src/utils/entityValidators.ts index e8915f9c..b04e1e26 100644 --- a/src/utils/entityValidators.ts +++ b/src/utils/entityValidators.ts @@ -408,7 +408,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', diff --git a/src/utils/enumConversions.ts b/src/utils/enumConversions.ts index e03836b6..52400e31 100644 --- a/src/utils/enumConversions.ts +++ b/src/utils/enumConversions.ts @@ -19,15 +19,18 @@ */ import { OcpErrorCodes, OcpParseError } from '../errors'; -import type { - EmailType, - PhoneType, - StakeholderRelationshipType, - StakeholderStatus, - StakeholderType, - StockClassType, +import { + STAKEHOLDER_RELATIONSHIP_TYPES, + type EmailType, + type PhoneType, + type StakeholderRelationshipType, + type StakeholderStatus, + type StakeholderType, + type StockClassType, } from '../types/native'; +export { STAKEHOLDER_RELATIONSHIP_TYPES } from '../types/native'; + // Keep public utility declarations structural so generated DAML codecs remain // an implementation detail of the ledger boundary. type DamlEmailType = 'OcfEmailTypeBusiness' | 'OcfEmailTypePersonal' | 'OcfEmailTypeOther'; @@ -265,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', @@ -279,12 +282,7 @@ export const STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML = { NON_US_EMPLOYEE: 'OcfRelNonUsEmployee', OFFICER: 'OcfRelOfficer', OTHER: 'OcfRelOther', -} as const satisfies Record; - -/** All canonical native relationship values, derived from the exhaustive mapping. */ -export const STAKEHOLDER_RELATIONSHIP_TYPES = Object.freeze( - Object.keys(STAKEHOLDER_RELATIONSHIP_TYPE_TO_DAML) as StakeholderRelationshipType[] -); +} 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 c7dd98cd..155a0803 100644 --- a/src/utils/ocfComparison.ts +++ b/src/utils/ocfComparison.ts @@ -31,7 +31,6 @@ export const DEFAULT_INTERNAL_FIELDS = [ */ export const DEFAULT_DEPRECATED_FIELDS = [ 'option_grant_type', // Deprecated in favor of compensation_type - 'new_relationships', // Deprecated in favor of relationship_started/relationship_ended 'stock_class_id', // Deprecated in favor of stock_class_ids (StockPlan) ] as const; @@ -83,7 +82,44 @@ export interface OcfComparisonResult { * @param value - Value to normalize * @returns Normalized value for comparison */ -function normalizeOcfValue(value: unknown): unknown { +function isCurrentRelationshipsPath(path: string): boolean { + return path === 'current_relationships' || path.endsWith('.current_relationships'); +} + +function isCurrentRelationshipElementPath(path: string): boolean { + return /(?:^|\.)current_relationships\[\d+\]$/.test(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 { + if (!isCurrentRelationshipsPath(path)) return false; + return ( + (left === undefined && Array.isArray(right) && right.length === 0) || + (right === undefined && Array.isArray(left) && left.length === 0) + ); +} + +function normalizeOcfValue(value: unknown, path = ''): unknown { + // Stakeholder relationships are closed canonical enum values. Comparison is + // raw and type-sensitive at these positions. + 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 // strings (e.g., "22500"). Without this, they would always fail the type check. @@ -252,8 +288,10 @@ export function ocfCompare(a: unknown, b: unknown, options?: OcfComparisonOption const differences: string[] = []; function compare(valA: unknown, valB: unknown, path: string): boolean { + if (areEquivalentEmptyCurrentRelationships(path, valA, valB)) return true; + // Consider empty arrays equivalent to undefined - if (isUndefinedLike(valA) && isUndefinedLike(valB)) return true; + if (!isStrictStakeholderRelationshipPath(path) && isUndefinedLike(valA) && isUndefinedLike(valB)) return true; // Handle null/undefined if (valA === valB) return true; @@ -270,8 +308,8 @@ export function ocfCompare(a: unknown, b: unknown, options?: OcfComparisonOption (typeof valA === 'number' && typeof valB === 'string') || (typeof valA === 'string' && typeof valB === 'number') ) { - const normalizedA = normalizeOcfValue(valA); - const normalizedB = normalizeOcfValue(valB); + const normalizedA = normalizeOcfValue(valA, path); + const normalizedB = normalizeOcfValue(valB, path); if (normalizedA === normalizedB) return true; } differences.push(`${path}: type mismatch (${typeof valA} vs ${typeof valB})`); @@ -286,6 +324,10 @@ export function ocfCompare(a: unknown, b: unknown, options?: OcfComparisonOption // Handle arrays if (Array.isArray(valA) && Array.isArray(valB)) { if (valA.length !== valB.length) { + if (isCurrentRelationshipsPath(path)) { + differences.push(`${path}: array length mismatch (${valA.length} vs ${valB.length})`); + return false; + } // Check if difference is just undefined-like elements const filteredA = valA.filter((v) => !isUndefinedLike(v)); const filteredB = valB.filter((v) => !isUndefinedLike(v)); @@ -319,12 +361,22 @@ export function ocfCompare(a: unknown, b: unknown, options?: OcfComparisonOption const childPath = path ? `${path}.${key}` : key; if (isSchemaDefaultEquivalent(childPath, childValA, childValB)) continue; + if (areEquivalentEmptyCurrentRelationships(childPath, childValA, childValB)) continue; // Treat empty arrays as undefined-like and skip if both are undefined-like - if (isUndefinedLike(childValA) && isUndefinedLike(childValB)) continue; + if ( + !isStrictStakeholderRelationshipPath(childPath) && + isUndefinedLike(childValA) && + isUndefinedLike(childValB) + ) { + continue; + } // If one is undefined-like and the other isn't, they don't match - if (isUndefinedLike(childValA) !== isUndefinedLike(childValB)) { + if ( + !isStrictStakeholderRelationshipPath(childPath) && + isUndefinedLike(childValA) !== isUndefinedLike(childValB) + ) { differences.push(`${childPath}: one side is empty/undefined`); allMatch = false; continue; @@ -340,8 +392,8 @@ export function ocfCompare(a: unknown, b: unknown, options?: OcfComparisonOption } // Handle primitive values with normalization - const normalizedA = normalizeOcfValue(valA); - const normalizedB = normalizeOcfValue(valB); + const normalizedA = normalizeOcfValue(valA, path); + const normalizedB = normalizeOcfValue(valB, path); if (normalizedA !== normalizedB) { differences.push(`${path}: ${JSON.stringify(valA)} !== ${JSON.stringify(valB)}`); @@ -390,16 +442,18 @@ export function ocfCompare(a: unknown, b: unknown, options?: OcfComparisonOption export function diffOcfObjects(a: unknown, b: unknown, path = ''): string[] { const diffs: string[] = []; + if (areEquivalentEmptyCurrentRelationships(path, a, b)) return diffs; + // Consider empty arrays equivalent to undefined - if (isUndefinedLike(a) && isUndefinedLike(b)) { + if (!isStrictStakeholderRelationshipPath(path) && isUndefinedLike(a) && isUndefinedLike(b)) { return diffs; } if (typeof a !== typeof b) { // If one is a number and the other is a string, try normalizing both before rejecting if ((typeof a === 'number' && typeof b === 'string') || (typeof a === 'string' && typeof b === 'number')) { - const normalizedA = normalizeOcfValue(a); - const normalizedB = normalizeOcfValue(b); + const normalizedA = normalizeOcfValue(a, path); + const normalizedB = normalizeOcfValue(b, path); if (normalizedA === normalizedB) return diffs; } diffs.push(`${path || '(root)'}: type mismatch (${typeof a} vs ${typeof b})`); @@ -410,8 +464,8 @@ export function diffOcfObjects(a: unknown, b: unknown, path = ''): string[] { // Handle arrays with proper [index] path format if (Array.isArray(a) && Array.isArray(b)) { // Check for length mismatch (filtering undefined-like elements) - const filteredA = a.filter((v) => !isUndefinedLike(v)); - const filteredB = b.filter((v) => !isUndefinedLike(v)); + const filteredA = isCurrentRelationshipsPath(path) ? a : a.filter((v) => !isUndefinedLike(v)); + const filteredB = isCurrentRelationshipsPath(path) ? b : b.filter((v) => !isUndefinedLike(v)); if (filteredA.length !== filteredB.length) { diffs.push(`${path || '(root)'}: array length mismatch (${a.length} vs ${b.length})`); } @@ -423,12 +477,12 @@ export function diffOcfObjects(a: unknown, b: unknown, path = ''): string[] { const av = a[i]; const bv = b[i]; - if (isUndefinedLike(av) && isUndefinedLike(bv)) continue; - if (!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 (isUndefinedLike(av) && !isUndefinedLike(bv)) { + if (!isStrictStakeholderRelationshipPath(subPath) && isUndefinedLike(av) && !isUndefinedLike(bv)) { diffs.push(`${subPath}: present in DB only -> ${JSON.stringify(bv)}`); continue; } @@ -452,13 +506,14 @@ export function diffOcfObjects(a: unknown, b: unknown, path = ''): string[] { const bv = objB[key]; if (isSchemaDefaultEquivalent(subPath, av, bv)) continue; + if (areEquivalentEmptyCurrentRelationships(subPath, av, bv)) continue; - if (isUndefinedLike(av) && isUndefinedLike(bv)) continue; - if (!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 (isUndefinedLike(av) && !isUndefinedLike(bv)) { + if (!isStrictStakeholderRelationshipPath(subPath) && isUndefinedLike(av) && !isUndefinedLike(bv)) { diffs.push(`${subPath}: present in DB only -> ${JSON.stringify(bv)}`); continue; } @@ -468,8 +523,8 @@ export function diffOcfObjects(a: unknown, b: unknown, path = ''): string[] { } // Compare primitive values with normalization - const normalizedA = normalizeOcfValue(a); - const normalizedB = normalizeOcfValue(b); + const normalizedA = normalizeOcfValue(a, path); + const normalizedB = normalizeOcfValue(b, path); if (normalizedA !== normalizedB) { diffs.push(`${path || '(root)'}: ${JSON.stringify(normalizedA)} != ${JSON.stringify(normalizedB)}`); diff --git a/src/utils/ocfZodSchemas.ts b/src/utils/ocfZodSchemas.ts index 817913c0..5c47bd73 100644 --- a/src/utils/ocfZodSchemas.ts +++ b/src/utils/ocfZodSchemas.ts @@ -16,10 +16,11 @@ import { ratioMechanismToDaml, warrantMechanismToDaml, } from '../functions/OpenCapTable/shared/conversionMechanisms'; -import type { - ConvertibleConversionMechanism, - PersistedStockClassRatioConversionMechanism, - PersistedWarrantConversionMechanism, +import { + STAKEHOLDER_RELATIONSHIP_TYPES, + type ConvertibleConversionMechanism, + type PersistedStockClassRatioConversionMechanism, + type PersistedWarrantConversionMechanism, } from '../types/native'; import { assertConversionTriggerListSemantics } from './conversionTriggers'; import { assertSafeOcfJson } from './ocfJsonValidation'; @@ -474,6 +475,66 @@ 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, + }); + } + 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, + }); + } + } + return; + } + + 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', + '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; for (const field of ['exercise_price', 'base_price'] as const) { @@ -536,6 +597,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_EQUITY_COMPENSATION_ISSUANCE: ['plan_security_type'], diff --git a/test/batch/CapTableBatch.test.ts b/test/batch/CapTableBatch.test.ts index 7118d49d..f1906649 100644 --- a/test/batch/CapTableBatch.test.ts +++ b/test/batch/CapTableBatch.test.ts @@ -6,6 +6,7 @@ import type { CapTableBatchOperations } from '../../src/functions/OpenCapTable/c import { buildUpdateCapTableCommand, CapTableBatch, ENTITY_TAG_MAP } from '../../src/functions/OpenCapTable/capTable'; import type { OcfStakeholder, + OcfStakeholderRelationshipChangeEvent, OcfStockClass, OcfStockClassConversionRatioAdjustment, OcfStockClassSplit, @@ -78,6 +79,35 @@ describe('CapTableBatch', () => { expect(batch.size).toBe(1); }); + it('should preserve stakeholder relationship order and duplicates', () => { + const batch = new CapTableBatch({ + capTableContractId: 'cap-table-123', + actAs: ['party-1'], + }); + const stakeholderData: OcfStakeholder = { + object_type: 'STAKEHOLDER', + id: 'sh-relationships', + name: { legal_name: 'Ordered Stakeholder' }, + stakeholder_type: 'INDIVIDUAL', + current_relationships: ['INVESTOR', 'ADVISOR', 'INVESTOR'], + }; + + batch.create('stakeholder', stakeholderData); + const { command } = batch.build(); + if (!('ExerciseCommand' in command)) throw new Error('Expected ExerciseCommand'); + const choiceArgument = command.ExerciseCommand.choiceArgument as { + creates: Array<{ tag: string; value: { current_relationships: string[] } }>; + }; + + expect(choiceArgument.creates[0]).toMatchObject({ + tag: 'OcfCreateStakeholder', + value: { + current_relationships: ['OcfRelInvestor', 'OcfRelAdvisor', 'OcfRelInvestor'], + }, + }); + expect(stakeholderData.current_relationships).toEqual(['INVESTOR', 'ADVISOR', 'INVESTOR']); + }); + it('should reject schema-invalid create payloads at write boundary', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', @@ -120,6 +150,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 non-schema stock class split ratio fields', () => { const batch = new CapTableBatch({ capTableContractId: 'cap-table-123', diff --git a/test/batch/stakeholderEventOperations.test.ts b/test/batch/stakeholderEventOperations.test.ts index 3594b9d4..7869cc78 100644 --- a/test/batch/stakeholderEventOperations.test.ts +++ b/test/batch/stakeholderEventOperations.test.ts @@ -1,3 +1,4 @@ +import { OcpValidationError } from '../../src/errors'; import { CapTableBatch } from '../../src/functions/OpenCapTable/capTable/CapTableBatch'; import { ENTITY_TAG_MAP, @@ -222,4 +223,40 @@ describe('stakeholder event operation boundaries', () => { expect(choiceArgument.edits).toEqual([{ tag: testCase.editTag, value: testCase.expected }]); } ); + + it.each([ + ['lowercase', { relationship_started: 'advisor' }], + ['leading whitespace', { relationship_started: ' ADVISOR' }], + ['trailing whitespace', { relationship_started: 'ADVISOR ' }], + ['unknown value', { relationship_started: 'UNKNOWN_RELATIONSHIP' }], + ['legacy discriminator', { object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT' }], + ['legacy payload field', { relationship_started: 'ADVISOR', new_relationships: ['FOUNDER'] }], + ] as const)('rejects %s relationship event input through every operation path', (_label, overrides) => { + const data = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'invalid-relationship-event', + date: '2026-07-10', + stakeholder_id: 'stakeholder-1', + relationship_started: 'ADVISOR', + ...overrides, + } as unknown as OcfStakeholderRelationshipChangeEvent; + const operation = { + type: 'stakeholderRelationshipChangeEvent', + data, + } as OcfCreateOperation; + + const actions = [ + () => stakeholderRelationshipChangeEventDataToDaml(data), + () => convertToDaml('stakeholderRelationshipChangeEvent', data), + () => convertOperationToDaml(operation), + () => buildOcfCreateData('stakeholderRelationshipChangeEvent', data), + () => buildOcfCreateDataFromOperation(operation), + () => { + const batch = new CapTableBatch({ capTableContractId: 'cap-table-invalid-event', actAs: ['issuer::party'] }); + batch.create('stakeholderRelationshipChangeEvent', data); + }, + ]; + + for (const action of actions) expect(action).toThrow(OcpValidationError); + }); }); diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 7628d2fb..2f798ef0 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -1,6 +1,6 @@ import { Canton, type ClientConfig } from '@fairmint/canton-node-sdk'; import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; -import { OcpErrorCodes } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { ENTITY_REGISTRY, OCF_OBJECT_TYPE_TO_ENTITY_TYPE, @@ -14,7 +14,7 @@ import { type AuthorizeIssuerResult, } from '../../src/functions/OpenCapTable/issuerAuthorization/authorizeIssuer'; import { OcpClient } from '../../src/OcpClient'; -import type { ContractResult, OcfOutputForObjectType } from '../../src/types'; +import type { ContractResult, OcfOutputForObjectType, OcfStakeholder } from '../../src/types'; import { createLedgerAndValidatorClients, createLedgerJsonApiClient, @@ -710,6 +710,56 @@ describe('OcpClient OpenCapTable.capTable facade', () => { expect(getStateSpy).toHaveBeenCalledTimes(1); expect(getStateSpy).toHaveBeenCalledWith(ledger, 'issuer::party-2'); }); + + it('preserves ordered duplicate relationships through capTable.update', () => { + const ledger = createLedgerJsonApiClient(config); + const ocp = new OcpClient({ ledger }); + const stakeholder: OcfStakeholder = { + object_type: 'STAKEHOLDER', + id: 'stakeholder-relationships', + name: { legal_name: 'Ordered Stakeholder' }, + stakeholder_type: 'INDIVIDUAL', + current_relationships: ['INVESTOR', 'ADVISOR', 'INVESTOR'], + }; + + const batch = ocp.OpenCapTable.capTable.update({ capTableContractId: 'cap-table-1', actAs: ['issuer::party'] }); + batch.create('stakeholder', stakeholder); + const { command } = batch.build(); + if (!('ExerciseCommand' in command)) throw new Error('Expected ExerciseCommand'); + const choiceArgument = command.ExerciseCommand.choiceArgument as { + creates: Array<{ value: { current_relationships: string[] } }>; + }; + + expect(choiceArgument.creates[0]?.value.current_relationships).toEqual([ + 'OcfRelInvestor', + 'OcfRelAdvisor', + 'OcfRelInvestor', + ]); + expect(stakeholder.current_relationships).toEqual(['INVESTOR', 'ADVISOR', 'INVESTOR']); + }); + + it.each([ + ['lowercase', { relationship_started: 'advisor' }], + ['padded', { relationship_started: ' ADVISOR ' }], + ['unknown', { relationship_started: 'UNKNOWN_RELATIONSHIP' }], + ['legacy discriminator', { object_type: 'TX_STAKEHOLDER_RELATIONSHIP_CHANGE_EVENT' }], + ['legacy field', { relationship_started: 'ADVISOR', new_relationships: ['FOUNDER'] }], + ] as const)('rejects %s relationship events through capTable.update', (_label, overrides) => { + const ledger = createLedgerJsonApiClient(config); + const ocp = new OcpClient({ ledger }); + const batch = ocp.OpenCapTable.capTable.update({ capTableContractId: 'cap-table-1', actAs: ['issuer::party'] }); + const event = { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'invalid-relationship-event', + date: '2026-07-10', + stakeholder_id: 'stakeholder-1', + relationship_started: 'ADVISOR', + ...overrides, + }; + + expect(() => batch.create('stakeholderRelationshipChangeEvent', event as never)).toThrow(OcpValidationError); + expect(batch.size).toBe(0); + }); }); describe('OcpClient OpenCapTable entity facade', () => { diff --git a/test/converters/coreObjectReadValidation.test.ts b/test/converters/coreObjectReadValidation.test.ts index 1a57b64b..6b4224c0 100644 --- a/test/converters/coreObjectReadValidation.test.ts +++ b/test/converters/coreObjectReadValidation.test.ts @@ -1,4 +1,6 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src'; +import type { ReadonlyDamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable'; +import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { damlDocumentDataToNative } from '../../src/functions/OpenCapTable/document/getDocumentAsOcf'; import { damlStakeholderDataToNative } from '../../src/functions/OpenCapTable/stakeholder/getStakeholderAsOcf'; @@ -25,8 +27,8 @@ const minimalDocument = { comments: [], }; -function asDamlStakeholder(value: object): Parameters[0] { - return value; +function asDamlStakeholder(value: object): ReadonlyDamlDataTypeFor<'stakeholder'> { + return value as unknown as ReadonlyDamlDataTypeFor<'stakeholder'>; } function asDamlDocument(value: object): Parameters[0] { @@ -46,6 +48,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 3de3bf18..353a6cec 100644 --- a/test/declarations/damlReadDispatch.types.ts +++ b/test/declarations/damlReadDispatch.types.ts @@ -9,14 +9,14 @@ import { type ReadonlyDamlDataTypeFor, } 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: ReadonlyDamlDataTypeFor<'stakeholder'> = decodeDamlEntityData( 'stakeholder', unknownLedgerData diff --git a/test/declarations/replicationHelpers.types.ts b/test/declarations/replicationHelpers.types.ts index bb95d83e..b1660332 100644 --- a/test/declarations/replicationHelpers.types.ts +++ b/test/declarations/replicationHelpers.types.ts @@ -7,6 +7,7 @@ import { type CapTableState, type OcfEntityType, type OcfStakeholder, + type OcfStakeholderOutput, type OcfStockClass, type ReadonlyOcfEntityData, type ReplicationCreateItem, @@ -24,6 +25,7 @@ cantonData.set('stakeholder', new Map([['stakeholder-1', stakeholder]])); const stakeholderData: ReadonlyMap> | undefined = cantonData.get('stakeholder'); +const stakeholderOutputData: ReadonlyMap | undefined = stakeholderData; // @ts-expect-error the entity key determines the exact canonical value shape cantonData.set('stakeholder', new Map([['stock-class-1', stockClass]])); @@ -86,6 +88,7 @@ const deleteOperation: 'delete' = replicationDiff.deletes[0]!.operation; replicationDiff.deletes[0]!.data; void stakeholderData; +void stakeholderOutputData; void invalidSourceItem; void stakeholderCreateData; void invalidStakeholderCreateData; diff --git a/test/declarations/stakeholderRelationships.types.ts b/test/declarations/stakeholderRelationships.types.ts new file mode 100644 index 00000000..ae970697 --- /dev/null +++ b/test/declarations/stakeholderRelationships.types.ts @@ -0,0 +1,102 @@ +/** Compile-time contracts for built stakeholder relationship declarations. */ + +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', + 'BOARD_MEMBER', + 'CONSULTANT', + 'EMPLOYEE', + 'EX_ADVISOR', + 'EX_CONSULTANT', + 'EX_EMPLOYEE', + 'EXECUTIVE', + 'FOUNDER', + 'INVESTOR', + 'NON_US_EMPLOYEE', + 'OFFICER', + 'OTHER', +]; +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = + IsAny extends true + ? false + : IsAny extends true + ? false + : [A] extends [B] + ? [B] extends [A] + ? true + : false + : false; +type EveryTrue = Exclude extends never ? true : false; + +type ExpectedDamlRelationship = + | 'OcfRelAdvisor' + | 'OcfRelBoardMember' + | 'OcfRelConsultant' + | 'OcfRelEmployee' + | 'OcfRelExAdvisor' + | 'OcfRelExConsultant' + | 'OcfRelExEmployee' + | 'OcfRelExecutive' + | 'OcfRelFounder' + | 'OcfRelInvestor' + | 'OcfRelNonUsEmployee' + | 'OcfRelOfficer' + | 'OcfRelOther'; +type GeneratedDamlRelationship = DamlDataTypeFor<'stakeholder'>['current_relationships'][number]; + +const exactTuple: Assert> = true; +const exactUnion: Assert> = true; +const exactStakeholderField: Assert< + IsExactly +> = true; +const exactReadonlyOutputField: Assert< + IsExactly +> = true; +const exactStakeholderReaderOutput: Assert< + IsExactly, OcfStakeholderOutput> +> = true; +const exactGeneratedDamlUnion: Assert> = true; +const relationshipTypesAreNotAny: Assert< + EveryTrue< + [ + IsExactly, false>, + IsExactly, false>, + IsExactly, false>, + IsExactly, false>, + IsExactly, false>, + ] + > +> = 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'; +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/functions/stakeholderEventReaders.test.ts b/test/functions/stakeholderEventReaders.test.ts index f589a25a..6efd0d22 100644 --- a/test/functions/stakeholderEventReaders.test.ts +++ b/test/functions/stakeholderEventReaders.test.ts @@ -20,11 +20,18 @@ import { stakeholderRelationshipChangeEventDataToDaml } from '../../src/function import { damlStakeholderStatusChangeEventToNative } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/damlToOcf'; import { getStakeholderStatusChangeEventAsOcf } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/getStakeholderStatusChangeEventAsOcf'; import { stakeholderStatusChangeEventDataToDaml } from '../../src/functions/OpenCapTable/stakeholderStatusChangeEvent/stakeholderStatusChangeEventDataToDaml'; -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 type { OcfStakeholderRelationshipChangeEventOutput, OcfStakeholderStatusChangeEventOutput, } from '../../src/types/output'; +import { stakeholderRelationshipTypeToDaml } from '../../src/utils/enumConversions'; +import { createLedgerJsonApiClient } from '../utils/cantonNodeSdkCompat'; type StakeholderEventEntityType = Extract< OcfEntityType, @@ -134,9 +141,16 @@ function createMockClient( }, }, }); + const client = createLedgerJsonApiClient({ network: 'devnet' }); + Object.defineProperty(client, 'getEventsByContractId', { + value: getEventsByContractId, + configurable: true, + enumerable: true, + writable: true, + }); return { - client: { getEventsByContractId } as unknown as LedgerJsonApiClient, + client, getEventsByContractId, }; } @@ -467,6 +481,131 @@ 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.UNKNOWN_ENUM_VALUE, + 'stakeholderRelationshipChangeEvent.relationship_started', + ], + [ + { relationship_started: ' ADVISOR' }, + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + 'stakeholderRelationshipChangeEvent.relationship_started', + ], + [ + { relationship_started: 'ADVISOR ' }, + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + 'stakeholderRelationshipChangeEvent.relationship_started', + ], + [ + { relationship_started: 'UNKNOWN_RELATIONSHIP' }, + OcpErrorCodes.UNKNOWN_ENUM_VALUE, + '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.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()); 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 a27f9e6d..3cd8826f 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 4a105d99..6462ff8e 100644 --- a/test/package-consumer/publicEntrypoint.types.ts +++ b/test/package-consumer/publicEntrypoint.types.ts @@ -10,6 +10,8 @@ import type { OcfManifest, OcfObject, OcfReadDataTypeFor, + OcfStakeholder, + OcfStakeholderOutput, OcfStakeholderRelationshipChangeEvent, OcfStakeholderStatusChangeEvent, OcfStockConsolidation, @@ -21,11 +23,32 @@ import type { OcpValidationError, SharedSecretEnvironmentConfigInput, SourceReplicationItem, + StakeholderRelationshipType, SubmitAndWaitForTransactionTreeResponse, } from '@open-captable-protocol/canton'; -import { buildCantonOcfDataMap, computeReplicationDiff } from '@open-captable-protocol/canton'; +import { + buildCantonOcfDataMap, + computeReplicationDiff, + STAKEHOLDER_RELATIONSHIP_TYPES, +} from '@open-captable-protocol/canton'; import type { Assert, IsAny, IsExactly } from '../typeContracts/typeAssertions'; +type ExpectedRelationshipTuple = readonly [ + 'ADVISOR', + 'BOARD_MEMBER', + 'CONSULTANT', + 'EMPLOYEE', + 'EX_ADVISOR', + 'EX_CONSULTANT', + 'EX_EMPLOYEE', + 'EXECUTIVE', + 'FOUNDER', + 'INVESTOR', + 'NON_US_EMPLOYEE', + 'OFFICER', + 'OTHER', +]; + const packageExactnessRejectsCompilerAny: Assert< IsExactly, 'canonical'>, false> > = true; @@ -123,6 +146,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'], DeepReadonly> > = true; @@ -185,6 +209,24 @@ const packageStakeholderEventTypesAreNotAny: Assert, + IsExactly, + IsExactly, + IsExactly, false>, + IsExactly, false>, + IsExactly, false>, +]; +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 = (null as unknown as OcfStockReissuance).resulting_security_ids[0]; // @ts-expect-error package-root reissuance results are statically non-empty @@ -234,6 +276,10 @@ void stakeholderStatusRead; void packageStakeholderRelationshipReaderIsExact; void packageStakeholderStatusReaderIsExact; void packageStakeholderEventTypesAreNotAny; +void STAKEHOLDER_RELATIONSHIP_TYPES; +void packageStakeholderRelationshipsAreExact; +void stakeholderRead; +void packageStakeholderReaderIsExact; void packageFirstConsolidationSource; void packageFirstReissuanceResult; void packageEmptyReissuanceResults; diff --git a/test/publicApi/rootExports.test.ts b/test/publicApi/rootExports.test.ts index dfd85243..4589c400 100644 --- a/test/publicApi/rootExports.test.ts +++ b/test/publicApi/rootExports.test.ts @@ -34,6 +34,7 @@ describe('package root exports', () => { 'OcpValidationError', 'SCRATCHNET_PRESET', 'STAGING_PRESET', + 'STAKEHOLDER_RELATIONSHIP_TYPES', 'TESTNET_PRESET', 'applyCommandContext', 'authorizeIssuer', @@ -71,4 +72,26 @@ 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).toEqual([ + 'ADVISOR', + 'BOARD_MEMBER', + 'CONSULTANT', + 'EMPLOYEE', + 'EX_ADVISOR', + 'EX_CONSULTANT', + 'EX_EMPLOYEE', + 'EXECUTIVE', + 'FOUNDER', + 'INVESTOR', + 'NON_US_EMPLOYEE', + 'OFFICER', + 'OTHER', + ]); + expect(() => (sdk.STAKEHOLDER_RELATIONSHIP_TYPES as unknown as string[]).push('LEGACY_RELATIONSHIP')).toThrow( + TypeError + ); + }); }); diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index 75d2e408..3455213d 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -1,5 +1,5 @@ { - "fingerprint": "4d9c483d1a3f70fc6ab7214959d1227a708a86d1e4bdb0d8955bca54a124798c", + "fingerprint": "bfe3eb7c647f90137392413220671f66f4cba63a30cf13041c6e16ec33492cc4", "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/canonicalSchemaPurity.test.ts b/test/schemaAlignment/canonicalSchemaPurity.test.ts index 57cf4ea5..0b570cf9 100644 --- a/test/schemaAlignment/canonicalSchemaPurity.test.ts +++ b/test/schemaAlignment/canonicalSchemaPurity.test.ts @@ -288,14 +288,14 @@ describe('canonical public DTO field purity', () => { }); describe('DAML readers emit canonical public DTO fields only', () => { - it('emits current_relationships for stakeholders', () => { + it('emits current_relationships in ledger order with duplicates preserved', () => { const result = damlStakeholderDataToNative({ id: 'stakeholder-1', name: { legal_name: 'Canonical Stakeholder', first_name: null, last_name: null }, stakeholder_type: 'OcfStakeholderTypeIndividual', addresses: [], comments: [], - current_relationships: ['OcfRelInvestor'], + current_relationships: ['OcfRelInvestor', 'OcfRelAdvisor', 'OcfRelInvestor'], tax_ids: [], contact_info: null, current_status: null, @@ -303,7 +303,7 @@ describe('DAML readers emit canonical public DTO fields only', () => { primary_contact: null, }); - expect(result.current_relationships).toEqual(['INVESTOR']); + expect(result.current_relationships).toEqual(['INVESTOR', 'ADVISOR', 'INVESTOR']); expect(result).not.toHaveProperty('current_relationship'); }); diff --git a/test/schemaAlignment/enumAlignment.test.ts b/test/schemaAlignment/enumAlignment.test.ts index e52a179d..f5b47dd8 100644 --- a/test/schemaAlignment/enumAlignment.test.ts +++ b/test/schemaAlignment/enumAlignment.test.ts @@ -1,5 +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'); @@ -12,21 +15,7 @@ const ENUM_MAPPINGS: Record< RoundingType: { sdkValues: ['CEILING', 'FLOOR', 'NORMAL'] }, ConvertibleType: { sdkValues: ['NOTE', 'SAFE', 'CONVERTIBLE_SECURITY'] }, StakeholderRelationshipType: { - sdkValues: [ - 'ADVISOR', - 'BOARD_MEMBER', - 'CONSULTANT', - 'EMPLOYEE', - 'EX_ADVISOR', - 'EX_CONSULTANT', - 'EX_EMPLOYEE', - 'EXECUTIVE', - 'FOUNDER', - 'INVESTOR', - 'NON_US_EMPLOYEE', - 'OFFICER', - 'OTHER', - ], + sdkValues: [...STAKEHOLDER_RELATIONSHIP_TYPES], }, ConversionTriggerType: { sdkValues: [ @@ -194,6 +183,22 @@ function getOcfEnumValues(schemaPath: string): string[] { } describe('OCF Enum Schema Alignment', () => { + it('uses the pinned StakeholderRelationshipType values in exact schema order', () => { + const schemaPath = path.join(ENUM_SCHEMA_DIR, 'StakeholderRelationshipType.schema.json'); + 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/damlReadDispatch.types.ts b/test/types/damlReadDispatch.types.ts index 5a16234e..cb75498a 100644 --- a/test/types/damlReadDispatch.types.ts +++ b/test/types/damlReadDispatch.types.ts @@ -9,14 +9,14 @@ import { type ReadonlyDamlDataTypeFor, } 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: ReadonlyDamlDataTypeFor<'stakeholder'> = decodeDamlEntityData( 'stakeholder', unknownLedgerData diff --git a/test/types/replicationHelpers.types.ts b/test/types/replicationHelpers.types.ts index 3d47677b..26d37ca2 100644 --- a/test/types/replicationHelpers.types.ts +++ b/test/types/replicationHelpers.types.ts @@ -7,6 +7,7 @@ import { type CapTableState, type OcfEntityType, type OcfStakeholder, + type OcfStakeholderOutput, type OcfStockClass, type ReadonlyOcfEntityData, type ReplicationCreateItem, @@ -24,6 +25,7 @@ cantonData.set('stakeholder', new Map([['stakeholder-1', stakeholder]])); const stakeholderData: ReadonlyMap> | undefined = cantonData.get('stakeholder'); +const stakeholderOutputData: ReadonlyMap | undefined = stakeholderData; // @ts-expect-error the entity key determines the exact canonical value shape cantonData.set('stakeholder', new Map([['stock-class-1', stockClass]])); @@ -86,6 +88,7 @@ const deleteOperation: 'delete' = replicationDiff.deletes[0]!.operation; replicationDiff.deletes[0]!.data; void stakeholderData; +void stakeholderOutputData; void invalidSourceItem; void stakeholderCreateData; void invalidStakeholderCreateData; diff --git a/test/types/stakeholderRelationships.types.ts b/test/types/stakeholderRelationships.types.ts new file mode 100644 index 00000000..d1e3daf0 --- /dev/null +++ b/test/types/stakeholderRelationships.types.ts @@ -0,0 +1,102 @@ +/** Compile-time contracts for canonical source stakeholder relationships. */ + +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', + 'BOARD_MEMBER', + 'CONSULTANT', + 'EMPLOYEE', + 'EX_ADVISOR', + 'EX_CONSULTANT', + 'EX_EMPLOYEE', + 'EXECUTIVE', + 'FOUNDER', + 'INVESTOR', + 'NON_US_EMPLOYEE', + 'OFFICER', + 'OTHER', +]; +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = + IsAny extends true + ? false + : IsAny extends true + ? false + : [A] extends [B] + ? [B] extends [A] + ? true + : false + : false; +type EveryTrue = Exclude extends never ? true : false; + +type ExpectedDamlRelationship = + | 'OcfRelAdvisor' + | 'OcfRelBoardMember' + | 'OcfRelConsultant' + | 'OcfRelEmployee' + | 'OcfRelExAdvisor' + | 'OcfRelExConsultant' + | 'OcfRelExEmployee' + | 'OcfRelExecutive' + | 'OcfRelFounder' + | 'OcfRelInvestor' + | 'OcfRelNonUsEmployee' + | 'OcfRelOfficer' + | 'OcfRelOther'; +type GeneratedDamlRelationship = DamlDataTypeFor<'stakeholder'>['current_relationships'][number]; + +const exactTuple: Assert> = true; +const exactUnion: Assert> = true; +const exactStakeholderField: Assert< + IsExactly +> = true; +const exactReadonlyOutputField: Assert< + IsExactly +> = true; +const exactStakeholderReaderOutput: Assert< + IsExactly, OcfStakeholderOutput> +> = true; +const exactGeneratedDamlUnion: Assert> = true; +const relationshipTypesAreNotAny: Assert< + EveryTrue< + [ + IsExactly, false>, + IsExactly, false>, + IsExactly, false>, + IsExactly, false>, + IsExactly, false>, + ] + > +> = 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'; +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/entityValidators.test.ts b/test/utils/entityValidators.test.ts index 634434e9..7fec3d0f 100644 --- a/test/utils/entityValidators.test.ts +++ b/test/utils/entityValidators.test.ts @@ -2,6 +2,7 @@ * Unit tests for entity validators. */ import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { STAKEHOLDER_RELATIONSHIP_TYPES } from '../../src/types'; import { validateAddress, validateContactInfo, @@ -447,6 +448,66 @@ 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'); + } + }); + + 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('accepts duplicate current_relationships without changing their order', () => { + const relationships = ['INVESTOR', 'FOUNDER', 'INVESTOR']; + + expect(() => + validateStakeholderData({ ...validStakeholder, current_relationships: relationships }, 'stakeholder') + ).not.toThrow(); + expect(relationships).toEqual(['INVESTOR', 'FOUNDER', 'INVESTOR']); + }); }); describe('validateStockClassData', () => { diff --git a/test/utils/ocfComparison.test.ts b/test/utils/ocfComparison.test.ts index c49e9b06..eb8d88b3 100644 --- a/test/utils/ocfComparison.test.ts +++ b/test/utils/ocfComparison.test.ts @@ -1,6 +1,6 @@ /** Tests for OCF comparison utilities */ -import { diffOcfObjects, ocfCompare, ocfDeepEqual } from '../../src/utils/ocfComparison'; +import { DEFAULT_DEPRECATED_FIELDS, diffOcfObjects, ocfCompare, ocfDeepEqual } from '../../src/utils/ocfComparison'; describe('ocfDeepEqual', () => { test('returns true for identical objects', () => { @@ -30,6 +30,114 @@ describe('ocfDeepEqual', () => { expect(ocfDeepEqual({ quantity: '100' }, { quantity: '200' })).toBe(false); }); + test('compares canonical stakeholder relationships as a strict ordered array', () => { + expect( + ocfDeepEqual( + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR', 'FOUNDER'] }, + { object_type: 'STAKEHOLDER', current_relationships: ['FOUNDER', 'INVESTOR'] } + ) + ).toBe(false); + }); + + test('preserves duplicate stakeholder relationship positions during comparison', () => { + expect( + ocfDeepEqual( + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR', 'FOUNDER', 'INVESTOR'] }, + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR', 'FOUNDER', 'INVESTOR'] } + ) + ).toBe(true); + expect( + ocfDeepEqual( + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR', 'FOUNDER', 'INVESTOR'] }, + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR', 'INVESTOR', 'FOUNDER'] } + ) + ).toBe(false); + expect( + ocfDeepEqual( + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR', 'INVESTOR'] }, + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR'] } + ) + ).toBe(false); + }); + + test('does not trim or numerically coerce current_relationships entries', () => { + expect( + ocfDeepEqual( + { object_type: 'STAKEHOLDER', current_relationships: ['INVESTOR'] }, + { object_type: 'STAKEHOLDER', current_relationships: [' INVESTOR '] } + ) + ).toBe(false); + expect( + ocfDeepEqual( + { object_type: 'STAKEHOLDER', current_relationships: [1] }, + { object_type: 'STAKEHOLDER', current_relationships: ['1.0000000000'] } + ) + ).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( + ocfDeepEqual( + { object_type: 'CE_STAKEHOLDER_RELATIONSHIP', relationship_started: 'ADVISOR' }, + { + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + relationship_started: 'ADVISOR', + new_relationships: ['FOUNDER'], + }, + { deprecatedFields: DEFAULT_DEPRECATED_FIELDS } + ) + ).toBe(false); + }); + + test('treats an omitted relationship array as equivalent only to the ledger empty array', () => { + expect( + ocfDeepEqual({ object_type: 'STAKEHOLDER' }, { object_type: 'STAKEHOLDER', current_relationships: [] }) + ).toBe(true); + expect( + ocfDeepEqual( + { object_type: 'STAKEHOLDER', current_relationships: null }, + { object_type: 'STAKEHOLDER', current_relationships: [] } + ) + ).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); @@ -241,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 "`]); + } + ); }); diff --git a/test/utils/ocfNormalization.test.ts b/test/utils/ocfNormalization.test.ts index 810a0996..b1c4255b 100644 --- a/test/utils/ocfNormalization.test.ts +++ b/test/utils/ocfNormalization.test.ts @@ -1,6 +1,8 @@ /** Tests for canonical OCF normalization. */ +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { normalizeOcfData } from '../../src/utils/ocfNormalization'; +import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; import { requireDefined, requireFirst } from '../../src/utils/requireDefined'; import { validateOcfObject } from './ocfSchemaValidator'; @@ -115,6 +117,11 @@ describe('OCF normalization utilities', () => { expect(result).toBe(input); expect(result.current_relationships).toEqual(['INVESTOR', 'FOUNDER', 'INVESTOR']); + expect(parseOcfEntityInput('stakeholder', input).current_relationships).toEqual([ + 'INVESTOR', + 'FOUNDER', + 'INVESTOR', + ]); }); it.each(['INVESTOR', undefined])( @@ -144,9 +151,15 @@ describe('OCF normalization utilities', () => { }; expect(() => normalizeOcfData(input)).toThrow('Invalid stakeholder current_relationships entry'); + 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', @@ -156,9 +169,15 @@ describe('OCF normalization utilities', () => { }; expect(() => normalizeOcfData(input)).toThrow('Invalid stakeholder current_relationships entry'); + 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 normalization and parser boundaries', () => { const input = { object_type: 'STAKEHOLDER', id: 'sh-1', @@ -168,6 +187,12 @@ describe('OCF normalization utilities', () => { }; expect(() => normalizeOcfData(input)).toThrow('Invalid stakeholder current_relationships: expected array'); + 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 () => { diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 6017e28b..cc72b9d3 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -1,9 +1,10 @@ -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { ENTITY_OBJECT_TYPE_MAP, ENTITY_REGISTRY, type OcfEntityType, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import type { StakeholderRelationshipType } from '../../src/types/native'; import { parseOcfEntityInput, parseOcfObject, resolveOcfSchemaDir } from '../../src/utils/ocfZodSchemas'; import { requireDefined } from '../../src/utils/requireDefined'; import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from './productionFixtures'; @@ -47,6 +48,27 @@ function defineEnumerableOwnProperty( } 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 { @@ -68,6 +90,8 @@ const retiredPlanSecurityObjectTypes = [ ] as const; describe('ocfZodSchemas', () => { + void stakeholderRelationshipTypeCoverageIsExhaustive; + beforeAll(() => { if (schemaAvailabilityError) { throw schemaAvailabilityError; @@ -378,6 +402,70 @@ describe('ocfZodSchemas', () => { expect(parsedRecord.resulting_security_id).toBe('test-security-consolidated-result-001'); }); + it('reports missing canonical relationship semantics as REQUIRED_FIELD_MISSING', () => { + expect( + captureValidationError(() => + parseOcfObject({ + object_type: 'CE_STAKEHOLDER_RELATIONSHIP', + id: 'canonical-missing-relationship', + date: '2025-03-01', + stakeholder_id: 'stakeholder-1', + }) + ) + ).toMatchObject({ + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stakeholderRelationshipChangeEvent', + }); + }); + + 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 ', 'UNKNOWN_RELATIONSHIP'])( + '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/replicationHelpers.test.ts b/test/utils/replicationHelpers.test.ts index a197487b..f39b16b5 100644 --- a/test/utils/replicationHelpers.test.ts +++ b/test/utils/replicationHelpers.test.ts @@ -1017,11 +1017,72 @@ describe('computeReplicationDiff', () => { expect(diff.creates).toHaveLength(0); expect(diff.edits).toHaveLength(1); expect(requireFirst(diff.edits, 'relationship-order edit')).toMatchObject({ + entityType: 'stakeholder', + id: 'sh-1', data: { current_relationships: ['INVESTOR', 'FOUNDER'] }, }); expect(diff.total).toBe(1); }); + it('does not edit an identical ordered duplicate relationship array', async () => { + const currentRelationships = ['INVESTOR', 'FOUNDER', 'INVESTOR'] as const; + const sourceStakeholder = createTestStakeholderData({ + id: 'sh-duplicates', + name: { legal_name: 'Alice Doe' }, + stakeholder_type: 'INDIVIDUAL', + current_relationships: [...currentRelationships], + }); + const cantonStakeholder = createTestStakeholderData({ + id: 'sh-duplicates', + name: { legal_name: 'Alice Doe' }, + stakeholder_type: 'INDIVIDUAL', + current_relationships: [...currentRelationships], + }); + await validateOcfObject({ ...sourceStakeholder }); + await validateOcfObject({ ...cantonStakeholder }); + + const sourceItems: SourceReplicationItem[] = [{ entityType: 'stakeholder', data: sourceStakeholder }]; + const cantonState = createEmptyCantonState(); + cantonState.entities.set('stakeholder', new Set(['sh-duplicates'])); + const cantonOcfData = new CantonOcfDataMap().set('stakeholder', new Map([['sh-duplicates', cantonStakeholder]])); + + const diff = computeReplicationDiff(sourceItems, cantonState, { cantonOcfData }); + + expect(diff.creates).toHaveLength(0); + expect(diff.edits).toHaveLength(0); + expect(diff.total).toBe(0); + }); + + it('does not edit an omitted relationship array after the ledger materializes it as empty', async () => { + const sourceStakeholder = createTestStakeholderData({ + id: 'sh-empty-relationships', + name: { legal_name: 'Alice Doe' }, + stakeholder_type: 'INDIVIDUAL', + }); + const cantonStakeholder = createTestStakeholderData({ + id: 'sh-empty-relationships', + name: { legal_name: 'Alice Doe' }, + stakeholder_type: 'INDIVIDUAL', + current_relationships: [], + }); + await validateOcfObject({ ...sourceStakeholder }); + await validateOcfObject({ ...cantonStakeholder }); + + const sourceItems: SourceReplicationItem[] = [{ entityType: 'stakeholder', data: sourceStakeholder }]; + const cantonState = createEmptyCantonState(); + cantonState.entities.set('stakeholder', new Set(['sh-empty-relationships'])); + const cantonOcfData = new CantonOcfDataMap().set( + 'stakeholder', + new Map([['sh-empty-relationships', cantonStakeholder]]) + ); + + const diff = computeReplicationDiff(sourceItems, cantonState, { cantonOcfData }); + + expect(diff.creates).toHaveLength(0); + expect(diff.edits).toHaveLength(0); + expect(diff.total).toBe(0); + }); + it('rejects a legacy stakeholder current_relationship instead of emitting a phantom edit', () => { const sourceStakeholder = { object_type: 'STAKEHOLDER', diff --git a/test/validation/boundaries.test.ts b/test/validation/boundaries.test.ts index 6c39d76f..a375f9b9 100644 --- a/test/validation/boundaries.test.ts +++ b/test/validation/boundaries.test.ts @@ -6,8 +6,9 @@ */ import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; import { stakeholderDataToDaml } from '../../src/functions/OpenCapTable/stakeholder/stakeholderDataToDaml'; -import type { OcfStakeholder, StakeholderRelationshipType } from '../../src/types'; +import { STAKEHOLDER_RELATIONSHIP_TYPES, type OcfStakeholder, type StakeholderRelationshipType } from '../../src/types'; import { damlStakeholderRelationshipToNative, type DamlStakeholderRelationshipType, @@ -237,12 +238,13 @@ describe('Boundary Condition Tests', () => { ['OFFICER', 'OcfRelOfficer'], ['OTHER', 'OcfRelOther'], ] as const satisfies ReadonlyArray; + expect(STAKEHOLDER_RELATIONSHIP_TYPES).toEqual(relationships.map(([relationship]) => relationship)); const dataWithRelationships: OcfStakeholder = { id: 'sh-relationships', object_type: 'STAKEHOLDER', name: { legal_name: 'Test' }, stakeholder_type: 'INDIVIDUAL', - current_relationships: relationships.map(([relationship]) => relationship), + current_relationships: [...STAKEHOLDER_RELATIONSHIP_TYPES], }; expect(stakeholderDataToDaml(dataWithRelationships).current_relationships).toEqual( @@ -250,6 +252,23 @@ describe('Boundary Condition Tests', () => { ); }); + test('relationship conversion preserves caller order, duplicates, 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', 'INVESTOR', 'FOUNDER'], + }; + + const result = stakeholderDataToDaml(dataWithRelationships); + const expectedRelationships = ['OcfRelInvestor', 'OcfRelAdvisor', 'OcfRelInvestor', 'OcfRelFounder']; + expect(result.current_relationships).toEqual(expectedRelationships); + expect(convertToDaml('stakeholder', dataWithRelationships).current_relationships).toEqual(expectedRelationships); + expect(result.current_relationships[0]).toBe('OcfRelInvestor'); + expect(dataWithRelationships.current_relationships).toEqual(['INVESTOR', 'ADVISOR', 'INVESTOR', 'FOUNDER']); + }); + test('fails fast for invalid current_relationships values', () => { const invalidRelationshipArrayData: OcfStakeholder = { id: 'sh-invalid-array-relationship', diff --git a/test/validation/generatedDamlBoundary.test.ts b/test/validation/generatedDamlBoundary.test.ts index ab0f3a7f..ce44ee2e 100644 --- a/test/validation/generatedDamlBoundary.test.ts +++ b/test/validation/generatedDamlBoundary.test.ts @@ -536,6 +536,47 @@ describe('trap-free direct OCF writers', () => { expect(accessor).not.toHaveBeenCalled(); }); + test('rejects proxied, accessor-backed, and sparse stakeholder relationship arrays before reading them', () => { + const traps = { + get: jest.fn(() => { + throw new Error('relationship proxy get trap invoked'); + }), + getPrototypeOf: jest.fn(() => { + throw new Error('relationship proxy getPrototypeOf trap invoked'); + }), + ownKeys: jest.fn(() => { + throw new Error('relationship proxy ownKeys trap invoked'); + }), + }; + const proxiedRelationships = new Proxy(['INVESTOR'], traps); + const baseStakeholder = { + object_type: 'STAKEHOLDER', + id: 'stakeholder-hostile-relationships', + name: { legal_name: 'Hostile Relationships' }, + stakeholder_type: 'INDIVIDUAL', + } as const; + + expect(() => + stakeholderDataToDaml({ ...baseStakeholder, current_relationships: proxiedRelationships } as never) + ).toThrow(expect.objectContaining({ fieldPath: 'stakeholder.current_relationships' })); + expect(Object.values(traps).every((trap) => trap.mock.calls.length === 0)).toBe(true); + + const getter = jest.fn(() => ['INVESTOR']); + const accessorStakeholder: Record = { ...baseStakeholder }; + Object.defineProperty(accessorStakeholder, 'current_relationships', { enumerable: true, get: getter }); + expect(() => stakeholderDataToDaml(accessorStakeholder as never)).toThrow( + expect.objectContaining({ fieldPath: 'stakeholder.current_relationships' }) + ); + expect(getter).not.toHaveBeenCalled(); + + const sparseRelationships = new Array(3); + sparseRelationships[0] = 'INVESTOR'; + sparseRelationships[2] = 'FOUNDER'; + expect(() => stakeholderDataToDaml({ ...baseStakeholder, current_relationships: sparseRelationships })).toThrow( + expect.objectContaining({ fieldPath: 'stakeholder.current_relationships[1]' }) + ); + }); + test.each([ ['undefined', undefined], ['BigInt', 1n],