From d510ecf9e0cbe87432cc4fcdbe4015cb1f83e057 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Thu, 9 Jul 2026 23:41:51 -0400 Subject: [PATCH 01/11] Enforce exact optional source boundaries --- package.json | 3 +- .../OpenCapTable/capTable/CapTableBatch.ts | 7 +-- .../OpenCapTable/capTable/archiveCapTable.ts | 10 +++- .../OpenCapTable/capTable/getCapTableState.ts | 2 +- .../OpenCapTable/issuer/getIssuerAsOcf.ts | 6 +- .../OpenCapTable/shared/singleContractRead.ts | 19 +++--- .../stockIssuance/getStockIssuanceAsOcf.ts | 18 +++--- .../stockPlan/getStockPlanAsOcf.ts | 11 ++-- .../vestingTerms/getVestingTermsAsOcf.ts | 6 +- src/observability.ts | 15 +++-- src/utils/cantonOcfExtractor.ts | 8 +-- src/utils/templateIdentity.ts | 59 ++++++++++++------- test/observability/observability.test.ts | 2 +- tsconfig.exact-source.json | 9 +++ 14 files changed, 105 insertions(+), 70 deletions(-) create mode 100644 tsconfig.exact-source.json diff --git a/package.json b/package.json index eb4a9dcc..278aaa55 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,11 @@ "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:exact-public-config": "tsc -p tsconfig.exact-public-config-tests.json --noEmit", + "test:exact-source": "tsc -p tsconfig.exact-source.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", "test:watch": "jest --watch", - "typecheck": "tsc -p tsconfig.tests.json --noEmit" + "typecheck": "tsc -p tsconfig.tests.json --noEmit && npm run -s test:exact-source" }, "config": { "localnet_quickstart_ref": "5c90cf4d0eb934712fd8c269b56e6272b605ccad" diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index 9d9f43ab..fd11c3e4 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -255,9 +255,8 @@ export class CapTableBatch { try { const templateId = 'ExerciseCommand' in command ? command.ExerciseCommand.templateId : undefined; const mergedContext = mergeCommandContext(this.params.defaultContext, this.params.context); - const context = mergeCommandContext(mergedContext, { - commandId: this.params.commandId ?? mergedContext?.commandId ?? createUpdateCapTableCommandId(), - }); + const commandId = this.params.commandId ?? mergedContext?.commandId ?? createUpdateCapTableCommandId(); + const context = mergeCommandContext(mergedContext, { commandId }) ?? { commandId }; response = await submitObservedTransactionTree( this.client, { @@ -269,7 +268,7 @@ export class CapTableBatch { { ...this.params, context }, { operation: 'capTable.update', - templateId, + ...(templateId !== undefined ? { templateId } : {}), choice: 'UpdateCapTable', } ); diff --git a/src/functions/OpenCapTable/capTable/archiveCapTable.ts b/src/functions/OpenCapTable/capTable/archiveCapTable.ts index 5996b618..f1494c27 100644 --- a/src/functions/OpenCapTable/capTable/archiveCapTable.ts +++ b/src/functions/OpenCapTable/capTable/archiveCapTable.ts @@ -41,7 +41,9 @@ export interface ArchiveCapTableResult { export function buildArchiveCapTableCommand(params: ArchiveCapTableParams): CommandWithDisclosedContracts { return buildCapTableCommand({ capTableContractId: params.capTableContractId, - capTableContractDetails: params.capTableContractDetails, + ...(params.capTableContractDetails !== undefined + ? { capTableContractDetails: params.capTableContractDetails } + : {}), choice: 'ArchiveCapTable', choiceArgument: {}, }); @@ -100,7 +102,11 @@ export async function archiveCapTable( disclosedContracts, }, params, - { operation: 'archiveCapTable', templateId, choice: 'ArchiveCapTable' } + { + operation: 'archiveCapTable', + ...(templateId !== undefined ? { templateId } : {}), + choice: 'ArchiveCapTable', + } ); return { updateId: result.transactionTree.updateId }; diff --git a/src/functions/OpenCapTable/capTable/getCapTableState.ts b/src/functions/OpenCapTable/capTable/getCapTableState.ts index 46a30e01..81791a78 100644 --- a/src/functions/OpenCapTable/capTable/getCapTableState.ts +++ b/src/functions/OpenCapTable/capTable/getCapTableState.ts @@ -281,7 +281,7 @@ async function buildCapTableStateFromCreatedEvent( try { const eventsResponse = await client.getEventsByContractId({ contractId: issuerContractId, - ...ledgerReadScope({ readAs: issuerPartyId ? [issuerPartyId] : undefined }), + ...ledgerReadScope(issuerPartyId ? { readAs: [issuerPartyId] } : {}), }); const issuerId = requireIssuerCanonicalObjectId(eventsResponse, issuerContractId); entities.set('issuer', new Set([issuerId])); diff --git a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts index 5fc31bfd..060ee3f8 100644 --- a/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts +++ b/src/functions/OpenCapTable/issuer/getIssuerAsOcf.ts @@ -8,14 +8,16 @@ import { damlEmailTypeToNative, damlPhoneTypeToNative } from '../../../utils/enu import { damlAddressToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -function damlEmailToNative(damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail): OcfIssuerInput['email'] { +function damlEmailToNative( + damlEmail: Fairmint.OpenCapTable.Types.Contact.OcfEmail +): NonNullable { return { email_type: damlEmailTypeToNative(damlEmail.email_type), email_address: damlEmail.email_address, }; } -function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): OcfIssuerInput['phone'] { +function damlPhoneToNative(phone: Fairmint.OpenCapTable.Types.Contact.OcfPhone): NonNullable { return { phone_type: damlPhoneTypeToNative(phone.phone_type), phone_number: phone.phone_number, diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index f202276a..7ae5350f 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -153,32 +153,31 @@ export async function readSingleContract( const templateIdentity = options.expectedTemplateId ? assertTemplateIdentity( { - templateId, - packageName, + ...(templateId !== undefined ? { templateId } : {}), + ...(packageName !== undefined ? { packageName } : {}), }, options.expectedTemplateId, { contractId: params.contractId, operation: options.operation, - message: - templateId === undefined && packageName === undefined - ? 'Contract template identity is missing; cannot validate expected template' - : undefined, + ...(templateId === undefined && packageName === undefined + ? { message: 'Contract template identity is missing; cannot validate expected template' } + : {}), } ) : undefined; const createArgument = requireCreateArgumentRecord(createdEvent.createArgument, params.contractId, { operation: options.operation, - templateId, + ...(templateId !== undefined ? { templateId } : {}), }); return { contractId: params.contractId, createArgument, createdEvent, - templateId, - packageName, - templateIdentity, + ...(templateId !== undefined ? { templateId } : {}), + ...(packageName !== undefined ? { packageName } : {}), + ...(templateIdentity !== undefined ? { templateIdentity } : {}), }; } diff --git a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts index f9a7c28d..4ed7888b 100644 --- a/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockIssuance/getStockIssuanceAsOcf.ts @@ -17,18 +17,21 @@ function damlShareNumberRangeToNative(r: Fairmint.OpenCapTable.Types.Stock.OcfSh }; } -function damlStockIssuanceTypeToNative(t: string | null): StockIssuanceType | undefined { - if (t === null) return undefined; +function damlStockIssuanceTypeToNative(t: unknown): StockIssuanceType | undefined { + if (t === null || t === undefined) return undefined; switch (t) { case 'OcfStockIssuanceRSA': return 'RSA'; case 'OcfStockIssuanceFounders': return 'FOUNDERS_STOCK'; - default: - throw new OcpParseError(`Unknown DAML stock issuance type: ${t}`, { + default: { + const detail = typeof t === 'string' ? `: ${t}` : ''; + throw new OcpParseError(`Unknown DAML stock issuance type${detail}`, { source: 'stockIssuance.issuance_type', code: OcpErrorCodes.UNKNOWN_ENUM_VALUE, + context: { receivedValue: t }, }); + } } } @@ -49,6 +52,7 @@ export function damlStockIssuanceDataToNative( amount: normalizeNumericString(vesting.amount), })) : []; + const issuanceType = damlStockIssuanceTypeToNative(anyD.issuance_type); return { object_type: 'TX_STOCK_ISSUANCE', @@ -84,11 +88,7 @@ export function damlStockIssuanceDataToNative( stock_legend_ids: Array.isArray((d as unknown as { stock_legend_ids?: unknown }).stock_legend_ids) ? (d as unknown as { stock_legend_ids: string[] }).stock_legend_ids : [], - ...((anyD as { issuance_type?: unknown }).issuance_type != null && { - issuance_type: damlStockIssuanceTypeToNative( - (anyD as { issuance_type?: unknown }).issuance_type as string | null - ), - }), + ...(issuanceType !== undefined ? { issuance_type: issuanceType } : {}), comments: (anyD as { comments?: unknown }).comments !== undefined && Array.isArray((anyD as { comments?: unknown }).comments) diff --git a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts index 3e87184d..7b1c4c93 100644 --- a/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts +++ b/src/functions/OpenCapTable/stockPlan/getStockPlanAsOcf.ts @@ -6,8 +6,8 @@ import type { OcfStockPlan, StockPlanCancellationBehavior } from '../../../types import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; import { readSingleContract } from '../shared/singleContractRead'; -function damlCancellationBehaviorToNative(b: string | null): StockPlanCancellationBehavior | undefined { - if (b === null) return undefined; +function damlCancellationBehaviorToNative(b: string | null | undefined): StockPlanCancellationBehavior | undefined { + if (b === null || b === undefined) return undefined; switch (b) { case 'OcfPlanCancelRetire': return 'RETIRE'; @@ -68,6 +68,7 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan receivedValue: stockClassIds, }); } + const defaultCancellationBehavior = damlCancellationBehaviorToNative(d.default_cancellation_behavior); return { object_type: 'STOCK_PLAN', @@ -80,9 +81,9 @@ export function damlStockPlanDataToNative(d: Fairmint.OpenCapTable.OCF.StockPlan stockholder_approval_date: damlTimeToDateString(d.stockholder_approval_date), }), initial_shares_reserved: normalizeNumericString(initialSharesReserved.toString()), - ...(d.default_cancellation_behavior && { - default_cancellation_behavior: damlCancellationBehaviorToNative(d.default_cancellation_behavior), - }), + ...(defaultCancellationBehavior !== undefined + ? { default_cancellation_behavior: defaultCancellationBehavior } + : {}), stock_class_ids: stockClassIds, comments: Array.isArray((d as unknown as { comments?: unknown }).comments) ? (d as unknown as { comments: string[] }).comments diff --git a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts index 60a52073..cbd88fdb 100644 --- a/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts +++ b/src/functions/OpenCapTable/vestingTerms/getVestingTermsAsOcf.ts @@ -146,7 +146,11 @@ function parseVestingPeriodCommonFields(v: Record): { ? parseNumericLike('vestingPeriod.cliff_installment', v.cliff_installment) : undefined; - return { length, occurrences, cliffInstallment }; + return { + length, + occurrences, + ...(cliffInstallment !== undefined ? { cliffInstallment } : {}), + }; } function damlVestingPeriodToNative(p: { tag: string; value?: Record }): VestingPeriod { diff --git a/src/observability.ts b/src/observability.ts index 8c03ccd6..82a76939 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -1,4 +1,4 @@ -import type { LedgerJsonApiClient, TraceContext } from '@fairmint/canton-node-sdk'; +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { CommandContext, CommandObservabilityOptions, CommandTelemetry } from './observabilityTypes'; export type { @@ -12,7 +12,6 @@ export type { type SubmitTransactionTreeParams = Parameters[0]; type SubmitTransactionTreeResponse = Awaited>; -type TraceableSubmitTransactionTreeParams = SubmitTransactionTreeParams & { traceContext?: TraceContext }; export function mergeCommandContext( ...contexts: Array | undefined> @@ -30,10 +29,10 @@ export function mergeCommandContext( return Object.keys(merged).length > 0 ? merged : undefined; } -function applyMergedCommandContext( - params: T, +function applyMergedCommandContext( + params: SubmitTransactionTreeParams, context: CommandContext | undefined -): T & TraceableSubmitTransactionTreeParams { +): SubmitTransactionTreeParams { if (!context) return params; return { @@ -55,10 +54,10 @@ function runBestEffort(callback: (() => unknown) | undefined): void { } } -export function applyCommandContext( - params: T, +export function applyCommandContext( + params: SubmitTransactionTreeParams, options?: CommandObservabilityOptions -): T & TraceableSubmitTransactionTreeParams { +): SubmitTransactionTreeParams { const context = mergeCommandContext(options?.defaultContext, options?.context); return applyMergedCommandContext(params, context); } diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 87ca5ae6..8341416e 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -414,10 +414,10 @@ export async function extractCantonOcfManifest( cantonState: CapTableState, options: ExtractCantonOcfOptions = {} ): Promise { - const { verbose = false, failOnReadErrors = true, readAs } = options; + const { verbose = false, failOnReadErrors = true } = options; // eslint-disable-next-line no-console const log = options.logger ?? (verbose ? (msg: string) => console.log(msg) : () => {}); - const readScopeOpts = ledgerReadScope({ readAs }); + const readScopeOpts = ledgerReadScope(options); const result: OcfManifest = { issuer: null, @@ -484,7 +484,7 @@ export async function extractCantonOcfManifest( objectId: issuerId, contractId: issuerCid, attempts: issuerAttempts, - ...ledgerReadScope({ readAs }), + ...ledgerReadScope(options), }, }); if (failOnReadErrors) { @@ -605,7 +605,7 @@ export async function extractCantonOcfManifest( objectId, contractId, attempts: readAttempts, - ...ledgerReadScope({ readAs }), + ...ledgerReadScope(options), }, }); if (failOnReadErrors) { diff --git a/src/utils/templateIdentity.ts b/src/utils/templateIdentity.ts index 5ab5eaad..b64395b4 100644 --- a/src/utils/templateIdentity.ts +++ b/src/utils/templateIdentity.ts @@ -12,17 +12,25 @@ export interface TemplateIdentityInput { packageName?: unknown; } -export interface TemplateIdentityComparison { - matches: boolean; - mismatch?: - | 'missing_template_id' - | 'invalid_template_id' - | 'invalid_expected_template_id' - | 'module_entity_mismatch' - | 'package_name_mismatch'; - actual?: ParsedTemplateIdentity; - expected?: ParsedTemplateIdentity; -} +type TemplateIdentityMismatch = + | 'missing_template_id' + | 'invalid_template_id' + | 'invalid_expected_template_id' + | 'module_entity_mismatch' + | 'package_name_mismatch'; + +export type TemplateIdentityComparison = + | Readonly<{ + matches: true; + actual: ParsedTemplateIdentity; + expected: ParsedTemplateIdentity; + }> + | Readonly<{ + matches: false; + mismatch: TemplateIdentityMismatch; + actual?: ParsedTemplateIdentity; + expected?: ParsedTemplateIdentity; + }>; export function parseTemplateIdentity(templateId: string): ParsedTemplateIdentity { if (templateId.length === 0) { @@ -41,10 +49,11 @@ export function parseTemplateIdentity(templateId: string): ParsedTemplateIdentit throw new Error('templateId must include non-empty package and module/entity path'); } + const packageName = packageRef.startsWith('#') ? packageRef.slice(1) : undefined; return { templateId, packageRef, - packageName: packageRef.startsWith('#') ? packageRef.slice(1) : undefined, + ...(packageName !== undefined ? { packageName } : {}), moduleEntityPath, }; } @@ -105,22 +114,28 @@ export function assertTemplateIdentity( ): ParsedTemplateIdentity { const comparison = compareTemplateIdentity(actual, expectedTemplateId); if (!comparison.matches) { + const actualTemplateId = typeof actual.templateId === 'string' ? actual.templateId : undefined; + const actualPackageName = typeof actual.packageName === 'string' ? actual.packageName : undefined; throw new OcpContractError(diagnostics.message ?? 'Contract template identity does not match expected template', { code: OcpErrorCodes.SCHEMA_MISMATCH, - contractId: diagnostics.contractId, - templateId: typeof actual.templateId === 'string' ? actual.templateId : undefined, - classification: comparison.mismatch ?? 'template_identity_mismatch', + ...(diagnostics.contractId !== undefined ? { contractId: diagnostics.contractId } : {}), + ...(actualTemplateId !== undefined ? { templateId: actualTemplateId } : {}), + classification: comparison.mismatch, context: { - operation: diagnostics.operation, - actualPackageName: typeof actual.packageName === 'string' ? actual.packageName : undefined, - actualTemplateId: typeof actual.templateId === 'string' ? actual.templateId : undefined, - actualModuleEntityPath: comparison.actual?.moduleEntityPath, + ...(diagnostics.operation !== undefined ? { operation: diagnostics.operation } : {}), + ...(actualPackageName !== undefined ? { actualPackageName } : {}), + ...(actualTemplateId !== undefined ? { actualTemplateId } : {}), + ...(comparison.actual !== undefined ? { actualModuleEntityPath: comparison.actual.moduleEntityPath } : {}), expectedTemplateId, - expectedPackageName: comparison.expected?.packageName, - expectedModuleEntityPath: comparison.expected?.moduleEntityPath, + ...(comparison.expected?.packageName !== undefined + ? { expectedPackageName: comparison.expected.packageName } + : {}), + ...(comparison.expected !== undefined + ? { expectedModuleEntityPath: comparison.expected.moduleEntityPath } + : {}), }, }); } - return comparison.actual as ParsedTemplateIdentity; + return comparison.actual; } diff --git a/test/observability/observability.test.ts b/test/observability/observability.test.ts index ff8abc9a..f0751afa 100644 --- a/test/observability/observability.test.ts +++ b/test/observability/observability.test.ts @@ -7,7 +7,7 @@ describe('observability helpers', () => { actAs: ['issuer::party'], }; - const result = applyCommandContext(params as never, { + const result = applyCommandContext(params, { defaultContext: { workflowId: 'workflow-default', commandId: 'command-default', diff --git a/tsconfig.exact-source.json b/tsconfig.exact-source.json new file mode 100644 index 00000000..b2c49bea --- /dev/null +++ b/tsconfig.exact-source.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} From a9da845ed12a9b5ed3da1d1d3738233d65958a3b Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 12:30:26 -0400 Subject: [PATCH 02/11] Preserve command context parameter subtypes --- src/observability.ts | 8 ++++---- src/utils/cantonOcfExtractor.ts | 4 ++-- test/declarations/publicApi.types.ts | 14 ++++++++++++++ test/observability/observability.test.ts | 6 +++++- test/types/observability.types.ts | 24 ++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 test/types/observability.types.ts diff --git a/src/observability.ts b/src/observability.ts index 82a76939..e5f8c4c9 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -54,12 +54,12 @@ function runBestEffort(callback: (() => unknown) | undefined): void { } } -export function applyCommandContext( - params: SubmitTransactionTreeParams, +export function applyCommandContext( + params: T, options?: CommandObservabilityOptions -): SubmitTransactionTreeParams { +): T { const context = mergeCommandContext(options?.defaultContext, options?.context); - return applyMergedCommandContext(params, context); + return applyMergedCommandContext(params, context) as T; } export async function submitObservedTransactionTree( diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 8341416e..2683321b 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -484,7 +484,7 @@ export async function extractCantonOcfManifest( objectId: issuerId, contractId: issuerCid, attempts: issuerAttempts, - ...ledgerReadScope(options), + ...readScopeOpts, }, }); if (failOnReadErrors) { @@ -605,7 +605,7 @@ export async function extractCantonOcfManifest( objectId, contractId, attempts: readAttempts, - ...ledgerReadScope(options), + ...readScopeOpts, }, }); if (failOnReadErrors) { diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 6af28f02..8636813e 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -2,6 +2,7 @@ /** Compile-time smoke tests for declarations exported by the built SDK. */ import { + applyCommandContext, authorizeIssuer, buildCreateIssuerCommand, CapTableBatch, @@ -67,6 +68,19 @@ void withdrawAuthorization; declare const createIssuerParams: CreateIssuerParams; buildCreateIssuerCommand(createIssuerParams); +const paramsWithCallerMetadata = { + commands: [], + actAs: ['issuer::party'], + callerMetadata: 'preserved' as const, +}; +const contextualizedParams = applyCommandContext(paramsWithCallerMetadata); +const publishedContextPreservesCallerSubtype: Assert< + IsExactly +> = true; +const preservedCallerMetadata: 'preserved' = contextualizedParams.callerMetadata; +void publishedContextPreservesCallerSubtype; +void preservedCallerMetadata; + // @ts-expect-error generated DAML wire unions are intentionally not root exports type RemovedGeneratedWireType = import('../../dist').OcfCreateData; declare const removedGeneratedWireType: RemovedGeneratedWireType; diff --git a/test/observability/observability.test.ts b/test/observability/observability.test.ts index f0751afa..7dd34184 100644 --- a/test/observability/observability.test.ts +++ b/test/observability/observability.test.ts @@ -5,6 +5,7 @@ describe('observability helpers', () => { const params = { commands: [], actAs: ['issuer::party'], + callerMetadata: 'preserved' as const, }; const result = applyCommandContext(params, { @@ -17,13 +18,16 @@ describe('observability helpers', () => { submissionId: 'submission-call', traceContext: { traceId: 'trace-1', spanId: 'span-1' }, }, - }) as Record; + }); + + const { callerMetadata }: { callerMetadata: 'preserved' } = result; expect(result).toMatchObject({ workflowId: 'workflow-default', commandId: 'command-call', submissionId: 'submission-call', traceContext: { traceId: 'trace-1', spanId: 'span-1' }, + callerMetadata, }); }); diff --git a/test/types/observability.types.ts b/test/types/observability.types.ts new file mode 100644 index 00000000..dbd6c1a0 --- /dev/null +++ b/test/types/observability.types.ts @@ -0,0 +1,24 @@ +/** Compile-time contracts for observability helper subtype preservation. */ + +import { applyCommandContext } from '../../src'; + +type Assert = T; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +const paramsWithCallerMetadata = { + commands: [], + actAs: ['issuer::party'], + callerMetadata: 'preserved' as const, +}; + +const contextualizedParams = applyCommandContext(paramsWithCallerMetadata, { + context: { workflowId: 'workflow-1' }, +}); + +const sourceContextPreservesCallerSubtype: Assert< + IsExactly +> = true; +const preservedCallerMetadata: 'preserved' = contextualizedParams.callerMetadata; + +void sourceContextPreservesCallerSubtype; +void preservedCallerMetadata; From ffa81b4d7f47ad379d9b6ad6076115d0f4ca6d25 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 14:44:38 -0400 Subject: [PATCH 03/11] Simplify required command context --- src/functions/OpenCapTable/capTable/CapTableBatch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index fd11c3e4..20726c33 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -256,7 +256,7 @@ export class CapTableBatch { const templateId = 'ExerciseCommand' in command ? command.ExerciseCommand.templateId : undefined; const mergedContext = mergeCommandContext(this.params.defaultContext, this.params.context); const commandId = this.params.commandId ?? mergedContext?.commandId ?? createUpdateCapTableCommandId(); - const context = mergeCommandContext(mergedContext, { commandId }) ?? { commandId }; + const context = { ...mergedContext, commandId }; response = await submitObservedTransactionTree( this.client, { From 3e72bbff15cf6a8a8af70ab354a008dcb844b45f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 15:15:15 -0400 Subject: [PATCH 04/11] Keep command context return types sound --- src/observability.ts | 20 +++++++++++++------- test/declarations/publicApi.types.ts | 14 ++++++++++++++ test/observability/observability.test.ts | 4 +++- test/types/observability.types.ts | 14 ++++++++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/observability.ts b/src/observability.ts index e5f8c4c9..7e555c04 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -12,6 +12,12 @@ export type { type SubmitTransactionTreeParams = Parameters[0]; type SubmitTransactionTreeResponse = Awaited>; +/** Preserve caller-specific fields while widening context fields that runtime merging may replace. */ +type AppliedCommandContext = { + [K in keyof T]: K extends keyof CommandContext + ? Exclude | (undefined extends T[K] ? undefined : never) + : T[K]; +}; export function mergeCommandContext( ...contexts: Array | undefined> @@ -29,11 +35,11 @@ export function mergeCommandContext( return Object.keys(merged).length > 0 ? merged : undefined; } -function applyMergedCommandContext( - params: SubmitTransactionTreeParams, +function applyMergedCommandContext( + params: T, context: CommandContext | undefined -): SubmitTransactionTreeParams { - if (!context) return params; +): AppliedCommandContext { + if (!context) return params as AppliedCommandContext; return { ...params, @@ -41,7 +47,7 @@ function applyMergedCommandContext( ...(context.commandId !== undefined ? { commandId: context.commandId } : {}), ...(context.submissionId !== undefined ? { submissionId: context.submissionId } : {}), ...(context.traceContext !== undefined ? { traceContext: context.traceContext } : {}), - }; + } as AppliedCommandContext; } function runBestEffort(callback: (() => unknown) | undefined): void { @@ -57,9 +63,9 @@ function runBestEffort(callback: (() => unknown) | undefined): void { export function applyCommandContext( params: T, options?: CommandObservabilityOptions -): T { +): AppliedCommandContext { const context = mergeCommandContext(options?.defaultContext, options?.context); - return applyMergedCommandContext(params, context) as T; + return applyMergedCommandContext(params, context); } export async function submitObservedTransactionTree( diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index adeceeb7..bfb4a247 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -90,8 +90,22 @@ const publishedContextPreservesCallerSubtype: Assert< IsExactly > = true; const preservedCallerMetadata: 'preserved' = contextualizedParams.callerMetadata; + +const paramsWithLiteralCommandId = { + ...paramsWithCallerMetadata, + commandId: 'command-from-params' as const, +}; +const contextualizedWithCommandOverride = applyCommandContext(paramsWithLiteralCommandId, { + context: { commandId: 'command-from-context' }, +}); +const publishedContextWidensOverriddenLiteral: Assert< + IsExactly +> = true; +const publishedOverridePreservesCallerMetadata: 'preserved' = contextualizedWithCommandOverride.callerMetadata; void publishedContextPreservesCallerSubtype; void preservedCallerMetadata; +void publishedContextWidensOverriddenLiteral; +void publishedOverridePreservesCallerMetadata; // @ts-expect-error generated DAML wire unions are intentionally not root exports type RemovedGeneratedWireType = import('../../dist').OcfCreateData; diff --git a/test/observability/observability.test.ts b/test/observability/observability.test.ts index 7dd34184..58d38fb6 100644 --- a/test/observability/observability.test.ts +++ b/test/observability/observability.test.ts @@ -5,6 +5,7 @@ describe('observability helpers', () => { const params = { commands: [], actAs: ['issuer::party'], + commandId: 'command-from-params' as const, callerMetadata: 'preserved' as const, }; @@ -20,7 +21,7 @@ describe('observability helpers', () => { }, }); - const { callerMetadata }: { callerMetadata: 'preserved' } = result; + const { callerMetadata, commandId }: { callerMetadata: 'preserved'; commandId: string } = result; expect(result).toMatchObject({ workflowId: 'workflow-default', @@ -29,6 +30,7 @@ describe('observability helpers', () => { traceContext: { traceId: 'trace-1', spanId: 'span-1' }, callerMetadata, }); + expect(commandId).toBe('command-call'); }); it('emits success logs and metrics around command submission', async () => { diff --git a/test/types/observability.types.ts b/test/types/observability.types.ts index dbd6c1a0..43d7aebd 100644 --- a/test/types/observability.types.ts +++ b/test/types/observability.types.ts @@ -20,5 +20,19 @@ const sourceContextPreservesCallerSubtype: Assert< > = true; const preservedCallerMetadata: 'preserved' = contextualizedParams.callerMetadata; +const paramsWithLiteralCommandId = { + ...paramsWithCallerMetadata, + commandId: 'command-from-params' as const, +}; +const contextualizedWithCommandOverride = applyCommandContext(paramsWithLiteralCommandId, { + context: { commandId: 'command-from-context' }, +}); +const sourceContextWidensOverriddenLiteral: Assert< + IsExactly +> = true; +const sourceOverridePreservesCallerMetadata: 'preserved' = contextualizedWithCommandOverride.callerMetadata; + void sourceContextPreservesCallerSubtype; void preservedCallerMetadata; +void sourceContextWidensOverriddenLiteral; +void sourceOverridePreservesCallerMetadata; From ac8df1f166d7ed0f042b964c8e5f5059b0ef85d0 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 19:53:16 -0400 Subject: [PATCH 05/11] Make applied command context typing sound --- src/observability.ts | 48 +++++++++++++++--------- test/declarations/publicApi.types.ts | 21 +++++------ test/exact/builtPublicConfig.types.ts | 24 ++++++++++++ test/exact/sourcePublicConfig.types.ts | 23 ++++++++++++ test/observability/observability.test.ts | 24 +++++++++++- test/types/observability.types.ts | 29 +++++++------- 6 files changed, 125 insertions(+), 44 deletions(-) diff --git a/src/observability.ts b/src/observability.ts index e0317ffd..6a5af327 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -14,12 +14,8 @@ export type { type SubmitTransactionTreeParams = Parameters[0]; type SubmitTransactionTreeResponse = Awaited>; -/** Preserve caller-specific fields while widening context fields that runtime merging may replace. */ -type AppliedCommandContext = { - [K in keyof T]: K extends keyof CommandContext - ? Exclude | (undefined extends T[K] ? undefined : never) - : T[K]; -}; +/** Plain ledger submit parameters with omission-only, immutable command-context fields. */ +export type AppliedCommandContext = Omit & CommandContext; export function mergeCommandContext( ...contexts: Array | undefined> @@ -27,19 +23,37 @@ export function mergeCommandContext( return mergeCommandContextSnapshots(contexts); } -function applyMergedCommandContext( - params: T, +function applyMergedCommandContext( + params: SubmitTransactionTreeParams, context: CommandContext | undefined -): AppliedCommandContext { - if (!context) return params as AppliedCommandContext; +): AppliedCommandContext { + const { workflowId, commandId, submissionId, traceContext, ...submitParams } = params; + const normalizedTraceContext = + traceContext === undefined + ? undefined + : { + ...(traceContext.traceId !== undefined ? { traceId: traceContext.traceId } : {}), + ...(traceContext.spanId !== undefined ? { spanId: traceContext.spanId } : {}), + ...(traceContext.parentSpanId !== undefined ? { parentSpanId: traceContext.parentSpanId } : {}), + ...(traceContext.metadata !== undefined ? { metadata: traceContext.metadata } : {}), + }; + const appliedContext = mergeCommandContext( + { + ...(workflowId !== undefined ? { workflowId } : {}), + ...(commandId !== undefined ? { commandId } : {}), + ...(submissionId !== undefined ? { submissionId } : {}), + ...(normalizedTraceContext !== undefined ? { traceContext: normalizedTraceContext } : {}), + }, + context + ); return { - ...params, - ...(context.workflowId !== undefined ? { workflowId: context.workflowId } : {}), - ...(context.commandId !== undefined ? { commandId: context.commandId } : {}), - ...(context.submissionId !== undefined ? { submissionId: context.submissionId } : {}), - ...(context.traceContext !== undefined ? { traceContext: context.traceContext } : {}), - } as AppliedCommandContext; + ...submitParams, + ...(appliedContext?.workflowId !== undefined ? { workflowId: appliedContext.workflowId } : {}), + ...(appliedContext?.commandId !== undefined ? { commandId: appliedContext.commandId } : {}), + ...(appliedContext?.submissionId !== undefined ? { submissionId: appliedContext.submissionId } : {}), + ...(appliedContext?.traceContext !== undefined ? { traceContext: appliedContext.traceContext } : {}), + }; } function runBestEffort(callback: (() => unknown) | undefined): void { @@ -55,7 +69,7 @@ function runBestEffort(callback: (() => unknown) | undefined): void { export function applyCommandContext( params: T, options?: CommandObservabilityOptions -): AppliedCommandContext { +): AppliedCommandContext { const context = mergeCommandContext(options?.defaultContext, options?.context); return applyMergedCommandContext(params, context); } diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index e790698b..3ac712c6 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -9,6 +9,7 @@ import { OcpClient, OcpValidationError, withdrawAuthorization, + type AppliedCommandContext, type AuthorizeIssuerResult, type CapTableBatchExecuteResult, type CapTableBatchOperations, @@ -127,10 +128,8 @@ const paramsWithCallerMetadata = { callerMetadata: 'preserved' as const, }; const contextualizedParams = applyCommandContext(paramsWithCallerMetadata); -const publishedContextPreservesCallerSubtype: Assert< - IsExactly -> = true; -const preservedCallerMetadata: 'preserved' = contextualizedParams.callerMetadata; +const publishedContextUsesPlainResult: Assert> = true; +const publishedWorkflowId: string | undefined = contextualizedParams.workflowId; const paramsWithLiteralCommandId = { ...paramsWithCallerMetadata, @@ -139,14 +138,12 @@ const paramsWithLiteralCommandId = { const contextualizedWithCommandOverride = applyCommandContext(paramsWithLiteralCommandId, { context: { commandId: 'command-from-context' }, }); -const publishedContextWidensOverriddenLiteral: Assert< - IsExactly -> = true; -const publishedOverridePreservesCallerMetadata: 'preserved' = contextualizedWithCommandOverride.callerMetadata; -void publishedContextPreservesCallerSubtype; -void preservedCallerMetadata; -void publishedContextWidensOverriddenLiteral; -void publishedOverridePreservesCallerMetadata; +const publishedCommandId: string | undefined = contextualizedWithCommandOverride.commandId; +// @ts-expect-error Plain submit results do not promise arbitrary caller-specific members. +contextualizedParams.callerMetadata; +void publishedContextUsesPlainResult; +void publishedWorkflowId; +void publishedCommandId; // @ts-expect-error generated DAML wire unions are intentionally not root exports type RemovedGeneratedWireType = import('../../dist').OcfCreateData; diff --git a/test/exact/builtPublicConfig.types.ts b/test/exact/builtPublicConfig.types.ts index 3616118a..44134d07 100644 --- a/test/exact/builtPublicConfig.types.ts +++ b/test/exact/builtPublicConfig.types.ts @@ -1,6 +1,8 @@ import { ENVIRONMENT_PRESETS, OcpNetworkError, + applyCommandContext, + type AppliedCommandContext, type AuthorizeIssuerParams, type EnvironmentConfig, type EnvironmentConfigInput, @@ -47,6 +49,19 @@ const errorStatusCodeIsRequired: IsOptional = fal const validationReceivedValueIsRequired: IsOptional = false; declare const validationError: OcpValidationError; const validationReceivedValue: unknown = validationError.receivedValue; +class SubmitParamsWithHelper { + readonly commands = []; + readonly actAs = ['issuer::party']; + + helper(): string { + return 'prototype-only'; + } +} +const appliedCommandContext = applyCommandContext(new SubmitParamsWithHelper(), { + context: { workflowId: 'workflow-from-context' }, +}); +const appliedWorkflowId: string | undefined = appliedCommandContext.workflowId; +const appliedContextContract: AppliedCommandContext = appliedCommandContext; // @ts-expect-error Built environment inputs preserve omission-only properties. const explicitUndefinedInput: EnvironmentConfigInput = { environment: 'localnet', ledgerApiUrl: undefined }; @@ -92,6 +107,12 @@ client.observability.defaultContext = { workflowId: 'mutated' }; immutableDefaultContext.workflowId = 'mutated'; // @ts-expect-error Built nested trace metadata is immutable. immutableTraceMetadata.tenant = 'mutated'; +// @ts-expect-error Built plain submit results do not promise prototype-only input members. +appliedCommandContext.helper; +// @ts-expect-error Built applied command-context fields are immutable. +appliedCommandContext.workflowId = 'mutated'; +// @ts-expect-error Built applied optional context properties are omission-only. +const explicitUndefinedAppliedContext: AppliedCommandContext = { commands: [], workflowId: undefined }; void validator; void factory; @@ -118,3 +139,6 @@ void resolved; void validationResult; void immutableDefaultContext; void immutableTraceMetadata; +void appliedWorkflowId; +void appliedContextContract; +void explicitUndefinedAppliedContext; diff --git a/test/exact/sourcePublicConfig.types.ts b/test/exact/sourcePublicConfig.types.ts index 2711dde7..822cfc6e 100644 --- a/test/exact/sourcePublicConfig.types.ts +++ b/test/exact/sourcePublicConfig.types.ts @@ -14,6 +14,7 @@ import { } from '../../src/environment'; import { OcpNetworkError, type OcpValidationError } from '../../src/errors'; import type { AuthorizeIssuerParams } from '../../src/functions/OpenCapTable/issuerAuthorization/types'; +import { applyCommandContext, type AppliedCommandContext } from '../../src/observability'; import type { CommandContext, OcpObservabilityOptions } from '../../src/observabilityTypes'; type IsOptional = {} extends Pick ? true : false; @@ -69,6 +70,19 @@ const errorEndpointIsRequired: IsOptional = false; const validationReceivedValueIsRequired: IsOptional = false; declare const validationError: OcpValidationError; const validationReceivedValue: unknown = validationError.receivedValue; +class SubmitParamsWithHelper { + readonly commands = []; + readonly actAs = ['issuer::party']; + + helper(): string { + return 'prototype-only'; + } +} +const appliedCommandContext = applyCommandContext(new SubmitParamsWithHelper(), { + context: { workflowId: 'workflow-from-context' }, +}); +const appliedWorkflowId: string | undefined = appliedCommandContext.workflowId; +const appliedContextContract: AppliedCommandContext = appliedCommandContext; const optionalValidatorUrl: string | undefined = resolved.validatorApiUrl; if (resolved.authMode === 'oauth2') { @@ -139,6 +153,12 @@ observability.defaultContext = { workflowId: 'mutated' }; immutableDefaultContext.workflowId = 'mutated'; // @ts-expect-error Nested trace metadata is immutable. immutableTraceMetadata.tenant = 'mutated'; +// @ts-expect-error A plain submit result does not promise prototype-only input members. +appliedCommandContext.helper; +// @ts-expect-error Applied command-context fields are immutable. +appliedCommandContext.workflowId = 'mutated'; +// @ts-expect-error Applied optional context properties are omission-only. +const explicitUndefinedAppliedContext: AppliedCommandContext = { commands: [], workflowId: undefined }; void oauthInput; void sharedSecretInput; @@ -170,3 +190,6 @@ void validationResult; void observability; void immutableDefaultContext; void immutableTraceMetadata; +void appliedWorkflowId; +void appliedContextContract; +void explicitUndefinedAppliedContext; diff --git a/test/observability/observability.test.ts b/test/observability/observability.test.ts index 64a82782..9229ad39 100644 --- a/test/observability/observability.test.ts +++ b/test/observability/observability.test.ts @@ -47,18 +47,38 @@ describe('observability helpers', () => { }, }); - const { callerMetadata, commandId }: { callerMetadata: 'preserved'; commandId: string } = result; + const { commandId } = result; expect(result).toMatchObject({ workflowId: 'workflow-default', commandId: 'command-call', submissionId: 'submission-call', traceContext: { traceId: 'trace-1', spanId: 'span-1' }, - callerMetadata, + callerMetadata: 'preserved', }); expect(commandId).toBe('command-call'); }); + it('returns a plain submit result without promising prototype-only caller members', () => { + class SubmitParamsWithHelper { + readonly commands = []; + readonly actAs = ['issuer::party']; + + helper(): string { + return 'prototype-only'; + } + } + + const result = applyCommandContext(new SubmitParamsWithHelper(), { + context: { workflowId: 'workflow-from-context' }, + }); + const { workflowId } = result; + + expect(workflowId).toBe('workflow-from-context'); + expect(result).not.toHaveProperty('helper'); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + }); + it('emits success logs and metrics around command submission', async () => { const client = { submitAndWaitForTransactionTree: jest.fn().mockResolvedValue({ diff --git a/test/types/observability.types.ts b/test/types/observability.types.ts index 43d7aebd..ea85c6bf 100644 --- a/test/types/observability.types.ts +++ b/test/types/observability.types.ts @@ -1,6 +1,6 @@ -/** Compile-time contracts for observability helper subtype preservation. */ +/** Compile-time contracts for the plain observability submit result. */ -import { applyCommandContext } from '../../src'; +import { applyCommandContext, type AppliedCommandContext } from '../../src'; type Assert = T; type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; @@ -15,10 +15,11 @@ const contextualizedParams = applyCommandContext(paramsWithCallerMetadata, { context: { workflowId: 'workflow-1' }, }); -const sourceContextPreservesCallerSubtype: Assert< - IsExactly +const sourceContextUsesPublicResult: Assert> = true; +const sourceWorkflowId: string | undefined = contextualizedParams.workflowId; +const sourceResultOmitsCallerMetadata: Assert< + IsExactly<'callerMetadata' extends keyof typeof contextualizedParams ? true : false, false> > = true; -const preservedCallerMetadata: 'preserved' = contextualizedParams.callerMetadata; const paramsWithLiteralCommandId = { ...paramsWithCallerMetadata, @@ -27,12 +28,14 @@ const paramsWithLiteralCommandId = { const contextualizedWithCommandOverride = applyCommandContext(paramsWithLiteralCommandId, { context: { commandId: 'command-from-context' }, }); -const sourceContextWidensOverriddenLiteral: Assert< - IsExactly -> = true; -const sourceOverridePreservesCallerMetadata: 'preserved' = contextualizedWithCommandOverride.callerMetadata; +const sourceCommandId: string | undefined = contextualizedWithCommandOverride.commandId; + +// @ts-expect-error Arbitrary caller-specific members are not promised by a plain submit result. +contextualizedParams.callerMetadata; +// @ts-expect-error Applied command-context fields are immutable. +contextualizedParams.workflowId = 'mutated'; -void sourceContextPreservesCallerSubtype; -void preservedCallerMetadata; -void sourceContextWidensOverriddenLiteral; -void sourceOverridePreservesCallerMetadata; +void sourceContextUsesPublicResult; +void sourceWorkflowId; +void sourceResultOmitsCallerMetadata; +void sourceCommandId; From e7501f6eea943967420936f009ad5b5a598b1b49 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 20:00:36 -0400 Subject: [PATCH 06/11] Materialize required command parameters --- src/observability.ts | 10 +++++++++- src/observabilityTypes.ts | 7 +++++-- test/exact/builtPublicConfig.types.ts | 13 ++++++++++++- test/exact/sourcePublicConfig.types.ts | 13 ++++++++++++- test/observability/observability.test.ts | 21 +++++++++++++++++++-- 5 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/observability.ts b/src/observability.ts index 6a5af327..e572fa83 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -14,6 +14,10 @@ export type { type SubmitTransactionTreeParams = Parameters[0]; type SubmitTransactionTreeResponse = Awaited>; +type RequiredKeys = { + [K in keyof T]-?: object extends Pick ? never : K; +}[keyof T]; +type RequiredSubmitTransactionTreeParams = Pick>; /** Plain ledger submit parameters with omission-only, immutable command-context fields. */ export type AppliedCommandContext = Omit & CommandContext; @@ -27,7 +31,10 @@ function applyMergedCommandContext( params: SubmitTransactionTreeParams, context: CommandContext | undefined ): AppliedCommandContext { - const { workflowId, commandId, submissionId, traceContext, ...submitParams } = params; + const { commands, workflowId, commandId, submissionId, traceContext, ...submitParams } = params; + // Materialize every required ledger field so structurally valid class instances and + // non-enumerable inputs cannot lose required data when normalized to a plain object. + const requiredSubmitParams: RequiredSubmitTransactionTreeParams = { commands }; const normalizedTraceContext = traceContext === undefined ? undefined @@ -49,6 +56,7 @@ function applyMergedCommandContext( return { ...submitParams, + ...requiredSubmitParams, ...(appliedContext?.workflowId !== undefined ? { workflowId: appliedContext.workflowId } : {}), ...(appliedContext?.commandId !== undefined ? { commandId: appliedContext.commandId } : {}), ...(appliedContext?.submissionId !== undefined ? { submissionId: appliedContext.submissionId } : {}), diff --git a/src/observabilityTypes.ts b/src/observabilityTypes.ts index ed945843..9e37953e 100644 --- a/src/observabilityTypes.ts +++ b/src/observabilityTypes.ts @@ -1,8 +1,11 @@ import type { TraceContext } from '@fairmint/canton-node-sdk'; -export type ReadonlyTraceContext = Readonly> & { +export interface ReadonlyTraceContext { + readonly traceId?: NonNullable; + readonly spanId?: NonNullable; + readonly parentSpanId?: NonNullable; readonly metadata?: Readonly>; -}; +} export interface CommandContext { /** Business process ID persisted by Canton on submitted commands. */ diff --git a/test/exact/builtPublicConfig.types.ts b/test/exact/builtPublicConfig.types.ts index 44134d07..09812bef 100644 --- a/test/exact/builtPublicConfig.types.ts +++ b/test/exact/builtPublicConfig.types.ts @@ -50,9 +50,12 @@ const validationReceivedValueIsRequired: IsOptional { expect(commandId).toBe('command-call'); }); - it('returns a plain submit result without promising prototype-only caller members', () => { + it('materializes required prototype getters in a plain result without promising helper methods', () => { class SubmitParamsWithHelper { - readonly commands = []; readonly actAs = ['issuer::party']; + get commands(): never[] { + return []; + } + helper(): string { return 'prototype-only'; } @@ -75,10 +78,24 @@ describe('observability helpers', () => { const { workflowId } = result; expect(workflowId).toBe('workflow-from-context'); + expect(result.commands).toEqual([]); + expect(Object.prototype.hasOwnProperty.call(result, 'commands')).toBe(true); expect(result).not.toHaveProperty('helper'); expect(Object.getPrototypeOf(result)).toBe(Object.prototype); }); + it('materializes a non-enumerable required submit field', () => { + const commands: never[] = []; + const params = { commands }; + Object.defineProperty(params, 'commands', { enumerable: false }); + + const result = applyCommandContext(params); + + expect(result.commands).toBe(commands); + expect(Object.prototype.hasOwnProperty.call(result, 'commands')).toBe(true); + expect(Object.prototype.propertyIsEnumerable.call(result, 'commands')).toBe(true); + }); + it('emits success logs and metrics around command submission', async () => { const client = { submitAndWaitForTransactionTree: jest.fn().mockResolvedValue({ From bd768200a37d3ec385e4dc37d7608ba11fd30894 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 21:07:37 -0400 Subject: [PATCH 07/11] Project command context inputs exactly --- src/observability.ts | 81 ++++++-- src/utils/commandContext.ts | 27 +-- test/declarations/publicApi.types.ts | 4 + test/exact/builtPublicConfig.types.ts | 14 +- test/exact/sourcePublicConfig.types.ts | 14 +- test/observability/observability.test.ts | 230 ++++++++++++++++++++--- 6 files changed, 317 insertions(+), 53 deletions(-) diff --git a/src/observability.ts b/src/observability.ts index e572fa83..1f65c09c 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -18,6 +18,38 @@ type RequiredKeys = { [K in keyof T]-?: object extends Pick ? never : K; }[keyof T]; type RequiredSubmitTransactionTreeParams = Pick>; +type OptionalSubmitTransactionTreeParams = Omit>; + +function exhaustiveKeys() { + return >( + keys: Keys & ([Exclude] extends [never] ? unknown : never) + ): Keys => keys; +} + +const REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEYS = exhaustiveKeys()(['commands']); +const OPTIONAL_SUBMIT_TRANSACTION_TREE_PARAM_KEYS = exhaustiveKeys()([ + 'commandId', + 'actAs', + 'userId', + 'readAs', + 'workflowId', + 'deduplicationPeriod', + 'minLedgerTimeAbs', + 'minLedgerTimeRel', + 'submissionId', + 'traceContext', + 'disclosedContracts', + 'synchronizerId', + 'packageIdSelectionPreference', + 'prefetchContractKeys', +]); +const SUBMIT_TRANSACTION_TREE_PARAM_KEYS = [ + ...REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEYS, + ...OPTIONAL_SUBMIT_TRANSACTION_TREE_PARAM_KEYS, +] as const satisfies ReadonlyArray; +const REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEY_SET: ReadonlySet = new Set( + REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEYS +); /** Plain ledger submit parameters with omission-only, immutable command-context fields. */ export type AppliedCommandContext = Omit & CommandContext; @@ -27,23 +59,51 @@ export function mergeCommandContext( return mergeCommandContextSnapshots(contexts); } +function snapshotSubmitTransactionTreeParams(params: SubmitTransactionTreeParams): SubmitTransactionTreeParams { + // Keep required fields materialized even when supplied by a prototype getter or as + // a non-enumerable property. This literal also becomes a compile-time tripwire if + // Canton makes another submit field required. + const requiredSubmitParams: RequiredSubmitTransactionTreeParams = { + commands: params.commands, + }; + const snapshot: SubmitTransactionTreeParams = { ...requiredSubmitParams }; + + // Read every optional canonical field exactly once, omit undefined values, and + // intentionally exclude unknown caller-specific properties and methods. + for (const key of SUBMIT_TRANSACTION_TREE_PARAM_KEYS) { + if (REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEY_SET.has(key)) continue; + const value = params[key]; + if (value !== undefined) { + Object.defineProperty(snapshot, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); + } + } + + return snapshot; +} + function applyMergedCommandContext( params: SubmitTransactionTreeParams, context: CommandContext | undefined ): AppliedCommandContext { - const { commands, workflowId, commandId, submissionId, traceContext, ...submitParams } = params; - // Materialize every required ledger field so structurally valid class instances and - // non-enumerable inputs cannot lose required data when normalized to a plain object. - const requiredSubmitParams: RequiredSubmitTransactionTreeParams = { commands }; + const snapshot = snapshotSubmitTransactionTreeParams(params); + const { workflowId, commandId, submissionId, traceContext, ...submitParams } = snapshot; const normalizedTraceContext = traceContext === undefined ? undefined - : { - ...(traceContext.traceId !== undefined ? { traceId: traceContext.traceId } : {}), - ...(traceContext.spanId !== undefined ? { spanId: traceContext.spanId } : {}), - ...(traceContext.parentSpanId !== undefined ? { parentSpanId: traceContext.parentSpanId } : {}), - ...(traceContext.metadata !== undefined ? { metadata: traceContext.metadata } : {}), - }; + : (() => { + const { traceId, spanId, parentSpanId, metadata } = traceContext; + return { + ...(traceId !== undefined ? { traceId } : {}), + ...(spanId !== undefined ? { spanId } : {}), + ...(parentSpanId !== undefined ? { parentSpanId } : {}), + ...(metadata !== undefined ? { metadata } : {}), + }; + })(); const appliedContext = mergeCommandContext( { ...(workflowId !== undefined ? { workflowId } : {}), @@ -56,7 +116,6 @@ function applyMergedCommandContext( return { ...submitParams, - ...requiredSubmitParams, ...(appliedContext?.workflowId !== undefined ? { workflowId: appliedContext.workflowId } : {}), ...(appliedContext?.commandId !== undefined ? { commandId: appliedContext.commandId } : {}), ...(appliedContext?.submissionId !== undefined ? { submissionId: appliedContext.submissionId } : {}), diff --git a/src/utils/commandContext.ts b/src/utils/commandContext.ts index 901fc3ed..37e2f50d 100644 --- a/src/utils/commandContext.ts +++ b/src/utils/commandContext.ts @@ -8,11 +8,12 @@ interface MutableCommandContext { } function snapshotTraceContext(traceContext: ReadonlyTraceContext): ReadonlyTraceContext { - const metadata = traceContext.metadata === undefined ? undefined : Object.freeze({ ...traceContext.metadata }); + const { traceId, spanId, parentSpanId, metadata: inputMetadata } = traceContext; + const metadata = inputMetadata === undefined ? undefined : Object.freeze({ ...inputMetadata }); return Object.freeze({ - ...(traceContext.traceId !== undefined ? { traceId: traceContext.traceId } : {}), - ...(traceContext.spanId !== undefined ? { spanId: traceContext.spanId } : {}), - ...(traceContext.parentSpanId !== undefined ? { parentSpanId: traceContext.parentSpanId } : {}), + ...(traceId !== undefined ? { traceId } : {}), + ...(spanId !== undefined ? { spanId } : {}), + ...(parentSpanId !== undefined ? { parentSpanId } : {}), ...(metadata !== undefined ? { metadata } : {}), }); } @@ -21,11 +22,12 @@ export function snapshotCommandContext(context: Partial | undefi if (context === undefined) { return undefined; } + const { workflowId, commandId, submissionId, traceContext } = context; const snapshot: MutableCommandContext = {}; - if (context.workflowId !== undefined) snapshot.workflowId = context.workflowId; - if (context.commandId !== undefined) snapshot.commandId = context.commandId; - if (context.submissionId !== undefined) snapshot.submissionId = context.submissionId; - if (context.traceContext !== undefined) snapshot.traceContext = snapshotTraceContext(context.traceContext); + if (workflowId !== undefined) snapshot.workflowId = workflowId; + if (commandId !== undefined) snapshot.commandId = commandId; + if (submissionId !== undefined) snapshot.submissionId = submissionId; + if (traceContext !== undefined) snapshot.traceContext = snapshotTraceContext(traceContext); return Object.keys(snapshot).length > 0 ? Object.freeze(snapshot) : undefined; } @@ -35,10 +37,11 @@ export function mergeCommandContextSnapshots( const merged: MutableCommandContext = {}; for (const context of contexts) { if (context === undefined) continue; - if (context.workflowId !== undefined) merged.workflowId = context.workflowId; - if (context.commandId !== undefined) merged.commandId = context.commandId; - if (context.submissionId !== undefined) merged.submissionId = context.submissionId; - if (context.traceContext !== undefined) merged.traceContext = context.traceContext; + const { workflowId, commandId, submissionId, traceContext } = context; + if (workflowId !== undefined) merged.workflowId = workflowId; + if (commandId !== undefined) merged.commandId = commandId; + if (submissionId !== undefined) merged.submissionId = submissionId; + if (traceContext !== undefined) merged.traceContext = traceContext; } return snapshotCommandContext(merged); } diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 3ac712c6..2830bc38 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -130,6 +130,8 @@ const paramsWithCallerMetadata = { const contextualizedParams = applyCommandContext(paramsWithCallerMetadata); const publishedContextUsesPlainResult: Assert> = true; const publishedWorkflowId: string | undefined = contextualizedParams.workflowId; +const publishedActAs: string[] | undefined = contextualizedParams.actAs; +const publishedReadAs: string[] | undefined = contextualizedParams.readAs; const paramsWithLiteralCommandId = { ...paramsWithCallerMetadata, @@ -143,6 +145,8 @@ const publishedCommandId: string | undefined = contextualizedWithCommandOverride contextualizedParams.callerMetadata; void publishedContextUsesPlainResult; void publishedWorkflowId; +void publishedActAs; +void publishedReadAs; void publishedCommandId; // @ts-expect-error generated DAML wire unions are intentionally not root exports diff --git a/test/exact/builtPublicConfig.types.ts b/test/exact/builtPublicConfig.types.ts index 09812bef..ae5fedc0 100644 --- a/test/exact/builtPublicConfig.types.ts +++ b/test/exact/builtPublicConfig.types.ts @@ -50,12 +50,18 @@ const validationReceivedValueIsRequired: IsOptional { commandId: 'command-call', submissionId: 'submission-call', traceContext: { traceId: 'trace-1', spanId: 'span-1' }, - callerMetadata: 'preserved', }); + expect(result).not.toHaveProperty('callerMetadata'); expect(commandId).toBe('command-call'); }); - it('materializes required prototype getters in a plain result without promising helper methods', () => { - class SubmitParamsWithHelper { - readonly actAs = ['issuer::party']; + it('projects every canonical submit field exactly once and strips unknown caller members', () => { + type SubmitParams = Parameters[0]; + type SubmitParamKey = keyof SubmitParams; - get commands(): never[] { - return []; - } + const traceReads = { + traceId: 0, + spanId: 0, + parentSpanId: 0, + metadata: 0, + }; + const traceMetadata = { tenant: 'tenant-1' }; + const traceContext = Object.create(null) as NonNullable; + Object.defineProperties(traceContext, { + traceId: { + get: () => { + traceReads.traceId += 1; + return 'trace-1'; + }, + }, + spanId: { + get: () => { + traceReads.spanId += 1; + return 'span-1'; + }, + }, + parentSpanId: { + get: () => { + traceReads.parentSpanId += 1; + return 'parent-span-1'; + }, + }, + metadata: { + get: () => { + traceReads.metadata += 1; + return traceMetadata; + }, + }, + }); - helper(): string { - return 'prototype-only'; - } + const values: SubmitParams = { + commands: [], + commandId: 'command-1', + actAs: ['issuer::party'], + userId: 'user-1', + readAs: ['reader::party'], + workflowId: 'workflow-1', + deduplicationPeriod: { Empty: {} }, + minLedgerTimeAbs: '2026-07-12T00:00:00Z', + minLedgerTimeRel: { seconds: 5 }, + submissionId: 'submission-1', + traceContext, + disclosedContracts: [], + synchronizerId: 'synchronizer-1', + packageIdSelectionPreference: ['package-1'], + prefetchContractKeys: [], + }; + const canonicalKeys = Object.keys(values) as SubmitParamKey[]; + const reads = Object.fromEntries(canonicalKeys.map((key) => [key, 0])) as Record; + const paramsPrototype = Object.create(null) as Record; + for (const key of canonicalKeys) { + Object.defineProperty(paramsPrototype, key, { + get: () => { + reads[key] += 1; + return values[key]; + }, + }); } - - const result = applyCommandContext(new SubmitParamsWithHelper(), { - context: { workflowId: 'workflow-from-context' }, + Object.defineProperty(paramsPrototype, 'prototypeHelper', { + value: () => 'must-not-leak', + }); + const params = Object.create(paramsPrototype) as SubmitParams & { + callerMetadata: string; + ownHelper: () => string; + }; + // Shadow one prototype getter with a non-enumerable own getter to cover both + // structural property shapes without changing the canonical read count. + Object.defineProperty(params, 'readAs', { + get: () => { + reads.readAs += 1; + return values.readAs; + }, }); - const { workflowId } = result; + let unknownGetterReads = 0; + Object.defineProperties(params, { + callerMetadata: { enumerable: true, value: 'must-not-leak' }, + ownHelper: { + enumerable: true, + get: () => { + unknownGetterReads += 1; + return () => 'must-not-leak'; + }, + }, + }); + const unknownSymbol = Symbol('unknown-submit-field'); + Object.defineProperty(params, unknownSymbol, { enumerable: true, value: 'must-not-leak' }); - expect(workflowId).toBe('workflow-from-context'); - expect(result.commands).toEqual([]); - expect(Object.prototype.hasOwnProperty.call(result, 'commands')).toBe(true); - expect(result).not.toHaveProperty('helper'); + const result = applyCommandContext(params); + + expect(Object.keys(result).sort()).toEqual(canonicalKeys.sort()); + expect(result).toEqual({ + ...values, + traceContext: { + traceId: 'trace-1', + spanId: 'span-1', + parentSpanId: 'parent-span-1', + metadata: { tenant: 'tenant-1' }, + }, + }); expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(result).not.toHaveProperty('callerMetadata'); + expect(result).not.toHaveProperty('ownHelper'); + expect(result).not.toHaveProperty('prototypeHelper'); + expect(Reflect.ownKeys(result)).not.toContain(unknownSymbol); + expect(unknownGetterReads).toBe(0); + expect(reads).toEqual(Object.fromEntries(canonicalKeys.map((key) => [key, 1]))); + expect(traceReads).toEqual({ traceId: 1, spanId: 1, parentSpanId: 1, metadata: 1 }); + expect(Object.isFrozen(result.traceContext)).toBe(true); + expect(Object.isFrozen(result.traceContext?.metadata)).toBe(true); + + traceMetadata.tenant = 'mutated'; + expect(result.traceContext?.metadata).toEqual({ tenant: 'tenant-1' }); }); - it('materializes a non-enumerable required submit field', () => { - const commands: never[] = []; - const params = { commands }; - Object.defineProperty(params, 'commands', { enumerable: false }); + it('applies params, default, and call context precedence without letting undefined clear or change ledger fields', () => { + const defaultReads = { workflowId: 0, commandId: 0, submissionId: 0, traceContext: 0 }; + const defaultContext = Object.create(null) as { + readonly workflowId?: string; + readonly commandId?: string; + readonly submissionId?: string; + readonly traceContext?: { readonly traceId?: string }; + }; + Object.defineProperties(defaultContext, { + workflowId: { + get: () => { + defaultReads.workflowId += 1; + return 'workflow-default'; + }, + }, + commandId: { + get: () => { + defaultReads.commandId += 1; + return 'command-default'; + }, + }, + submissionId: { + get: () => { + defaultReads.submissionId += 1; + return 'submission-default'; + }, + }, + traceContext: { + get: () => { + defaultReads.traceContext += 1; + return { traceId: 'trace-default' }; + }, + }, + }); + const callReads = { workflowId: 0, commandId: 0, submissionId: 0, traceContext: 0 }; + const callContext = Object.create(null) as Record; + Object.defineProperties(callContext, { + workflowId: { + get: () => { + callReads.workflowId += 1; + return 'workflow-call'; + }, + }, + commandId: { + get: () => { + callReads.commandId += 1; + return undefined; + }, + }, + submissionId: { + get: () => { + callReads.submissionId += 1; + return 'submission-call'; + }, + }, + traceContext: { + get: () => { + callReads.traceContext += 1; + return undefined; + }, + }, + actAs: { enumerable: true, value: ['context::must-not-apply'] }, + }); - const result = applyCommandContext(params); + const result = applyCommandContext( + { + commands: [], + actAs: ['params::party'], + workflowId: 'workflow-params', + commandId: 'command-params', + submissionId: 'submission-params', + traceContext: { traceId: 'trace-params' }, + }, + { + defaultContext, + // Deliberately exercise JavaScript callers that provide explicit undefined + // and an unknown ledger field despite the omission-only TypeScript contract. + context: callContext, + } + ); - expect(result.commands).toBe(commands); - expect(Object.prototype.hasOwnProperty.call(result, 'commands')).toBe(true); - expect(Object.prototype.propertyIsEnumerable.call(result, 'commands')).toBe(true); + expect(result).toMatchObject({ + actAs: ['params::party'], + workflowId: 'workflow-call', + commandId: 'command-default', + submissionId: 'submission-call', + traceContext: { traceId: 'trace-default' }, + }); + expect(defaultReads).toEqual({ workflowId: 1, commandId: 1, submissionId: 1, traceContext: 1 }); + expect(callReads).toEqual({ workflowId: 1, commandId: 1, submissionId: 1, traceContext: 1 }); }); it('emits success logs and metrics around command submission', async () => { From c1db98fad8b4a51aa748a11f9d8466a91a29e3fc Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 08:28:47 -0400 Subject: [PATCH 08/11] test: enforce exhaustive observability projections --- src/observability.ts | 35 +++++++++++++++++++++---------- test/types/observability.types.ts | 17 ++++++++++----- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/observability.ts b/src/observability.ts index 63caed60..cd6ffad7 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -17,6 +17,7 @@ export type { type SubmitTransactionTreeParams = Parameters[0]; type SubmitTransactionTreeResponse = Awaited>; +type SubmitTraceContext = NonNullable; type RequiredKeys = { [K in keyof T]-?: object extends Pick ? never : K; }[keyof T]; @@ -46,6 +47,12 @@ const OPTIONAL_SUBMIT_TRANSACTION_TREE_PARAM_KEYS = exhaustiveKeys()([ + 'traceId', + 'spanId', + 'parentSpanId', + 'metadata', +]); const SUBMIT_TRANSACTION_TREE_PARAM_KEYS = [ ...REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEYS, ...OPTIONAL_SUBMIT_TRANSACTION_TREE_PARAM_KEYS, @@ -89,6 +96,22 @@ function snapshotSubmitTransactionTreeParams(params: SubmitTransactionTreeParams return snapshot; } +function snapshotSubmitTraceContext(traceContext: SubmitTraceContext): CommandContext['traceContext'] { + const snapshot: Partial> = {}; + for (const key of SUBMIT_TRACE_CONTEXT_KEYS) { + const value = traceContext[key]; + if (value !== undefined) { + Object.defineProperty(snapshot, key, { + configurable: false, + enumerable: true, + value, + writable: false, + }); + } + } + return Object.freeze(snapshot); +} + function applyMergedCommandContext( params: SubmitTransactionTreeParams, context: CommandContext | undefined @@ -96,17 +119,7 @@ function applyMergedCommandContext( const snapshot = snapshotSubmitTransactionTreeParams(params); const { workflowId, commandId, submissionId, traceContext, ...submitParams } = snapshot; const normalizedTraceContext = - traceContext === undefined - ? undefined - : (() => { - const { traceId, spanId, parentSpanId, metadata } = traceContext; - return { - ...(traceId !== undefined ? { traceId } : {}), - ...(spanId !== undefined ? { spanId } : {}), - ...(parentSpanId !== undefined ? { parentSpanId } : {}), - ...(metadata !== undefined ? { metadata } : {}), - }; - })(); + traceContext === undefined ? undefined : snapshotSubmitTraceContext(traceContext); const appliedContext = mergeCommandContext( { ...(workflowId !== undefined ? { workflowId } : {}), diff --git a/test/types/observability.types.ts b/test/types/observability.types.ts index ea85c6bf..b6195169 100644 --- a/test/types/observability.types.ts +++ b/test/types/observability.types.ts @@ -1,9 +1,7 @@ /** Compile-time contracts for the plain observability submit result. */ -import { applyCommandContext, type AppliedCommandContext } from '../../src'; - -type Assert = T; -type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +import { applyCommandContext, type AppliedCommandContext, type CommandContext } from '../../src'; +import type { Assert, IsExactly } from '../typeContracts/typeAssertions'; const paramsWithCallerMetadata = { commands: [], @@ -15,7 +13,14 @@ const contextualizedParams = applyCommandContext(paramsWithCallerMetadata, { context: { workflowId: 'workflow-1' }, }); -const sourceContextUsesPublicResult: Assert> = true; +const sourceContextUsesPublicResult: AppliedCommandContext = contextualizedParams; +const sourceResultKeysAreExact: Assert> = true; +const sourceContextFieldsAreExact: Assert< + IsExactly< + Pick, + Pick + > +> = true; const sourceWorkflowId: string | undefined = contextualizedParams.workflowId; const sourceResultOmitsCallerMetadata: Assert< IsExactly<'callerMetadata' extends keyof typeof contextualizedParams ? true : false, false> @@ -36,6 +41,8 @@ contextualizedParams.callerMetadata; contextualizedParams.workflowId = 'mutated'; void sourceContextUsesPublicResult; +void sourceResultKeysAreExact; +void sourceContextFieldsAreExact; void sourceWorkflowId; void sourceResultOmitsCallerMetadata; void sourceCommandId; From ab6bb21bee7e204df3934c78915f6738f12df847 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 08:33:05 -0400 Subject: [PATCH 09/11] fix: keep exact boundary checks executable --- src/functions/OpenCapTable/capTable/CapTableBatch.ts | 2 +- src/observability.ts | 3 +-- test/declarations/publicApi.types.ts | 10 +++++++++- test/types/observability.types.ts | 8 +++----- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/CapTableBatch.ts b/src/functions/OpenCapTable/capTable/CapTableBatch.ts index a0dbeded..20192036 100644 --- a/src/functions/OpenCapTable/capTable/CapTableBatch.ts +++ b/src/functions/OpenCapTable/capTable/CapTableBatch.ts @@ -399,7 +399,7 @@ export class CapTableBatch { ...(this.params.logger === undefined ? {} : { logger: this.params.logger }), ...(this.params.metrics === undefined ? {} : { metrics: this.params.metrics }), ...(this.params.defaultContext === undefined ? {} : { defaultContext: this.params.defaultContext }), - ...(context === undefined ? {} : { context }), + context, }, { operation: 'capTable.update', diff --git a/src/observability.ts b/src/observability.ts index cd6ffad7..f1abfe48 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -118,8 +118,7 @@ function applyMergedCommandContext( ): AppliedCommandContext { const snapshot = snapshotSubmitTransactionTreeParams(params); const { workflowId, commandId, submissionId, traceContext, ...submitParams } = snapshot; - const normalizedTraceContext = - traceContext === undefined ? undefined : snapshotSubmitTraceContext(traceContext); + const normalizedTraceContext = traceContext === undefined ? undefined : snapshotSubmitTraceContext(traceContext); const appliedContext = mergeCommandContext( { ...(workflowId !== undefined ? { workflowId } : {}), diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 113faad0..8201da7c 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -15,6 +15,7 @@ import { type CapTableBatchOperations, type CapTableBatchParams, type CapTableContractDetails, + type CommandContext, type ConversionTriggerFor, type ConvertibleConversionRight, type ConvertibleConversionTrigger, @@ -160,7 +161,12 @@ const paramsWithCallerMetadata = { callerMetadata: 'preserved' as const, }; const contextualizedParams = applyCommandContext(paramsWithCallerMetadata); -const publishedContextUsesPlainResult: Assert> = true; +const publishedContextUsesPlainResult: AppliedCommandContext = contextualizedParams; +const publishedContextKeysAreExact: Assert> = + true; +const publishedContextFieldsAreExact: Assert< + IsExactly, Pick> +> = true; const publishedWorkflowId: string | undefined = contextualizedParams.workflowId; const publishedActAs: string[] | undefined = contextualizedParams.actAs; const publishedReadAs: string[] | undefined = contextualizedParams.readAs; @@ -176,6 +182,8 @@ const publishedCommandId: string | undefined = contextualizedWithCommandOverride // @ts-expect-error Plain submit results do not promise arbitrary caller-specific members. contextualizedParams.callerMetadata; void publishedContextUsesPlainResult; +void publishedContextKeysAreExact; +void publishedContextFieldsAreExact; void publishedWorkflowId; void publishedActAs; void publishedReadAs; diff --git a/test/types/observability.types.ts b/test/types/observability.types.ts index b6195169..fb29e1b3 100644 --- a/test/types/observability.types.ts +++ b/test/types/observability.types.ts @@ -14,12 +14,10 @@ const contextualizedParams = applyCommandContext(paramsWithCallerMetadata, { }); const sourceContextUsesPublicResult: AppliedCommandContext = contextualizedParams; -const sourceResultKeysAreExact: Assert> = true; +const sourceResultKeysAreExact: Assert> = + true; const sourceContextFieldsAreExact: Assert< - IsExactly< - Pick, - Pick - > + IsExactly, Pick> > = true; const sourceWorkflowId: string | undefined = contextualizedParams.workflowId; const sourceResultOmitsCallerMetadata: Assert< From f9bdc196090a7c79aa2054f32748793eeb5806a8 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 08:40:55 -0400 Subject: [PATCH 10/11] fix: harden applied command context snapshots --- src/observability.ts | 142 +++++++++++++++++----- test/declarations/publicApi.types.ts | 2 + test/exact/builtPublicConfig.types.ts | 2 + test/exact/sourcePublicConfig.types.ts | 2 + test/observability/observability.test.ts | 143 +++++++++++------------ test/types/observability.types.ts | 2 + 6 files changed, 186 insertions(+), 107 deletions(-) diff --git a/src/observability.ts b/src/observability.ts index f1abfe48..5a9cc73c 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -1,8 +1,15 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { types as nodeUtilTypes } from 'node:util'; +import { OcpErrorCodes, OcpValidationError } from './errors'; import { toSafeDiagnosticText, toSafeDiagnosticValue } from './errors/OcpError'; import type { CommandContext, CommandObservabilityOptions, CommandTelemetry } from './observabilityTypes'; -import { mergeCommandContextSnapshots } from './utils/commandContext'; +import { mergeCommandContextSnapshots, snapshotCommandContext } from './utils/commandContext'; +import { + inspectExactObject, + toExactDataValidationError, + type ExactDataFailure, + type ExactObjectSnapshot, +} from './utils/exactObject'; import { snapshotCommandObservabilityOptions } from './utils/observabilityConfig'; export type { @@ -53,6 +60,7 @@ const SUBMIT_TRACE_CONTEXT_KEYS = exhaustiveKeys()([ 'parentSpanId', 'metadata', ]); +const SUBMIT_TRACE_CONTEXT_KEY_SET: ReadonlySet = new Set(SUBMIT_TRACE_CONTEXT_KEYS); const SUBMIT_TRANSACTION_TREE_PARAM_KEYS = [ ...REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEYS, ...OPTIONAL_SUBMIT_TRANSACTION_TREE_PARAM_KEYS, @@ -60,8 +68,8 @@ const SUBMIT_TRANSACTION_TREE_PARAM_KEYS = [ const REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEY_SET: ReadonlySet = new Set( REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEYS ); -/** Plain ledger submit parameters with omission-only, immutable command-context fields. */ -export type AppliedCommandContext = Omit & CommandContext; +/** Plain ledger submit parameters with omission-only, immutable top-level and command-context fields. */ +export type AppliedCommandContext = Readonly> & CommandContext; export function mergeCommandContext( ...contexts: Array | undefined> @@ -69,47 +77,119 @@ export function mergeCommandContext( return mergeCommandContextSnapshots(contexts); } +function throwSubmitObjectFailure(root: string, subject: string, failure: ExactDataFailure): never { + throw toExactDataValidationError(root, failure, { + message: `${subject} must be a plain object containing own data properties only; rejected ${failure.reason}.`, + expectedType: `plain ${subject} object with own data properties only`, + }); +} + +function requiredSubmitParameter( + snapshot: ExactObjectSnapshot, + key: keyof RequiredSubmitTransactionTreeParams, + root: string +): unknown { + if (!snapshot.has(key)) { + throw new OcpValidationError(`${root}.${key}`, `${key} is required.`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType: 'defined own data property', + }); + } + const value = snapshot.get(key); + if (value === undefined) { + throw new OcpValidationError(`${root}.${key}`, `${key} must not be undefined.`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'defined own data property', + receivedValue: value, + }); + } + return value; +} + function snapshotSubmitTransactionTreeParams(params: SubmitTransactionTreeParams): SubmitTransactionTreeParams { - // Keep required fields materialized even when supplied by a prototype getter or as - // a non-enumerable property. This literal also becomes a compile-time tripwire if - // Canton makes another submit field required. + const inspection = inspectExactObject(params); + if (!inspection.ok) throwSubmitObjectFailure('submitParams', 'ledger submit parameters', inspection); + + // Keep required fields materialized. The typed assertion is localized after a + // descriptor-safe read; the Canton client remains responsible for command decoding. const requiredSubmitParams: RequiredSubmitTransactionTreeParams = { - commands: params.commands, + commands: requiredSubmitParameter( + inspection.snapshot, + 'commands', + 'submitParams' + ) as SubmitTransactionTreeParams['commands'], }; const snapshot: SubmitTransactionTreeParams = { ...requiredSubmitParams }; - // Read every optional canonical field exactly once, omit undefined values, and - // intentionally exclude unknown caller-specific properties and methods. + // Project every optional canonical field from its captured data descriptor and + // intentionally exclude unknown caller-specific data properties. for (const key of SUBMIT_TRANSACTION_TREE_PARAM_KEYS) { if (REQUIRED_SUBMIT_TRANSACTION_TREE_PARAM_KEY_SET.has(key)) continue; - const value = params[key]; - if (value !== undefined) { - Object.defineProperty(snapshot, key, { - configurable: true, - enumerable: true, - value, - writable: true, - }); + if (!inspection.snapshot.has(key)) continue; + const value = inspection.snapshot.get(key); + if (value === undefined) { + throw new OcpValidationError( + `submitParams.${String(key)}`, + `${String(key)} must be omitted rather than undefined.`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'defined value or omitted property', + receivedValue: value, + } + ); } + Object.defineProperty(snapshot, key, { + configurable: false, + enumerable: true, + value, + writable: false, + }); } - return snapshot; + return Object.freeze(snapshot); } -function snapshotSubmitTraceContext(traceContext: SubmitTraceContext): CommandContext['traceContext'] { - const snapshot: Partial> = {}; +function snapshotSubmitTraceContext(traceContext: SubmitTraceContext): NonNullable { + const inspection = inspectExactObject(traceContext, { allowedKeys: SUBMIT_TRACE_CONTEXT_KEY_SET }); + if (!inspection.ok) throwSubmitObjectFailure('submitParams.traceContext', 'trace context', inspection); + + const projected: Partial = {}; for (const key of SUBMIT_TRACE_CONTEXT_KEYS) { - const value = traceContext[key]; - if (value !== undefined) { - Object.defineProperty(snapshot, key, { - configurable: false, - enumerable: true, - value, - writable: false, - }); + if (!inspection.snapshot.has(key)) continue; + const value = inspection.snapshot.get(key); + if (value === undefined) { + throw new OcpValidationError( + `submitParams.traceContext.${String(key)}`, + `${String(key)} must be omitted rather than undefined.`, + { + code: OcpErrorCodes.INVALID_TYPE, + expectedType: 'defined value or omitted property', + receivedValue: value, + } + ); } + Object.defineProperty(projected, key, { + configurable: false, + enumerable: true, + value, + writable: false, + }); } - return Object.freeze(snapshot); + + const context = snapshotCommandContext( + { + traceContext: projected, + }, + 'submitParams' + ); + if (context?.traceContext === undefined) { + throw new OcpValidationError('submitParams.traceContext', 'traceContext snapshot could not be created.', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'valid trace context', + receivedValue: traceContext, + }); + } + return context.traceContext; } function applyMergedCommandContext( @@ -129,13 +209,13 @@ function applyMergedCommandContext( context ); - return { + return Object.freeze({ ...submitParams, ...(appliedContext?.workflowId !== undefined ? { workflowId: appliedContext.workflowId } : {}), ...(appliedContext?.commandId !== undefined ? { commandId: appliedContext.commandId } : {}), ...(appliedContext?.submissionId !== undefined ? { submissionId: appliedContext.submissionId } : {}), ...(appliedContext?.traceContext !== undefined ? { traceContext: appliedContext.traceContext } : {}), - }; + }); } function runBestEffort(callback: (() => unknown) | undefined): void { diff --git a/test/declarations/publicApi.types.ts b/test/declarations/publicApi.types.ts index 8201da7c..1dd642b1 100644 --- a/test/declarations/publicApi.types.ts +++ b/test/declarations/publicApi.types.ts @@ -181,6 +181,8 @@ const contextualizedWithCommandOverride = applyCommandContext(paramsWithLiteralC const publishedCommandId: string | undefined = contextualizedWithCommandOverride.commandId; // @ts-expect-error Plain submit results do not promise arbitrary caller-specific members. contextualizedParams.callerMetadata; +// @ts-expect-error Published applied submit fields are immutable at the top level. +contextualizedParams.commands = []; void publishedContextUsesPlainResult; void publishedContextKeysAreExact; void publishedContextFieldsAreExact; diff --git a/test/exact/builtPublicConfig.types.ts b/test/exact/builtPublicConfig.types.ts index 7f5f68cd..c0af6fcd 100644 --- a/test/exact/builtPublicConfig.types.ts +++ b/test/exact/builtPublicConfig.types.ts @@ -185,6 +185,8 @@ immutableTraceMetadata.tenant = 'mutated'; appliedCommandContext.helper; // @ts-expect-error Built applied command-context fields are immutable. appliedCommandContext.workflowId = 'mutated'; +// @ts-expect-error Built applied ledger submit fields are immutable at the top level. +appliedCommandContext.commands = []; // @ts-expect-error Built applied optional context properties are omission-only. const explicitUndefinedAppliedContext: AppliedCommandContext = { commands: [], workflowId: undefined }; const explicitUndefinedAppliedTraceId: AppliedCommandContext = { diff --git a/test/exact/sourcePublicConfig.types.ts b/test/exact/sourcePublicConfig.types.ts index 6adce67c..cf6b552d 100644 --- a/test/exact/sourcePublicConfig.types.ts +++ b/test/exact/sourcePublicConfig.types.ts @@ -207,6 +207,8 @@ immutableTraceMetadata.tenant = 'mutated'; appliedCommandContext.helper; // @ts-expect-error Applied command-context fields are immutable. appliedCommandContext.workflowId = 'mutated'; +// @ts-expect-error Applied ledger submit fields are immutable at the top level. +appliedCommandContext.commands = []; // @ts-expect-error Applied optional context properties are omission-only. const explicitUndefinedAppliedContext: AppliedCommandContext = { commands: [], workflowId: undefined }; const explicitUndefinedAppliedTraceId: AppliedCommandContext = { diff --git a/test/observability/observability.test.ts b/test/observability/observability.test.ts index aeadec45..4fce759c 100644 --- a/test/observability/observability.test.ts +++ b/test/observability/observability.test.ts @@ -59,45 +59,11 @@ describe('observability helpers', () => { expect(commandId).toBe('command-call'); }); - it('projects every canonical submit field exactly once and strips unknown caller members', () => { + it('projects canonical own data fields, strips unknown data members, and freezes the result', () => { type SubmitParams = Parameters[0]; type SubmitParamKey = keyof SubmitParams; - const traceReads = { - traceId: 0, - spanId: 0, - parentSpanId: 0, - metadata: 0, - }; const traceMetadata = { tenant: 'tenant-1' }; - const traceContext = Object.create(null) as NonNullable; - Object.defineProperties(traceContext, { - traceId: { - get: () => { - traceReads.traceId += 1; - return 'trace-1'; - }, - }, - spanId: { - get: () => { - traceReads.spanId += 1; - return 'span-1'; - }, - }, - parentSpanId: { - get: () => { - traceReads.parentSpanId += 1; - return 'parent-span-1'; - }, - }, - metadata: { - get: () => { - traceReads.metadata += 1; - return traceMetadata; - }, - }, - }); - const values: SubmitParams = { commands: [], commandId: 'command-1', @@ -109,51 +75,33 @@ describe('observability helpers', () => { minLedgerTimeAbs: '2026-07-12T00:00:00Z', minLedgerTimeRel: { seconds: 5 }, submissionId: 'submission-1', - traceContext, + traceContext: { + traceId: 'trace-1', + spanId: 'span-1', + parentSpanId: 'parent-span-1', + metadata: traceMetadata, + }, disclosedContracts: [], synchronizerId: 'synchronizer-1', packageIdSelectionPreference: ['package-1'], prefetchContractKeys: [], }; const canonicalKeys = Object.keys(values) as SubmitParamKey[]; - const reads = Object.fromEntries(canonicalKeys.map((key) => [key, 0])) as Record; - const paramsPrototype = Object.create(null) as Record; - for (const key of canonicalKeys) { - Object.defineProperty(paramsPrototype, key, { - get: () => { - reads[key] += 1; - return values[key]; - }, - }); - } - Object.defineProperty(paramsPrototype, 'prototypeHelper', { - value: () => 'must-not-leak', - }); - const params = Object.create(paramsPrototype) as SubmitParams & { + const params = { + callerMetadata: 'must-not-leak', + ownHelper: () => 'must-not-leak', + } as SubmitParams & { callerMetadata: string; ownHelper: () => string; }; - // Shadow one prototype getter with a non-enumerable own getter to cover both - // structural property shapes without changing the canonical read count. - Object.defineProperty(params, 'readAs', { - get: () => { - reads.readAs += 1; - return values.readAs; - }, - }); - let unknownGetterReads = 0; - Object.defineProperties(params, { - callerMetadata: { enumerable: true, value: 'must-not-leak' }, - ownHelper: { - enumerable: true, - get: () => { - unknownGetterReads += 1; - return () => 'must-not-leak'; - }, - }, - }); - const unknownSymbol = Symbol('unknown-submit-field'); - Object.defineProperty(params, unknownSymbol, { enumerable: true, value: 'must-not-leak' }); + for (const key of canonicalKeys) { + Object.defineProperty(params, key, { + configurable: false, + enumerable: key !== 'readAs', + value: values[key], + writable: false, + }); + } const result = applyCommandContext(params); @@ -170,11 +118,7 @@ describe('observability helpers', () => { expect(Object.getPrototypeOf(result)).toBe(Object.prototype); expect(result).not.toHaveProperty('callerMetadata'); expect(result).not.toHaveProperty('ownHelper'); - expect(result).not.toHaveProperty('prototypeHelper'); - expect(Reflect.ownKeys(result)).not.toContain(unknownSymbol); - expect(unknownGetterReads).toBe(0); - expect(reads).toEqual(Object.fromEntries(canonicalKeys.map((key) => [key, 1]))); - expect(traceReads).toEqual({ traceId: 1, spanId: 1, parentSpanId: 1, metadata: 1 }); + expect(Object.isFrozen(result)).toBe(true); expect(Object.isFrozen(result.traceContext)).toBe(true); expect(Object.isFrozen(result.traceContext?.metadata)).toBe(true); @@ -182,6 +126,53 @@ describe('observability helpers', () => { expect(result.traceContext?.metadata).toEqual({ tenant: 'tenant-1' }); }); + it('rejects proxy, accessor, symbol, and inherited submit carriers without invoking traps', () => { + const proxyGet = jest.fn(() => []); + const proxy = new Proxy({ commands: [] }, { get: proxyGet }); + expect(() => applyCommandContext(proxy)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams' }) + ); + expect(proxyGet).not.toHaveBeenCalled(); + + const commandsGetter = jest.fn(() => []); + const accessorParams = {} as Parameters[0]; + Object.defineProperty(accessorParams, 'commands', { enumerable: true, get: commandsGetter }); + expect(() => applyCommandContext(accessorParams)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.commands' }) + ); + expect(commandsGetter).not.toHaveBeenCalled(); + + const inheritedParams = Object.create({ commands: [] }) as Parameters[0]; + expect(() => applyCommandContext(inheritedParams)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams' }) + ); + + const symbol = Symbol('hidden'); + expect(() => applyCommandContext({ commands: [], [symbol]: 'hidden' } as never)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams' }) + ); + }); + + it('rejects trace accessors and explicit undefined optional submit fields without invoking accessors', () => { + const traceIdGetter = jest.fn(() => 'trace-1'); + const traceContext = {} as NonNullable[0]['traceContext']>; + Object.defineProperty(traceContext, 'traceId', { enumerable: true, get: traceIdGetter }); + expect(() => applyCommandContext({ commands: [], traceContext })).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.traceContext.traceId' }) + ); + expect(traceIdGetter).not.toHaveBeenCalled(); + + expect(() => applyCommandContext({ commands: [], workflowId: undefined })).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.workflowId' }) + ); + expect(() => applyCommandContext({ commands: [], traceContext: { traceId: undefined } })).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.traceContext.traceId' }) + ); + expect(() => applyCommandContext({} as never)).toThrow( + expect.objectContaining({ name: 'OcpValidationError', fieldPath: 'submitParams.commands' }) + ); + }); + it('applies params, default, and call context precedence without omitted fields clearing earlier values', () => { const result = applyCommandContext( { diff --git a/test/types/observability.types.ts b/test/types/observability.types.ts index fb29e1b3..61ece9bc 100644 --- a/test/types/observability.types.ts +++ b/test/types/observability.types.ts @@ -37,6 +37,8 @@ const sourceCommandId: string | undefined = contextualizedWithCommandOverride.co contextualizedParams.callerMetadata; // @ts-expect-error Applied command-context fields are immutable. contextualizedParams.workflowId = 'mutated'; +// @ts-expect-error Applied ledger submit fields are immutable at the top level too. +contextualizedParams.commands = []; void sourceContextUsesPublicResult; void sourceResultKeysAreExact; From 87e30866bf1e02eae44566aadbb96cefdb3f7731 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 08:55:28 -0400 Subject: [PATCH 11/11] docs: align submit carrier contracts --- src/observability.ts | 6 ++++++ test/exact/builtPublicConfig.types.ts | 27 ++++++++------------------ test/exact/sourcePublicConfig.types.ts | 27 ++++++++------------------ 3 files changed, 22 insertions(+), 38 deletions(-) diff --git a/src/observability.ts b/src/observability.ts index 5a9cc73c..e3e746c5 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -283,6 +283,12 @@ function observedErrorDiagnostics(error: unknown): ObservedErrorDiagnostics { }); } +/** + * Apply command context to a plain ledger-submit carrier. + * + * Runtime inputs must use own data properties on a plain or null-prototype object. + * Proxies, accessors, symbols, and custom prototypes are rejected without invoking traps. + */ export function applyCommandContext( params: T, options?: CommandObservabilityOptions diff --git a/test/exact/builtPublicConfig.types.ts b/test/exact/builtPublicConfig.types.ts index c0af6fcd..1484e13a 100644 --- a/test/exact/builtPublicConfig.types.ts +++ b/test/exact/builtPublicConfig.types.ts @@ -70,24 +70,13 @@ const errorStatusCodeIsRequired: IsOptional = fal const validationReceivedValueIsRequired: IsOptional = false; declare const validationError: OcpValidationError; const validationReceivedValue: unknown = validationError.receivedValue; -class SubmitParamsWithHelper { - get commands(): never[] { - return []; - } - - get actAs(): string[] { - return ['issuer::party']; - } - - get readAs(): string[] { - return ['reader::party']; - } - - helper(): string { - return 'prototype-only'; - } -} -const appliedCommandContext = applyCommandContext(new SubmitParamsWithHelper(), { +const submitParamsWithHelper = { + commands: [], + actAs: ['issuer::party'], + readAs: ['reader::party'], + helper: () => 'caller-only', +}; +const appliedCommandContext = applyCommandContext(submitParamsWithHelper, { context: { workflowId: 'workflow-from-context' }, }); const appliedWorkflowId: string | undefined = appliedCommandContext.workflowId; @@ -181,7 +170,7 @@ client.observability.defaultContext = { workflowId: 'mutated' }; immutableDefaultContext.workflowId = 'mutated'; // @ts-expect-error Built nested trace metadata is immutable. immutableTraceMetadata.tenant = 'mutated'; -// @ts-expect-error Built plain submit results do not promise prototype-only input members. +// @ts-expect-error Built plain submit results do not promise caller-only input members. appliedCommandContext.helper; // @ts-expect-error Built applied command-context fields are immutable. appliedCommandContext.workflowId = 'mutated'; diff --git a/test/exact/sourcePublicConfig.types.ts b/test/exact/sourcePublicConfig.types.ts index cf6b552d..1f4549fb 100644 --- a/test/exact/sourcePublicConfig.types.ts +++ b/test/exact/sourcePublicConfig.types.ts @@ -78,24 +78,13 @@ const errorEndpointIsRequired: IsOptional = false; const validationReceivedValueIsRequired: IsOptional = false; declare const validationError: OcpValidationError; const validationReceivedValue: unknown = validationError.receivedValue; -class SubmitParamsWithHelper { - get commands(): never[] { - return []; - } - - get actAs(): string[] { - return ['issuer::party']; - } - - get readAs(): string[] { - return ['reader::party']; - } - - helper(): string { - return 'prototype-only'; - } -} -const appliedCommandContext = applyCommandContext(new SubmitParamsWithHelper(), { +const submitParamsWithHelper = { + commands: [], + actAs: ['issuer::party'], + readAs: ['reader::party'], + helper: () => 'caller-only', +}; +const appliedCommandContext = applyCommandContext(submitParamsWithHelper, { context: { workflowId: 'workflow-from-context' }, }); const appliedWorkflowId: string | undefined = appliedCommandContext.workflowId; @@ -203,7 +192,7 @@ observability.defaultContext = { workflowId: 'mutated' }; immutableDefaultContext.workflowId = 'mutated'; // @ts-expect-error Nested trace metadata is immutable. immutableTraceMetadata.tenant = 'mutated'; -// @ts-expect-error A plain submit result does not promise prototype-only input members. +// @ts-expect-error A plain submit result does not promise caller-only input members. appliedCommandContext.helper; // @ts-expect-error Applied command-context fields are immutable. appliedCommandContext.workflowId = 'mutated';