Payload encryption at the promise store boundary in TypeScript

Sensitive workflow inputs and intermediate checkpoints — credit card numbers, SSNs, monetary amounts — land in the promise store as plaintext unless something encrypts them at the boundary. Resonate exposes an Encryptor interface with two methods (encrypt/decrypt) that the SDK's Codec invokes around every payload it persists. example-encryption-ts implements that interface in ~50 lines of AES-256-GCM and plugs it into the Resonate constructor; the payment workflow itself imports no crypto and has no awareness of encryption.

The shape of the solution

const ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

const encryptor = new AesGcmEncryptor(ENCRYPTION_KEY, "demo-key-v1");

// ...

const resonate = new Resonate({ encryptor });
resonate.register(processPayment);

// ... orderId, card, amount, shouldCrash declared above

const result = await resonate.run(
`payment/${orderId}`,
processPayment,
orderId,
card,
amount,
shouldCrash,
);
// from example-encryption-ts/src/index.ts:10-55 (truncated; full file 68 lines)

The workflow itself is a generator that yields step results — no encrypt call, no decrypt call, no key material in scope:

export function* processPayment(
ctx: Context,
orderId: string,
card: string,
amount: number,
shouldCrash: boolean,
): Generator<any, PaymentResult, any> {
const steps: string[] = [];
const start = Date.now();

// Step 1: Validate card (PII passes through encrypted promise store)
steps.push(yield* ctx.run(validateCard, orderId, card));

// Step 2: Fraud check (sensitive financial data, encrypted at rest)
steps.push(yield* ctx.run(fraudCheck, orderId, card, amount));

// Step 3: Charge payment (crash demo — retry without re-processing steps 1-2)
steps.push(yield* ctx.run(chargeCard, orderId, card, amount, shouldCrash));

// Step 4: Send receipt
steps.push(yield* ctx.run(sendReceipt, orderId, amount));

return { orderId, steps, totalMs: Date.now() - start };
}
// from example-encryption-ts/src/workflow.ts:19-42

The Encryptor implementation is the only file that knows about crypto:

