Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ee9e90f
chore: harden public type guardrails
HardlyDifficult Jul 10, 2026
f92cf03
Merge commit 'd1ce1c9' into codex/schema-cardinality-guardrails
HardlyDifficult Jul 10, 2026
6161ff9
feat: enforce non-empty OCF arrays
HardlyDifficult Jul 10, 2026
2939798
Merge branch 'codex/canonical-schema-purity' into codex/schema-cardin…
HardlyDifficult Jul 10, 2026
bd2b533
Merge branch 'codex/canonical-schema-purity' into codex/schema-cardin…
HardlyDifficult Jul 10, 2026
8649e81
Merge branch 'codex/canonical-schema-purity' into codex/schema-cardin…
HardlyDifficult Jul 10, 2026
6e9619a
Merge remote-tracking branch 'origin/codex/canonical-schema-purity' i…
HardlyDifficult Jul 10, 2026
dbcb369
Merge commit '7ed5677a7c6d1331f41a373ed67fdc9afe6bd51c' into codex/sc…
HardlyDifficult Jul 10, 2026
3051f25
Use canonical stock consolidation getter conversion
HardlyDifficult Jul 10, 2026
e7ffd29
Merge commit '7351f7555c82f5b8e1188012701dbfb3127846bf' into codex/sc…
HardlyDifficult Jul 10, 2026
431d57c
Merge commit 'dfaa254aad652e7c4d8f03e9556ba615eff331fe' into codex/sc…
HardlyDifficult Jul 11, 2026
3a28f42
fix: preserve transfer validation context
HardlyDifficult Jul 11, 2026
b6dfc71
test: cover transfer path fallback
HardlyDifficult Jul 11, 2026
0aa839c
Merge commit '6d162ea6c9b01b6756e89ec9c5cff783708726e5' into codex/sc…
HardlyDifficult Jul 11, 2026
178810c
Merge commit 'b1032aa' into codex/schema-cardinality-guardrails
HardlyDifficult Jul 11, 2026
91a2f6f
fix: validate non-empty array containers
HardlyDifficult Jul 11, 2026
e6baebc
Merge commit 'e5be6b1df04845726fd31ae8cb4f68b3eeb7dee7' into codex/sc…
HardlyDifficult Jul 11, 2026
a097314
test: type non-empty conversion trigger fixture
HardlyDifficult Jul 11, 2026
0cdc326
Merge commit '87d3daa01377001c9239a39c332bd1225000ac44' into codex/sc…
HardlyDifficult Jul 11, 2026
ef112de
Merge prepared schema purity into cardinality guardrails
HardlyDifficult Jul 11, 2026
6cdbdd0
Merge finalized schema purity stack into cardinality guardrails
HardlyDifficult Jul 11, 2026
8ad3324
Merge centralized trigger validation into cardinality guardrails
HardlyDifficult Jul 11, 2026
787967f
Merge commit '7f152e43753013a9584b7378f18804e41e25c5ec' into codex/sc…
HardlyDifficult Jul 11, 2026
0dfc424
Merge commit '3b30d7dc017b60cd4a69aef417ba0c230e00fb17' into codex/sc…
HardlyDifficult Jul 11, 2026
302162f
Merge commit '8c4db4d5553d1e69c4d80fc1f646a00b2528ee46' into codex/sc…
HardlyDifficult Jul 11, 2026
99fa660
Merge commit '9f6bb56c12800cf004e54619893830298d549096' into codex/sc…
HardlyDifficult Jul 11, 2026
e5bdbdf
Merge commit '1941a5bdc333b56d060c0225048c1dde030c2f4e' into codex/sc…
HardlyDifficult Jul 11, 2026
319ef50
Merge commit '48f7d3602797769e16793e81257e75f6c6f05ea2' into codex/sc…
HardlyDifficult Jul 11, 2026
e0354d2
Merge commit '4ae240ff23bdba102028d0cd57c6c92a5ea1bb41' into codex/sc…
HardlyDifficult Jul 11, 2026
2af4ec8
Merge commit '95dbe6b253fb4a4b188c58824aa7da3a0e2529ba' into codex/sc…
HardlyDifficult Jul 11, 2026
0a2c6df
Merge commit '543d1161a4d0c569655c11fd931a028f4c8fdb17' into codex/sc…
HardlyDifficult Jul 11, 2026
b2cdf90
Harden schema array cardinality boundaries
HardlyDifficult Jul 12, 2026
8968c1f
Merge commit 'e7411b906cf7630a23200094cbcf1b1b74e04871' into codex/sc…
HardlyDifficult Jul 12, 2026
0ea8345
refactor: centralize dense array validation
HardlyDifficult Jul 12, 2026
1557d19
Merge commit '474e3214038214283e533bb36d47572830d3ed6f' into codex/sc…
HardlyDifficult Jul 12, 2026
06a169b
fix: preserve schema-valid vesting rows
HardlyDifficult Jul 12, 2026
9352fc7
Merge commit '0c2e9b43cdf641db18f379b14b655838475affaf' into codex/sc…
HardlyDifficult Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ module.exports = {
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
coverageThreshold: {
global: {
branches: 60,
functions: 68,
lines: 72,
statements: 72,
branches: 68,
functions: 76,
lines: 75,
statements: 75,
},
},
setupFilesAfterEnv: ['<rootDir>/test/setupTests.ts'],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"test": "npm run -s typecheck && jest --passWithNoTests",
"test:ci": "npm run -s typecheck && jest --runInBand --coverage",
"test:coverage": "jest --coverage --passWithNoTests",
"test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config",
"test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config && node scripts/check-built-cardinality.cjs",
"test:exact-public-config": "tsc -p tsconfig.exact-public-config-tests.json --noEmit",
"test:integration": "npm run -s typecheck && jest -c jest.integration.config.js --passWithNoTests",
"test:integration:ci": "npm run -s typecheck && jest -c jest.integration.config.js --runInBand",
Expand Down
101 changes: 101 additions & 0 deletions scripts/check-built-cardinality.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use strict';

const assert = require('node:assert/strict');

function loadBuiltModules() {
const originalConsoleDebug = console.debug;
const originalConsoleLog = console.log;
try {
console.debug = () => undefined;
console.log = () => undefined;
return {
errors: require('../dist/errors'),
readDispatcher: require('../dist/functions/OpenCapTable/capTable/damlToOcf'),
stockConsolidationWriter: require('../dist/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'),
stockTransferWriter: require('../dist/functions/OpenCapTable/stockTransfer/createStockTransfer'),
typeConversions: require('../dist/utils/typeConversions'),
};
} finally {
console.debug = originalConsoleDebug;
console.log = originalConsoleLog;
}
}

const built = loadBuiltModules();
const { OcpErrorCodes, OcpValidationError } = built.errors;
const { convertToOcf } = built.readDispatcher;
const { stockConsolidationDataToDaml } = built.stockConsolidationWriter;
const { stockTransferDataToDaml } = built.stockTransferWriter;
const { toNonEmptyStringArray } = built.typeConversions;

function expectValidationError(action, expectedPath, expectedCode) {
assert.throws(action, (error) => {
assert(error instanceof OcpValidationError);
assert.equal(error.fieldPath, expectedPath);
assert.equal(error.code, expectedCode);
return true;
});
}

expectValidationError(
() => toNonEmptyStringArray(['duplicate', 'duplicate'], 'items', { uniqueItems: true }),
'items.1',
OcpErrorCodes.INVALID_FORMAT
);

let trapCalls = 0;
const proxy = new Proxy(['secret'], {
get(target, property, receiver) {
trapCalls += 1;
return Reflect.get(target, property, receiver);
},
ownKeys(target) {
trapCalls += 1;
return Reflect.ownKeys(target);
},
});
expectValidationError(() => toNonEmptyStringArray(proxy, 'items'), 'items', OcpErrorCodes.SCHEMA_MISMATCH);
assert.equal(trapCalls, 0);

const transferDaml = {
balance_security_id: null,
comments: [],
consideration_text: null,
date: '2026-01-01T00:00:00.000Z',
id: 'transfer-1',
quantity: '1',
resulting_security_ids: ['duplicate', 'duplicate'],
security_id: 'security-1',
};
expectValidationError(
() => convertToOcf('stockTransfer', transferDaml),
'stockTransfer.resulting_security_ids.1',
OcpErrorCodes.INVALID_FORMAT
);

expectValidationError(
() =>
stockTransferDataToDaml({
date: '2026-01-01',
id: 'transfer-1',
object_type: 'TX_STOCK_TRANSFER',
quantity: '1',
resulting_security_ids: ['duplicate', 'duplicate'],
security_id: 'security-1',
}),
'stockTransfer.resulting_security_ids.1',
OcpErrorCodes.INVALID_FORMAT
);

expectValidationError(
() =>
stockConsolidationDataToDaml({
date: '2026-01-01',
id: 'consolidation-1',
object_type: 'TX_STOCK_CONSOLIDATION',
security_ids: [],
resulting_security_id: 'result-1',
}),
'stockConsolidation.security_ids',
OcpErrorCodes.OUT_OF_RANGE
);
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
import { OcpErrorCodes, OcpValidationError } from '../../../errors';
import { OcpValidationError } from '../../../errors';
import type { OcfConvertibleTransfer } from '../../../types';
import { cleanComments, dateStringToDAMLTime, monetaryToDaml, optionalString } from '../../../utils/typeConversions';
import {
cleanComments,
dateStringToDAMLTime,
monetaryToDaml,
optionalString,
toNonEmptyStringArray,
} from '../../../utils/typeConversions';
import { assertCanonicalJsonGraph } from '../shared/ocfValues';

export function convertibleTransferDataToDaml(d: OcfConvertibleTransfer): Record<string, unknown> {
assertCanonicalJsonGraph(d, 'convertibleTransfer', { rejectUndefined: true });
if (!d.id) {
throw new OcpValidationError('convertibleTransfer.id', 'Required field is missing or empty', {
expectedType: 'string',
receivedValue: d.id,
});
}
// Validate required array field
if (d.resulting_security_ids.length === 0) {
throw new OcpValidationError('convertibleTransfer.resulting_security_ids', 'Must contain at least one element', {
code: OcpErrorCodes.REQUIRED_FIELD_MISSING,
receivedValue: d.resulting_security_ids,
});
}
const resultingSecurityIds = toNonEmptyStringArray(
d.resulting_security_ids,
'convertibleTransfer.resulting_security_ids',
{ uniqueItems: true }
);
return {
id: d.id,
date: dateStringToDAMLTime(d.date, 'convertibleTransfer.date'),
security_id: d.security_id,
amount: monetaryToDaml(d.amount),
resulting_security_ids: d.resulting_security_ids,
resulting_security_ids: resultingSecurityIds,
balance_security_id: optionalString(d.balance_security_id),
consideration_text: optionalString(d.consideration_text),
comments: cleanComments(d.comments),
Expand Down
8 changes: 6 additions & 2 deletions src/functions/OpenCapTable/convertibleTransfer/damlToOcf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import type { OcfConvertibleTransfer } from '../../../types';
import { damlMonetaryToNative, damlTimeToDateString } from '../../../utils/typeConversions';
import { damlMonetaryToNative, damlTimeToDateString, toNonEmptyStringArray } from '../../../utils/typeConversions';

/**
* DAML ConvertibleTransfer data structure.
Expand Down Expand Up @@ -33,7 +33,11 @@ export function damlConvertibleTransferToNative(d: DamlConvertibleTransferData):
date: damlTimeToDateString(d.date, 'convertibleTransfer.date'),
security_id: d.security_id,
amount: damlMonetaryToNative(d.amount),
resulting_security_ids: d.resulting_security_ids,
resulting_security_ids: toNonEmptyStringArray(
d.resulting_security_ids,
'convertibleTransfer.resulting_security_ids',
{ uniqueItems: true }
),
...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}),
...(d.consideration_text ? { consideration_text: d.consideration_text } : {}),
...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk';
import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js';
import type { GetByContractIdParams } from '../../../types/common';
import type { OcfConvertibleTransfer } from '../../../types/native';
import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions';
import { damlTimeToDateString, normalizeNumericString, toNonEmptyStringArray } from '../../../utils/typeConversions';
import { readSingleContract } from '../shared/singleContractRead';

/**
Expand Down Expand Up @@ -48,7 +48,11 @@ export async function getConvertibleTransferAsOcf(
amount: normalizeNumericString(amountStr),
currency: data.amount.currency,
},
resulting_security_ids: data.resulting_security_ids,
resulting_security_ids: toNonEmptyStringArray(
data.resulting_security_ids,
'convertibleTransfer.resulting_security_ids',
{ uniqueItems: true }
),
...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}),
...(data.consideration_text ? { consideration_text: data.consideration_text } : {}),
...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import type {
PeriodType,
TerminationWindowReason,
} from '../../../types/native';
import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation';
import {
damlTimeToDateString,
isRecord,
nonEmptyArrayOrUndefined,
normalizeNumericString,
nullableDamlTimeToDateString,
optionalDamlTimeToDateString,
Expand Down Expand Up @@ -111,40 +113,43 @@ export function damlEquityCompensationIssuanceDataToNative(d: Record<string, unk
const basePrice = equityCompensationMonetaryFromDaml(d.base_price, 'equityCompensationIssuance.base_price');

const vestings =
Array.isArray(d.vestings) && d.vestings.length > 0
? d.vestings.map((v, index) => {
if (!isRecord(v)) {
throw new OcpValidationError(
`equityCompensationIssuance.vestings[${index}]`,
`Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`,
{
code: OcpErrorCodes.INVALID_TYPE,
expectedType: 'object',
receivedValue: v,
}
);
}
d.vestings === undefined
? undefined
: (() => {
assertSafeGeneratedDamlJson(d.vestings, 'equityCompensationIssuance.vestings');
return nonEmptyArrayOrUndefined(d.vestings, 'equityCompensationIssuance.vestings', (v, { index }) => {
if (!isRecord(v)) {
throw new OcpValidationError(
`equityCompensationIssuance.vestings[${index}]`,
`Must be an object, got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}`,
{
code: OcpErrorCodes.INVALID_TYPE,
expectedType: 'object',
receivedValue: v,
}
);
}

// Validate vesting amount
if (typeof v.amount !== 'string' && typeof v.amount !== 'number') {
throw new OcpValidationError(
`equityCompensationIssuance.vestings[${index}].amount`,
`Must be string or number, got ${typeof v.amount}`,
{
code: OcpErrorCodes.INVALID_TYPE,
expectedType: 'string | number',
receivedValue: v.amount,
}
);
}
// Convert to string after validation
const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount;
return {
date: damlTimeToDateString(v.date, `equityCompensationIssuance.vestings[${index}].date`),
amount: normalizeNumericString(amountStr),
};
})
: undefined;
// Validate vesting amount
if (typeof v.amount !== 'string' && typeof v.amount !== 'number') {
throw new OcpValidationError(
`equityCompensationIssuance.vestings[${index}].amount`,
`Must be string or number, got ${typeof v.amount}`,
{
code: OcpErrorCodes.INVALID_TYPE,
expectedType: 'string | number',
receivedValue: v.amount,
}
);
}
// Convert to string after validation
const amountStr = typeof v.amount === 'number' ? v.amount.toString() : v.amount;
return {
date: damlTimeToDateString(v.date, `equityCompensationIssuance.vestings[${index}].date`),
amount: normalizeNumericString(amountStr),
};
});
})();

const terminationWindows = optionalCollection(
d.termination_exercise_windows,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
import { OcpErrorCodes, OcpValidationError } from '../../../errors';
import { OcpValidationError } from '../../../errors';
import type { OcfEquityCompensationTransfer } from '../../../types';
import {
cleanComments,
dateStringToDAMLTime,
normalizeNumericString,
optionalString,
toNonEmptyStringArray,
} from '../../../utils/typeConversions';
import { assertCanonicalJsonGraph } from '../shared/ocfValues';

export function equityCompensationTransferDataToDaml(d: OcfEquityCompensationTransfer): Record<string, unknown> {
assertCanonicalJsonGraph(d, 'equityCompensationTransfer', { rejectUndefined: true });
if (!d.id) {
throw new OcpValidationError('equityCompensationTransfer.id', 'Required field is missing or empty', {
expectedType: 'string',
receivedValue: d.id,
});
}
// Validate required array field
if (d.resulting_security_ids.length === 0) {
throw new OcpValidationError(
'equityCompensationTransfer.resulting_security_ids',
'Must contain at least one element',
{ code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: d.resulting_security_ids }
);
}
const resultingSecurityIds = toNonEmptyStringArray(
d.resulting_security_ids,
'equityCompensationTransfer.resulting_security_ids',
{ uniqueItems: true }
);
return {
id: d.id,
date: dateStringToDAMLTime(d.date, 'equityCompensationTransfer.date'),
security_id: d.security_id,
quantity: normalizeNumericString(d.quantity),
resulting_security_ids: d.resulting_security_ids,
resulting_security_ids: resultingSecurityIds,
balance_security_id: optionalString(d.balance_security_id),
consideration_text: optionalString(d.consideration_text),
comments: cleanComments(d.comments),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk';
import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js';
import type { GetByContractIdParams } from '../../../types/common';
import type { OcfEquityCompensationTransfer } from '../../../types/native';
import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions';
import { damlTimeToDateString, normalizeNumericString, toNonEmptyStringArray } from '../../../utils/typeConversions';
import { readSingleContract } from '../shared/singleContractRead';

/**
Expand Down Expand Up @@ -46,7 +46,11 @@ export async function getEquityCompensationTransferAsOcf(
date: damlTimeToDateString(data.date, 'equityCompensationTransfer.date'),
security_id: data.security_id,
quantity: normalizeNumericString(quantityStr),
resulting_security_ids: data.resulting_security_ids,
resulting_security_ids: toNonEmptyStringArray(
data.resulting_security_ids,
'equityCompensationTransfer.resulting_security_ids',
{ uniqueItems: true }
),
...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}),
...(data.consideration_text ? { consideration_text: data.consideration_text } : {}),
...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}),
Expand Down
Loading