Skip to content

Commit f6882fe

Browse files
btraversclaude
andcommitted
fix!: parse payloads once per boundary (validate on send, transmit original)
Implements D1 from the v8 review remediation spec. Previously both sides of every payload boundary ran the same Standard Schema and the sender transmitted the PARSED value; the receiver parsed again, so a transforming schema (z.coerce.*, .transform(...)) was applied twice — silent data corruption. Now each boundary parses exactly once, on the receiving side: the sender still validates (failing early with the existing typed error) but transmits the caller's ORIGINAL value, discarding the parsed result. Send-side sites reclassified (validate, transmit original): - client startWorkflow / executeWorkflow / signalWithStart workflow args (resolveDefinitionAndValidateInput no longer returns a parsed value) - client signalWithStart signal args - client handle signal/query/update proxy inputs (buildValidatedProxy) - client schedule.create args - worker workflow-side activity proxy inputs (both throwing and Result-shaped wrappers in activities-proxy.ts) - worker child-workflow args (startChildWorkflow / executeChildWorkflow) - worker continueAsNew args - worker workflow output (declareWorkflow returns the implementation's original value after validating it) - worker activity handler output (declareActivitiesHandler) - worker query/update handler outputs (bindQueryHandler/bindUpdateHandler) - worker contract-error data (contractErrorToApplicationFailure details[0]) Receive-side sites confirmed as the single boundary parse (unchanged): - worker workflow/signal/query/update input handlers, activity handler input, middleware-substituted inputs (a patched input is raw from the pipeline's perspective and parsed once) - client executeWorkflow / handle.result() / query / update result parsing - worker parent-side child-workflow result parsing, activity-result parsing in the workflow proxy, contract-error rehydration Adds transform-schema tests pinning the new wire format at unit level in both packages plus an end-to-end integration case in the client suite, and rewrites the old "intentional double-validation" rationale comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 018c56f commit f6882fe

21 files changed

Lines changed: 866 additions & 90 deletions

packages/client/src/__tests__/client.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,33 @@ describe("Client Package - Integration Tests", () => {
158158
});
159159
});
160160