export class AesGcmEncryptor implements Encryptor {
private rawKey: Buffer;
private keyId: string;

constructor(keyHex: string, keyId = "default") {
this.rawKey = Buffer.from(keyHex, "hex");
this.keyId = keyId;

if (this.rawKey.length !== 32) {
throw new Error("Encryption key must be 32 bytes (64 hex characters)");
}
}

encrypt(plaintext: Value): Value {
if (!plaintext.data) return plaintext;

const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.rawKey, iv);

const encrypted = Buffer.concat([
cipher.update(plaintext.data, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();

// Pack: [iv (12)] + [ciphertext] + [tag (16)]
const packed = Buffer.concat([iv, encrypted, tag]);

return {
headers: {
...plaintext.headers,
"x-encrypted": "true",
"x-encryption-key-id": this.keyId,
},
data: packed.toString("base64"),
};
}

// decrypt(): symmetric inverse — see src/encryptor.ts:63-90
// ...
}
// from example-encryption-ts/src/encryptor.ts:25-91

The durable primitives in play

Resonate({ encryptor }) — wires a user Encryptor into the SDK's Codec. Default is NoopEncryptor. (example-encryption-ts/src/index.ts:22; SDK at resonate-sdk-ts/src/resonate.ts:111,124,130.)

Encryptor interface — encrypt(plaintext: Value): Value and decrypt(ciphertext: Value): Value. Value is { headers?: Record<string,string>; data?: any }. (resonate-sdk-ts/src/encryptor.ts:3-6; resonate-sdk-ts/src/network/types.ts:5-8.)

Codec.encode / Codec.decode — the SDK's serialization seam. encode JSON-encodes a value, then calls encryptor.encrypt; decode calls encryptor.decrypt, then JSON-decodes. Every value the SDK persists or reads passes through here. (resonate-sdk-ts/src/codec.ts:81-112.)

resonate.register(processPayment) — registers the generator function so it can be replayed by id. (example-encryption-ts/src/index.ts:23.)

resonate.run(id, fn, ...args) — durable invocation keyed by id. Arguments and the final return value are checkpointed through the Codec, so they pass through the encryptor. (example-encryption-ts/src/index.ts:48-55.)

yield* ctx.run(stepFn, ...args) — checkpoints each step's args and return value. After a crash, replays read the cached encrypted value and the codec decrypts it transparently. (example-encryption-ts/src/workflow.ts:30,33,36,39.)

What the SDK handles vs. what you write

You write: an implementation of Encryptor with encrypt(Value) → Value and decrypt(Value) → Value. Whatever your algorithm, key source, header conventions, and rotation strategy require — this class is the only place those concerns live. You pass an instance to new Resonate({ encryptor }) and you're done.

The SDK handles: invoking your encrypt on every payload it persists (function arguments, intermediate ctx.run results, final return value), invoking your decrypt on every payload it reads back, JSON-encoding/decoding around your bytes, and threading the Codec through resonate.run, registered functions, and replay paths. There is no separate codec-server process, no protobuf schema, no out-of-band converter to maintain — the seam is two method calls inside the SDK's Codec class (resonate-sdk-ts/src/codec.ts:94,107).

Failure modes covered

Worker crashes mid-workflow. Steps that completed before the crash are checkpointed as encrypted blobs in the promise store. On retry, the SDK reads them back, the codec calls decrypt, and the workflow resumes without re-executing them. The crash demo (bun start:crash) shows validate and fraud each logging exactly once; charge throws on attempt 1, the SDK retries, and on attempt 2 it succeeds. (workflow.ts:79-88; README output README.md:81-90.)

Plaintext leak in the promise store. Every persisted Value has data replaced by base64([iv][ciphertext][tag]) and headers tagged with x-encrypted: true + x-encryption-key-id. Anyone reading the store sees only the encrypted blob and the key id. (encryptor.ts:50-60.)

Mixed encrypted/plaintext records. decrypt checks for headers["x-encrypted"] === "true" and short-circuits to pass-through for anything missing the marker — so records written before the encryptor was installed are still readable. (encryptor.ts:64-65.)

Ciphertext tampering. AES-GCM authenticates: a flipped bit in the stored blob fails the auth-tag check in decipher.final() and throws. (encryptor.ts:72-78.)

Header pollution on read. After decrypt, the x-encrypted and x-encryption-key-id headers are stripped from the returned Value so downstream consumers see clean metadata. (encryptor.ts:80-89.)

When to reach for this pattern

If your workflow handles PII or financial data and the promise store is not a trusted disclosure boundary.

If you need encryption-at-rest for durable execution payloads without changing any business-logic code.

If your compliance posture (PCI, HIPAA, GDPR) requires that sensitive fields never be persisted in plaintext.

If you want to centralize crypto in one place — algorithm, key id, future rotation — without scattering encrypt/decrypt calls through workflow steps.

If you want to migrate gradually: deploy a NoopEncryptor-style pass-through first, then swap in a real encryptor that ignores untagged records and encrypts new ones.

Not the right pattern if you need field-level encryption with per-field key policy — the seam encrypts whole Value payloads, not subtrees.

Sources

Example repo: https://github.com/resonatehq-examples/example-encryption-ts

TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts (pinned at ^0.10.0)

Encryptor interface and default NoopEncryptor: resonate-sdk-ts/src/encryptor.ts

Codec that calls encrypt/decrypt around every payload: resonate-sdk-ts/src/codec.ts (lines 81–112)

Resonate constructor option wiring: resonate-sdk-ts/src/resonate.ts (lines 96, 111, 124, 130)

Value payload shape: resonate-sdk-ts/src/network/types.ts (lines 5–8)

Resonate documentation: https://docs.resonatehq.io