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-42The 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-91The durable primitives in play
Resonate({ encryptor })— wires a userEncryptorinto the SDK'sCodec. Default isNoopEncryptor. (example-encryption-ts/src/index.ts:22; SDK atresonate-sdk-ts/src/resonate.ts:111,124,130.)Encryptorinterface —encrypt(plaintext: Value): Valueanddecrypt(ciphertext: Value): Value.Valueis{ 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.encodeJSON-encodes a value, then callsencryptor.encrypt;decodecallsencryptor.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 theCodec, 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) showsvalidateandfraudeach logging exactly once;chargethrows on attempt 1, the SDK retries, and on attempt 2 it succeeds. (workflow.ts:79-88; README outputREADME.md:81-90.) - Plaintext leak in the promise store. Every persisted
Valuehasdatareplaced bybase64([iv][ciphertext][tag])and headers tagged withx-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.
decryptchecks forheaders["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, thex-encryptedandx-encryption-key-idheaders are stripped from the returnedValueso 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/decryptcalls 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
Valuepayloads, 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) Encryptorinterface and defaultNoopEncryptor:resonate-sdk-ts/src/encryptor.ts- Codec that calls encrypt/decrypt around every payload:
resonate-sdk-ts/src/codec.ts(lines 81–112) Resonateconstructor option wiring:resonate-sdk-ts/src/resonate.ts(lines 96, 111, 124, 130)Valuepayload shape:resonate-sdk-ts/src/network/types.ts(lines 5–8)- Resonate documentation:
https://docs.resonatehq.io