161+
describe("Wire format (D1) — transforms apply exactly once per boundary", () => {
162+
it("sends the original input, receiver parses once; output parsed once by the client", async ({
163+
client,
164+
}) => {
165+
// GIVEN — input schema appends "!", output schema doubles. The client
166+
// validates-and-discards on send; the workflow-side parse is the only
167+
// input transform, and the client-side result parse is the only output
168+
// transform. Double application would yield "hello!!" / 84.
169+
const input = { text: "hello" };
170+
171+
// WHEN
172+
const result = await client.executeWorkflow("transformWorkflow", {
173+
workflowId: `transform-${Date.now()}`,
174+
args: input,
175+
});
176+
177+
// THEN
178+
expect(result).toBeOk();
179+
if (result.isOk()) {
180+
expect(result.value).toEqual({
181+
handlerSaw: "hello!", // exactly one "!" — parsed once, on receive
182+
doubled: 42, // 21 doubled exactly once, by the client's parse
183+
});
184+
}
185+
});
186+
});
187+
161188
describe("Workflow with Activities", () => {
162189
it("should execute workflow with activity", async ({ client }) => {
163190
// GIVEN

packages/client/src/__tests__/test.contract.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ export const testContract = defineContract({
6060
},
6161
}),
6262

63+
// Workflow with transforming input/output schemas — exercises the D1
64+
// wire format: the sender validates but transmits the ORIGINAL value;
65+
// the receiver parses, so each transform applies exactly once.
66+
transformWorkflow: defineWorkflow({
67+
input: z.object({
68+
text: z.string().transform((s) => `${s}!`),
69+
}),
70+
output: z.object({
71+
// What the (receive-side-parsed) input looked like to the handler.
72+
handlerSaw: z.string(),
73+
// The handler returns the pre-transform number; only the client's
74+
// receive-side parse doubles it.
75+
doubled: z.number().transform((n) => n * 2),
76+
}),
77+
}),
78+
6379
// Workflow with activities
6480
workflowWithActivity: defineWorkflow({
6581
input: z.object({

packages/client/src/__tests__/test.workflows.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
defineUpdate,
88
} from "@temporalio/workflow";
99

10+
import { testContract } from "./test.contract.js";
11+
1012
// Define activity types manually based on the contract
1113
type Activities = {
1214
logMessage(args: { message: string }): Promise<{}>;
@@ -56,6 +58,22 @@ export async function interactiveWorkflow(args: { initialValue: number }) {
5658
};
5759
}
5860

61+
// Mirrors what `@temporal-contract/worker`'s `declareWorkflow` does at the
62+
// D1 wire boundary: the client transmits the caller's ORIGINAL args, the
63+
// receiving side parses them exactly once, and the return value is handed to
64+
// Temporal untransformed (the client parses the output on receive).
65+
export async function transformWorkflow(args: { text: string }) {
66+
const parsed = await testContract.workflows.transformWorkflow.input["~standard"].validate(args);
67+
if (parsed.issues) {
68+
throw new Error(`transformWorkflow input validation failed`);
69+
}
70+
const input = parsed.value as { text: string };
71+
return {
72+
handlerSaw: input.text,
73+
doubled: 21,
74+
};
75+
}
76+
5977
export async function workflowWithActivity(args: { message: string }) {
6078
const processed = await activities.processMessage({ message: args.message });
6179
await activities.logMessage({ message: `Activity result: ${processed.processed}` });

packages/client/src/client.spec.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,201 @@ describe("TypedClient", () => {
13201320
});
13211321
});
13221322

1323+
describe("TypedClient — wire format (validate on send, parse on receive)", () => {
1324+
// D1: each payload boundary parses exactly once, on the receiving side.
1325+
// The client validates what it sends (failing early with the typed
1326+
// validation error) but transmits the caller's ORIGINAL value; parsed
1327+
// results are only used on the receive side (workflow/query/update
1328+
// results). Transforming schemas make the two sides observable.
1329+
const transformContract = defineContract({
1330+
taskQueue: "wire-q",
1331+
workflows: {
1332+
transformer: defineWorkflow({
1333+
// Asymmetric transform: input type is `string`, parsed type is `number`.
1334+
input: z.string().transform((s) => s.length),
1335+
output: z.number().transform((n) => n * 2),
1336+
signals: {
1337+
ping: { input: z.string().transform((s) => s.length) },
1338+
},
1339+
queries: {
1340+
peek: {
1341+
input: z.string().transform((s) => s.length),
1342+
output: z.number().transform((n) => n * 2),
1343+
},
1344+
},
1345+
updates: {
1346+
poke: {
1347+
input: z.string().transform((s) => s.length),
1348+
output: z.number().transform((n) => n * 2),
1349+
},
1350+
},
1351+
}),
1352+
},
1353+
});
1354+
1355+
let wireClient: TypedClient<typeof transformContract>;
1356+
1357+
beforeEach(() => {
1358+
vi.clearAllMocks();
1359+
const rawClient = { workflow: mockWorkflow, schedule: mockSchedule } as unknown as Client;
1360+
wireClient = TypedClient.createOrThrow(transformContract, rawClient);
1361+
});
1362+
1363+
it("startWorkflow transmits the ORIGINAL args, not the parsed value", async () => {
1364+
mockWorkflow.start.mockResolvedValue({ workflowId: "wf-1" });
1365+
1366+
const result = await wireClient.startWorkflow("transformer", {
1367+
workflowId: "wf-1",
1368+
args: "hello",
1369+
});
1370+
1371+
expect(result).toBeOk();
1372+
expect(mockWorkflow.start).toHaveBeenCalledWith("transformer", {
1373+
workflowId: "wf-1",
1374+
taskQueue: "wire-q",
1375+
args: ["hello"], // original string — not 5 (the parsed length)
1376+
});
1377+
});
1378+
1379+
it("startWorkflow still rejects invalid input before dispatch", async () => {
1380+
const result = await wireClient.startWorkflow("transformer", {
1381+
workflowId: "wf-1",
1382+
// @ts-expect-error testing runtime validation
1383+
args: 42,
1384+
});
1385+
1386+
expect(result).toBeErr();
1387+
if (result.isErr()) {
1388+
expect(result.error).toBeInstanceOf(WorkflowValidationError);
1389+
}
1390+
expect(mockWorkflow.start).not.toHaveBeenCalled();
1391+
});
1392+
1393+
it("executeWorkflow transmits the ORIGINAL args and parses the result exactly once", async () => {
1394+
// The wire carries the producer's original (pre-transform) value; the
1395+
// client applies the output transform on receive.
1396+
mockWorkflow.execute.mockResolvedValue(21);
1397+
1398+
const result = await wireClient.executeWorkflow("transformer", {
1399+
workflowId: "wf-2",
1400+
args: "hello",
1401+
});
1402+
1403+
expect(mockWorkflow.execute).toHaveBeenCalledWith("transformer", {
1404+
workflowId: "wf-2",
1405+
taskQueue: "wire-q",
1406+
args: ["hello"],
1407+
});
1408+
expect(result).toBeOk();
1409+
if (result.isOk()) {
1410+
expect(result.value).toBe(42); // 21 doubled once — not 84
1411+
}
1412+
});
1413+
1414+
it("signalWithStart transmits the ORIGINAL workflow and signal args", async () => {
1415+
mockWorkflow.signalWithStart.mockResolvedValue({
1416+
workflowId: "wf-3",
1417+
signaledRunId: "run-1",
1418+
});
1419+
1420+
const result = await wireClient.signalWithStart("transformer", {
1421+
workflowId: "wf-3",
1422+
args: "hello",
1423+
signalName: "ping",
1424+
signalArgs: "hey",
1425+
});
1426+
1427+
expect(result).toBeOk();
1428+
expect(mockWorkflow.signalWithStart).toHaveBeenCalledWith("transformer", {
1429+
workflowId: "wf-3",
1430+
taskQueue: "wire-q",
1431+
args: ["hello"],
1432+
signal: "ping",
1433+
signalArgs: ["hey"], // original string — not 3
1434+
});
1435+
});
1436+
1437+
it("handle.result() parses what the handle returns (receive side)", async () => {
1438+
const rawHandle = {
1439+
workflowId: "wf-4",
1440+
result: vi.fn().mockResolvedValue(21),
1441+
query: vi.fn(),
1442+
signal: vi.fn(),
1443+
executeUpdate: vi.fn(),
1444+
};
1445+
mockWorkflow.getHandle.mockReturnValue(rawHandle);
1446+
1447+
const handleResult = await wireClient.getHandle("transformer", "wf-4");
1448+
if (!handleResult.isOk()) throw new Error("expected Ok");
1449+
const result = await handleResult.value.result();
1450+
1451+
expect(result).toBeOk();
1452+
if (result.isOk()) {
1453+
expect(result.value).toBe(42);
1454+
}
1455+
});
1456+
1457+
it("handle.signals.* transmits the ORIGINAL signal args", async () => {
1458+
const rawHandle = {
1459+
workflowId: "wf-5",
1460+
result: vi.fn(),
1461+
query: vi.fn(),
1462+
signal: vi.fn().mockResolvedValue(undefined),
1463+
executeUpdate: vi.fn(),
1464+
};
1465+
mockWorkflow.getHandle.mockReturnValue(rawHandle);
1466+
1467+
const handleResult = await wireClient.getHandle("transformer", "wf-5");
1468+
if (!handleResult.isOk()) throw new Error("expected Ok");
1469+
const result = await handleResult.value.signals.ping("hey");
1470+
1471+
expect(result).toBeOk();
1472+
expect(rawHandle.signal).toHaveBeenCalledWith("ping", "hey");
1473+
});
1474+
1475+
it("handle.queries.* transmits the ORIGINAL input and parses the result once", async () => {
1476+
const rawHandle = {
1477+
workflowId: "wf-6",
1478+
result: vi.fn(),
1479+
query: vi.fn().mockResolvedValue(21),
1480+
signal: vi.fn(),
1481+
executeUpdate: vi.fn(),
1482+
};
1483+
mockWorkflow.getHandle.mockReturnValue(rawHandle);
1484+
1485+
const handleResult = await wireClient.getHandle("transformer", "wf-6");
1486+
if (!handleResult.isOk()) throw new Error("expected Ok");
1487+
const result = await handleResult.value.queries.peek("hey");
1488+
1489+
expect(rawHandle.query).toHaveBeenCalledWith("peek", "hey");
1490+
expect(result).toBeOk();
1491+
if (result.isOk()) {
1492+
expect(result.value).toBe(42);
1493+
}
1494+
});
1495+
1496+
it("handle.updates.* transmits the ORIGINAL input and parses the result once", async () => {
1497+
const rawHandle = {
1498+
workflowId: "wf-7",
1499+
result: vi.fn(),
1500+
query: vi.fn(),
1501+
signal: vi.fn(),
1502+
executeUpdate: vi.fn().mockResolvedValue(21),
1503+
};
1504+
mockWorkflow.getHandle.mockReturnValue(rawHandle);
1505+
1506+
const handleResult = await wireClient.getHandle("transformer", "wf-7");
1507+
if (!handleResult.isOk()) throw new Error("expected Ok");
1508+
const result = await handleResult.value.updates.poke("hey");
1509+
1510+
expect(rawHandle.executeUpdate).toHaveBeenCalledWith("poke", { args: ["hey"] });
1511+
expect(result).toBeOk();
1512+
if (result.isOk()) {
1513+
expect(result.value).toBe(42);
1514+
}
1515+
});
1516+
});
1517+
13231518
describe("TypedClient — workflow contract errors", () => {
13241519
const erroredContract = defineContract({
13251520
taskQueue: "test-queue",

0 commit comments

Comments
 (0)