@stigmer/runner 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/README.md +12 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/call-llm.js +9 -10
  4. package/dist/activities/call-llm.js.map +1 -1
  5. package/dist/activities/classify-tool-approvals.d.ts +2 -1
  6. package/dist/activities/classify-tool-approvals.js +28 -2
  7. package/dist/activities/classify-tool-approvals.js.map +1 -1
  8. package/dist/activities/discover-mcp-server.d.ts +32 -0
  9. package/dist/activities/discover-mcp-server.js +162 -27
  10. package/dist/activities/discover-mcp-server.js.map +1 -1
  11. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
  12. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  13. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
  15. package/dist/activities/execute-cursor/approval-state.js +7 -1
  16. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  17. package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
  18. package/dist/activities/execute-cursor/attachment-resolver.js +25 -4
  19. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  20. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  21. package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
  22. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  23. package/dist/activities/execute-cursor/env-resolver.js +3 -1
  24. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  25. package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
  26. package/dist/activities/execute-cursor/error-classifier.js +81 -3
  27. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  28. package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
  29. package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
  30. package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
  31. package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
  32. package/dist/activities/execute-cursor/hook-script.js +72 -10
  33. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  34. package/dist/activities/execute-cursor/index.d.ts +5 -1
  35. package/dist/activities/execute-cursor/index.js +59 -58
  36. package/dist/activities/execute-cursor/index.js.map +1 -1
  37. package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
  38. package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
  39. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  40. package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
  41. package/dist/activities/execute-cursor/prompt-builder.js +12 -7
  42. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  43. package/dist/activities/execute-cursor/turn-stream.js +4 -1
  44. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
  46. package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
  47. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  48. package/dist/activities/execute-deep-agent/environment.js +3 -1
  49. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/index.js +15 -0
  51. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  52. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
  53. package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
  54. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
  56. package/dist/activities/execute-deep-agent/setup.js +65 -26
  57. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  58. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
  59. package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
  60. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  61. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
  62. package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
  63. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  64. package/dist/activities/hydrate-workflow-execution.js +3 -1
  65. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  66. package/dist/activities/workflow-event-activities.d.ts +28 -10
  67. package/dist/activities/workflow-event-activities.js +87 -58
  68. package/dist/activities/workflow-event-activities.js.map +1 -1
  69. package/dist/claimcheck/payload-codec.js +21 -1
  70. package/dist/claimcheck/payload-codec.js.map +1 -1
  71. package/dist/client/stigmer-client.d.ts +9 -4
  72. package/dist/client/stigmer-client.js +28 -15
  73. package/dist/client/stigmer-client.js.map +1 -1
  74. package/dist/encryption/config.d.ts +32 -0
  75. package/dist/encryption/config.js +68 -0
  76. package/dist/encryption/config.js.map +1 -0
  77. package/dist/encryption/index.d.ts +3 -0
  78. package/dist/encryption/index.js +3 -0
  79. package/dist/encryption/index.js.map +1 -0
  80. package/dist/encryption/payload-codec.d.ts +41 -0
  81. package/dist/encryption/payload-codec.js +130 -0
  82. package/dist/encryption/payload-codec.js.map +1 -0
  83. package/dist/payload-codecs.d.ts +16 -0
  84. package/dist/payload-codecs.js +38 -0
  85. package/dist/payload-codecs.js.map +1 -0
  86. package/dist/preflight.d.ts +31 -0
  87. package/dist/preflight.js +43 -0
  88. package/dist/preflight.js.map +1 -1
  89. package/dist/runner-manager.js +5 -15
  90. package/dist/runner-manager.js.map +1 -1
  91. package/dist/runner.js +5 -16
  92. package/dist/runner.js.map +1 -1
  93. package/dist/shared/approval-policy.d.ts +9 -3
  94. package/dist/shared/approval-policy.js +15 -6
  95. package/dist/shared/approval-policy.js.map +1 -1
  96. package/dist/shared/attachment-naming.d.ts +53 -0
  97. package/dist/shared/attachment-naming.js +59 -0
  98. package/dist/shared/attachment-naming.js.map +1 -0
  99. package/dist/shared/attachment-vision.d.ts +43 -2
  100. package/dist/shared/attachment-vision.js +72 -6
  101. package/dist/shared/attachment-vision.js.map +1 -1
  102. package/dist/shared/caller-identity.d.ts +23 -2
  103. package/dist/shared/caller-identity.js +36 -5
  104. package/dist/shared/caller-identity.js.map +1 -1
  105. package/dist/shared/channel-attachment.js +1 -0
  106. package/dist/shared/channel-attachment.js.map +1 -1
  107. package/dist/shared/checkpointer/http-saver.d.ts +42 -1
  108. package/dist/shared/checkpointer/http-saver.js +96 -8
  109. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  110. package/dist/shared/conversation-attachment.js +1 -0
  111. package/dist/shared/conversation-attachment.js.map +1 -1
  112. package/dist/shared/datastore-attachment.d.ts +50 -7
  113. package/dist/shared/datastore-attachment.js +93 -11
  114. package/dist/shared/datastore-attachment.js.map +1 -1
  115. package/dist/shared/http-retry.d.ts +43 -0
  116. package/dist/shared/http-retry.js +50 -0
  117. package/dist/shared/http-retry.js.map +1 -0
  118. package/dist/shared/llm-backend.d.ts +275 -0
  119. package/dist/shared/llm-backend.js +425 -0
  120. package/dist/shared/llm-backend.js.map +1 -0
  121. package/dist/shared/llm-proxy.d.ts +8 -0
  122. package/dist/shared/llm-proxy.js +15 -0
  123. package/dist/shared/llm-proxy.js.map +1 -1
  124. package/dist/shared/mcp-enabled-tools.d.ts +57 -0
  125. package/dist/shared/mcp-enabled-tools.js +86 -0
  126. package/dist/shared/mcp-enabled-tools.js.map +1 -0
  127. package/dist/shared/mcp-manager.d.ts +17 -2
  128. package/dist/shared/mcp-manager.js +37 -25
  129. package/dist/shared/mcp-manager.js.map +1 -1
  130. package/dist/shared/mcp-resolver.d.ts +39 -2
  131. package/dist/shared/mcp-resolver.js +38 -2
  132. package/dist/shared/mcp-resolver.js.map +1 -1
  133. package/dist/shared/model-client.d.ts +12 -5
  134. package/dist/shared/model-client.js +138 -18
  135. package/dist/shared/model-client.js.map +1 -1
  136. package/dist/shared/model-error.js +198 -5
  137. package/dist/shared/model-error.js.map +1 -1
  138. package/dist/shared/model-registry.d.ts +20 -2
  139. package/dist/shared/model-registry.js +37 -2
  140. package/dist/shared/model-registry.js.map +1 -1
  141. package/dist/shared/plan-mode-permissions.d.ts +26 -0
  142. package/dist/shared/plan-mode-permissions.js +28 -0
  143. package/dist/shared/plan-mode-permissions.js.map +1 -0
  144. package/dist/worker.d.ts +2 -1
  145. package/dist/worker.js +2 -4
  146. package/dist/worker.js.map +1 -1
  147. package/dist/workflow-engine/types.d.ts +18 -0
  148. package/dist/workflow-engine/types.js.map +1 -1
  149. package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
  150. package/dist/workflows/call-agent-orchestrator.js +1 -0
  151. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  152. package/dist/workflows/connect-mcp-server.js +7 -0
  153. package/dist/workflows/connect-mcp-server.js.map +1 -1
  154. package/dist/workflows/engine-core.js +23 -2
  155. package/dist/workflows/engine-core.js.map +1 -1
  156. package/dist/workflows/execute-from-execution.d.ts +1 -1
  157. package/dist/workflows/execute-from-execution.js +11 -1
  158. package/dist/workflows/execute-from-execution.js.map +1 -1
  159. package/package.json +8 -2
  160. package/src/__tests__/claimcheck-codec.test.ts +36 -0
  161. package/src/__tests__/encryption-codec.test.ts +234 -0
  162. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  163. package/src/__tests__/history-encryption-e2e.test.ts +243 -0
  164. package/src/__tests__/preflight.test.ts +50 -2
  165. package/src/activities/__tests__/call-llm.test.ts +75 -0
  166. package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
  167. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
  168. package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
  169. package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
  170. package/src/activities/call-llm.ts +9 -16
  171. package/src/activities/classify-tool-approvals.ts +34 -4
  172. package/src/activities/discover-mcp-server.ts +190 -32
  173. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
  174. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
  175. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +92 -0
  176. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
  177. package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
  178. package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
  179. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
  180. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
  181. package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
  182. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
  183. package/src/activities/execute-cursor/approval-state.ts +30 -1
  184. package/src/activities/execute-cursor/attachment-resolver.ts +38 -3
  185. package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
  186. package/src/activities/execute-cursor/env-resolver.ts +3 -1
  187. package/src/activities/execute-cursor/error-classifier.ts +91 -4
  188. package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
  189. package/src/activities/execute-cursor/hook-script.ts +74 -10
  190. package/src/activities/execute-cursor/index.ts +63 -72
  191. package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
  192. package/src/activities/execute-cursor/prompt-builder.ts +34 -9
  193. package/src/activities/execute-cursor/turn-stream.ts +5 -2
  194. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +132 -8
  195. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
  196. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
  197. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  198. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  199. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
  200. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  201. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
  202. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
  203. package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
  204. package/src/activities/execute-deep-agent/environment.ts +3 -1
  205. package/src/activities/execute-deep-agent/index.ts +20 -0
  206. package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
  207. package/src/activities/execute-deep-agent/setup.ts +86 -31
  208. package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
  209. package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
  210. package/src/activities/hydrate-workflow-execution.ts +3 -1
  211. package/src/activities/workflow-event-activities.ts +96 -69
  212. package/src/claimcheck/payload-codec.ts +33 -1
  213. package/src/client/__tests__/stigmer-client.test.ts +8 -8
  214. package/src/client/stigmer-client.ts +32 -18
  215. package/src/encryption/config.ts +91 -0
  216. package/src/encryption/index.ts +3 -0
  217. package/src/encryption/payload-codec.ts +152 -0
  218. package/src/payload-codecs.ts +56 -0
  219. package/src/preflight.ts +45 -0
  220. package/src/runner-manager.ts +6 -24
  221. package/src/runner.ts +6 -25
  222. package/src/shared/__tests__/approval-policy.test.ts +82 -39
  223. package/src/shared/__tests__/attachment-naming.test.ts +159 -0
  224. package/src/shared/__tests__/attachment-vision.test.ts +97 -0
  225. package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
  226. package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
  227. package/src/shared/__tests__/caller-identity.test.ts +25 -0
  228. package/src/shared/__tests__/channel-attachment.test.ts +1 -1
  229. package/src/shared/__tests__/connect-backfill.test.ts +1 -0
  230. package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
  231. package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
  232. package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
  233. package/src/shared/__tests__/foundry-seam.test.ts +482 -0
  234. package/src/shared/__tests__/http-retry.test.ts +67 -0
  235. package/src/shared/__tests__/llm-backend.test.ts +616 -0
  236. package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
  237. package/src/shared/__tests__/mcp-manager.test.ts +170 -2
  238. package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
  239. package/src/shared/__tests__/model-client.test.ts +154 -0
  240. package/src/shared/__tests__/model-error.test.ts +289 -1
  241. package/src/shared/__tests__/model-registry.test.ts +71 -0
  242. package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
  243. package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
  244. package/src/shared/__tests__/vertex-seam.test.ts +295 -0
  245. package/src/shared/approval-policy.ts +14 -7
  246. package/src/shared/attachment-naming.ts +78 -0
  247. package/src/shared/attachment-vision.ts +92 -9
  248. package/src/shared/caller-identity.ts +40 -5
  249. package/src/shared/channel-attachment.ts +1 -0
  250. package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
  251. package/src/shared/checkpointer/http-saver.ts +117 -9
  252. package/src/shared/conversation-attachment.ts +1 -0
  253. package/src/shared/datastore-attachment.ts +106 -11
  254. package/src/shared/http-retry.ts +50 -0
  255. package/src/shared/llm-backend.ts +544 -0
  256. package/src/shared/llm-proxy.ts +15 -0
  257. package/src/shared/mcp-enabled-tools.ts +105 -0
  258. package/src/shared/mcp-manager.ts +43 -26
  259. package/src/shared/mcp-resolver.ts +73 -2
  260. package/src/shared/model-client.ts +161 -19
  261. package/src/shared/model-error.ts +222 -4
  262. package/src/shared/model-registry.ts +50 -2
  263. package/src/shared/plan-mode-permissions.ts +30 -0
  264. package/src/worker.ts +4 -5
  265. package/src/workflow-engine/types.ts +18 -0
  266. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
  267. package/src/workflows/call-agent-orchestrator.ts +10 -0
  268. package/src/workflows/connect-mcp-server.ts +7 -0
  269. package/src/workflows/engine-core.ts +23 -2
  270. package/src/workflows/execute-from-execution.ts +12 -2
@@ -3,8 +3,12 @@
3
3
  *
4
4
  * Converts plain event descriptors (safe for the Temporal deterministic
5
5
  * sandbox) into WorkflowExecutionEvent proto objects and sends them
6
- * alongside a status update via gRPC. Best-effort failures are logged
7
- * but do not fail the activity.
6
+ * alongside a status update via gRPC. Failures propagate so the local
7
+ * activity's retry policy fires safe because sequence numbers are
8
+ * workflow-assigned (stable across attempts) and the store skips
9
+ * already-persisted sequences, making retries idempotent. The workflow's
10
+ * emit wrappers catch after retries are exhausted; event emission never
11
+ * fails a run.
8
12
  *
9
13
  * Registered as proxyLocalActivities in engine-core.ts so the workflow
10
14
  * sandbox can emit events at task boundaries and approval gates.
@@ -140,6 +144,17 @@ function buildClient(): StigmerClient {
140
144
  });
141
145
  }
142
146
 
147
+ /**
148
+ * LEGACY sequence assignment — only for descriptors that arrive without a
149
+ * workflow-assigned `sequenceNumber`, i.e. replays of histories recorded
150
+ * before the "workflow-assigned-event-sequences" patch (see
151
+ * engine-core.ts). This counter is process-global: it is shared across
152
+ * every execution on the worker and resets on worker restart, which is
153
+ * the root cause of oss#308's silent event loss. Deliberately NOT fixed —
154
+ * pre-patch executions keep their old (flawed) behavior for the
155
+ * migration window. Delete together with the patch gate once pre-patch
156
+ * executions have drained.
157
+ */
143
158
  let sequenceCounter = 0;
144
159
 
145
160
  function nextSequence(): bigint {
@@ -148,33 +163,44 @@ function nextSequence(): bigint {
148
163
  }
149
164
 
150
165
  /**
151
- * Initialize the event sequence counter from the persisted high-water mark.
166
+ * Return the persisted event-log high-water mark for the execution, and
167
+ * seed the legacy process-global counter from it.
152
168
  *
153
- * On first run (no prior events), the counter stays at 0 and the first
154
- * event gets sequence_number = 1 identical to the original behavior.
169
+ * The workflow seeds its own sequence counter from the returned value
170
+ * (patched path); the legacy global seed remains for pre-patch replays,
171
+ * whose emit path still assigns sequences here in the activity.
155
172
  *
156
- * On recovery (events already persisted from a failed run), the counter
157
- * is set to the highest persisted sequence_number so that new events
158
- * continue from N+1, avoiding duplicate-key collisions in storage.
173
+ * On first run (no prior events) returns 0, so the first event gets
174
+ * sequence_number = 1. On recovery (events already persisted from a
175
+ * failed run) returns the highest persisted sequence_number, so new
176
+ * events continue from N+1.
159
177
  *
160
178
  * When executionId is empty (direct executeServerlessWorkflow without a
161
- * persisted execution), the counter resets to 0 as a safe fallback.
179
+ * persisted execution), returns 0 as a safe fallback.
180
+ *
181
+ * Returns a plain number — the value crosses back into the workflow
182
+ * through Temporal's JSON payload converter, which cannot carry BigInt.
162
183
  */
163
- export async function initSequenceFromEventLog(executionId: string): Promise<void> {
184
+ export async function initSequenceFromEventLog(executionId: string): Promise<number> {
164
185
  if (!executionId) {
165
186
  sequenceCounter = 0;
166
- return;
187
+ return 0;
167
188
  }
168
189
  const client = buildClient();
169
190
  const highWaterMark = await client.getEventLogHighWaterMark(executionId);
170
191
  sequenceCounter = Number(highWaterMark);
192
+ return sequenceCounter;
171
193
  }
172
194
 
173
195
  /** @internal Exported for unit testing only. */
174
196
  export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEvent {
175
197
  const base = create(WorkflowExecutionEventSchema, {
176
198
  eventId: crypto.randomUUID(),
177
- sequenceNumber: nextSequence(),
199
+ // Workflow-assigned sequence when present (stable across activity
200
+ // retries); legacy activity-side assignment only for pre-patch replays.
201
+ sequenceNumber: desc.sequenceNumber !== undefined
202
+ ? BigInt(desc.sequenceNumber)
203
+ : nextSequence(),
178
204
  occurredAt: desc.occurredAt,
179
205
  taskName: desc.taskName ?? "",
180
206
  });
@@ -382,6 +408,14 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
382
408
  return base;
383
409
  }
384
410
 
411
+ /**
412
+ * Emit workflow events (with the accompanying status snapshot) to the
413
+ * server. Errors propagate so the local activity retry policy fires;
414
+ * retries are idempotent because workflow-assigned sequence numbers are
415
+ * stable across attempts and the store skips already-persisted ones. The
416
+ * workflow-side emit wrappers absorb the failure after retries are
417
+ * exhausted, so a broken timeline write never fails the run.
418
+ */
385
419
  export async function emitWorkflowEvents(
386
420
  executionId: string,
387
421
  events: WorkflowEventDescriptor[],
@@ -389,64 +423,57 @@ export async function emitWorkflowEvents(
389
423
  ): Promise<void> {
390
424
  if (!executionId || events.length === 0) return;
391
425
 
392
- try {
393
- const client = buildClient();
394
- const protoEvents = events.map(toProtoEvent);
395
-
396
- const protoTasks = (taskStatuses ?? []).map(ts =>
397
- create(WorkflowTaskSchema, {
398
- taskId: ts.taskId ?? "",
399
- taskName: ts.taskName,
400
- taskType: TASK_KIND_TO_TYPE_MAP[ts.taskKind] ?? WorkflowTaskType.WORKFLOW_TASK_TYPE_UNSPECIFIED,
401
- status: TASK_STATUS_MAP[ts.status],
402
- startedAt: ts.startedAt ?? "",
403
- completedAt: ts.completedAt ?? "",
404
- error: ts.error ?? "",
405
- input: toJsonObject(ts.input),
406
- output: toJsonObject(ts.output),
407
- metadata: toJsonObject(ts.metadata),
408
- costMicros: BigInt(ts.costMicros ?? 0),
409
- inputTokens: BigInt(ts.inputTokens ?? 0),
410
- outputTokens: BigInt(ts.outputTokens ?? 0),
411
- uiHint: ts.uiHint ?? "",
412
- }),
413
- );
414
-
415
- const startedEvent = events.find(e => e.type === "execution_started");
416
- const completedEvent = events.find(e => e.type === "execution_completed");
417
- const failedEvent = events.find(e => e.type === "execution_failed");
418
-
419
- const statusFields: Record<string, unknown> = { tasks: protoTasks };
420
-
421
- if (startedEvent) {
422
- statusFields.phase = ExecutionPhase.EXECUTION_IN_PROGRESS;
423
- statusFields.startedAt = startedEvent.occurredAt;
424
- }
425
- if (completedEvent && completedEvent.type === "execution_completed") {
426
- statusFields.phase = ExecutionPhase.EXECUTION_COMPLETED;
427
- statusFields.completedAt = completedEvent.occurredAt;
428
- statusFields.totalCostMicros = BigInt(completedEvent.totalCostMicros);
429
- statusFields.totalInputTokens = BigInt(completedEvent.totalInputTokens ?? 0);
430
- statusFields.totalOutputTokens = BigInt(completedEvent.totalOutputTokens ?? 0);
431
- }
432
- if (failedEvent && failedEvent.type === "execution_failed") {
433
- statusFields.phase = ExecutionPhase.EXECUTION_FAILED;
434
- statusFields.completedAt = failedEvent.occurredAt;
435
- statusFields.error = failedEvent.error;
436
- }
437
-
438
- const input = create(WorkflowExecutionUpdateStatusInputSchema, {
439
- executionId,
440
- status: create(WorkflowExecutionStatusSchema, statusFields as Parameters<typeof create>[1]),
441
- events: protoEvents,
442
- });
443
- await client.workflowExecutionCommand.updateStatus(input);
444
- } catch (err) {
445
- console.error(
446
- `Failed to emit ${events.length} workflow event(s) for ${executionId}:`,
447
- err,
448
- );
426
+ const client = buildClient();
427
+ const protoEvents = events.map(toProtoEvent);
428
+
429
+ const protoTasks = (taskStatuses ?? []).map(ts =>
430
+ create(WorkflowTaskSchema, {
431
+ taskId: ts.taskId ?? "",
432
+ taskName: ts.taskName,
433
+ taskType: TASK_KIND_TO_TYPE_MAP[ts.taskKind] ?? WorkflowTaskType.WORKFLOW_TASK_TYPE_UNSPECIFIED,
434
+ status: TASK_STATUS_MAP[ts.status],
435
+ startedAt: ts.startedAt ?? "",
436
+ completedAt: ts.completedAt ?? "",
437
+ error: ts.error ?? "",
438
+ input: toJsonObject(ts.input),
439
+ output: toJsonObject(ts.output),
440
+ metadata: toJsonObject(ts.metadata),
441
+ costMicros: BigInt(ts.costMicros ?? 0),
442
+ inputTokens: BigInt(ts.inputTokens ?? 0),
443
+ outputTokens: BigInt(ts.outputTokens ?? 0),
444
+ uiHint: ts.uiHint ?? "",
445
+ }),
446
+ );
447
+
448
+ const startedEvent = events.find(e => e.type === "execution_started");
449
+ const completedEvent = events.find(e => e.type === "execution_completed");
450
+ const failedEvent = events.find(e => e.type === "execution_failed");
451
+
452
+ const statusFields: Record<string, unknown> = { tasks: protoTasks };
453
+
454
+ if (startedEvent) {
455
+ statusFields.phase = ExecutionPhase.EXECUTION_IN_PROGRESS;
456
+ statusFields.startedAt = startedEvent.occurredAt;
457
+ }
458
+ if (completedEvent && completedEvent.type === "execution_completed") {
459
+ statusFields.phase = ExecutionPhase.EXECUTION_COMPLETED;
460
+ statusFields.completedAt = completedEvent.occurredAt;
461
+ statusFields.totalCostMicros = BigInt(completedEvent.totalCostMicros);
462
+ statusFields.totalInputTokens = BigInt(completedEvent.totalInputTokens ?? 0);
463
+ statusFields.totalOutputTokens = BigInt(completedEvent.totalOutputTokens ?? 0);
464
+ }
465
+ if (failedEvent && failedEvent.type === "execution_failed") {
466
+ statusFields.phase = ExecutionPhase.EXECUTION_FAILED;
467
+ statusFields.completedAt = failedEvent.occurredAt;
468
+ statusFields.error = failedEvent.error;
449
469
  }
470
+
471
+ const input = create(WorkflowExecutionUpdateStatusInputSchema, {
472
+ executionId,
473
+ status: create(WorkflowExecutionStatusSchema, statusFields as Parameters<typeof create>[1]),
474
+ events: protoEvents,
475
+ });
476
+ await client.workflowExecutionCommand.updateStatus(input);
450
477
  }
451
478
 
452
479
  const PROTO_STATUS_TO_STRING: Record<number, string> = {
@@ -22,6 +22,14 @@ interface ClaimcheckMarker {
22
22
  key: string;
23
23
  size: number;
24
24
  compressed: boolean;
25
+ /**
26
+ * The relocated payload's original metadata, base64-encoded per value.
27
+ * Without it the restored payload would carry the marker's own
28
+ * "binary/claimcheck" encoding and no payload converter could interpret
29
+ * it. Absent on markers written before this field existed; those decode
30
+ * with the legacy (metadata-less) behavior.
31
+ */
32
+ metadata?: Record<string, string>;
25
33
  }
26
34
 
27
35
  export class ClaimcheckPayloadCodec implements PayloadCodec {
@@ -63,6 +71,7 @@ export class ClaimcheckPayloadCodec implements PayloadCodec {
63
71
  key,
64
72
  size: data.length,
65
73
  compressed,
74
+ metadata: serializeMetadata(payload.metadata),
66
75
  };
67
76
 
68
77
  return {
@@ -94,7 +103,9 @@ export class ClaimcheckPayloadCodec implements PayloadCodec {
94
103
  const dataBuf = marker.compressed ? decompress(rawBuf) : rawBuf;
95
104
 
96
105
  return {
97
- metadata: payload.metadata,
106
+ metadata: marker.metadata
107
+ ? deserializeMetadata(marker.metadata)
108
+ : payload.metadata,
98
109
  data: dataBuf,
99
110
  };
100
111
  }
@@ -105,3 +116,24 @@ export class ClaimcheckPayloadCodec implements PayloadCodec {
105
116
  return Buffer.from(encoding).toString("utf-8") === MARKER_ENCODING_VALUE;
106
117
  }
107
118
  }
119
+
120
+ function serializeMetadata(
121
+ metadata: Payload["metadata"],
122
+ ): Record<string, string> | undefined {
123
+ if (!metadata) return undefined;
124
+ const out: Record<string, string> = {};
125
+ for (const [key, value] of Object.entries(metadata)) {
126
+ if (value) out[key] = Buffer.from(value).toString("base64");
127
+ }
128
+ return out;
129
+ }
130
+
131
+ function deserializeMetadata(
132
+ metadata: Record<string, string>,
133
+ ): Record<string, Uint8Array> {
134
+ const out: Record<string, Uint8Array> = {};
135
+ for (const [key, value] of Object.entries(metadata)) {
136
+ out[key] = Buffer.from(value, "base64");
137
+ }
138
+ return out;
139
+ }
@@ -320,22 +320,22 @@ describe("StigmerClient", () => {
320
320
  expect(spy).not.toHaveBeenCalled();
321
321
  });
322
322
 
323
- it("falls back (undefined) when the server mints no token", async () => {
323
+ it("throws when the server mints no token — the bootstrap credential no longer decrypts (stigmer-cloud#218)", async () => {
324
324
  const client = clientWithRunnerCredential(fakeTokenOfType("embedded_runner"));
325
325
  vi.spyOn(client, "getRunnerScopedToken").mockResolvedValue(undefined);
326
326
 
327
- const token = await client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" });
328
-
329
- expect(token).toBeUndefined();
327
+ await expect(
328
+ client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" }),
329
+ ).rejects.toThrow(/minted no scoped runner token for agent execution aex_1/);
330
330
  });
331
331
 
332
- it("falls back (undefined) when the exchange fails, instead of failing the execution", async () => {
332
+ it("throws when the exchange fails, naming the scope and preserving the cause", async () => {
333
333
  const client = clientWithRunnerCredential(fakeTokenOfType("embedded_runner"));
334
334
  vi.spyOn(client, "getRunnerScopedToken").mockRejectedValue(new Error("boom"));
335
335
 
336
- const token = await client.acquireScopedRunnerToken({ agentExecutionId: "aex_1" });
337
-
338
- expect(token).toBeUndefined();
336
+ await expect(
337
+ client.acquireScopedRunnerToken({ workflowExecutionId: "wfx_1" }),
338
+ ).rejects.toThrow(/exchange failed for workflow execution wfx_1: boom/);
339
339
  });
340
340
  });
341
341
 
@@ -360,10 +360,15 @@ export class StigmerClient {
360
360
  * OSS/local runner holds no runner-class credential at all (skip — the
361
361
  * server neither mints nor redacts).
362
362
  *
363
- * Failure falls back rather than failing the execution: returning undefined
364
- * makes the read authenticate with the bootstrap credential, which remains
365
- * decrypt-eligible until issue #156 item 3 removes it at that point this
366
- * fallback stops yielding secrets and executions surface the warning below.
363
+ * A failed exchange is a hard error, not a fallback: since the #156 item-3
364
+ * flip (stigmer-cloud#218) the bootstrap credential no longer decrypts, so
365
+ * a read that "fell back" would silently receive redacted placeholders and
366
+ * the execution would run against junk secret values strictly worse than
367
+ * failing here with the real reason. The secret-delivery call sites let
368
+ * this error fail the activity; opportunistic consumers that can genuinely
369
+ * proceed without a scoped token (attachment credential, channel
370
+ * discovery) catch it at the call site, where their degrade-to-empty
371
+ * contract lives.
367
372
  */
368
373
  async acquireScopedRunnerToken(
369
374
  scope: RunnerScopedTokenScope,
@@ -371,24 +376,33 @@ export class StigmerClient {
371
376
  if (!isEmbeddedRunnerToken(this.runnerTokenRef?.current)) {
372
377
  return undefined;
373
378
  }
379
+ const scopeDescription =
380
+ "agentExecutionId" in scope
381
+ ? `agent execution ${scope.agentExecutionId}`
382
+ : "workflowExecutionId" in scope
383
+ ? `workflow execution ${scope.workflowExecutionId}`
384
+ : JSON.stringify(scope);
385
+ let scoped: RunnerScopedToken | undefined;
374
386
  try {
375
- const scoped = await this.getRunnerScopedToken(scope);
376
- if (!scoped) {
377
- console.warn(
378
- "[stigmer-client] Server minted no scoped runner token; " +
379
- "falling back to the bootstrap credential for the ExecutionContext read",
380
- );
381
- return undefined;
382
- }
383
- return scoped.token;
387
+ scoped = await this.getRunnerScopedToken(scope);
384
388
  } catch (err) {
385
- console.warn(
386
- "[stigmer-client] Scoped runner token exchange failed; " +
387
- "falling back to the bootstrap credential for the ExecutionContext read: " +
388
- `${err instanceof Error ? err.message : err}`,
389
+ throw new Error(
390
+ `Scoped runner token exchange failed for ${scopeDescription}: ` +
391
+ `${err instanceof Error ? err.message : String(err)}. ` +
392
+ "The bootstrap credential cannot read ExecutionContext secrets " +
393
+ "(stigmer-cloud#218), so this runner cannot serve the execution " +
394
+ "until the exchange succeeds.",
395
+ );
396
+ }
397
+ if (!scoped) {
398
+ throw new Error(
399
+ `Server minted no scoped runner token for ${scopeDescription}. ` +
400
+ "The bootstrap credential cannot read ExecutionContext secrets " +
401
+ "(stigmer-cloud#218), so this runner cannot serve the execution " +
402
+ "until the control plane mints scoped tokens.",
389
403
  );
390
- return undefined;
391
404
  }
405
+ return scoped.token;
392
406
  }
393
407
 
394
408
  /**
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Payload-encryption configuration (stigmer-cloud#227).
3
+ *
4
+ * Encryption is enabled iff STIGMER_PAYLOAD_ENCRYPTION_KEY is present —
5
+ * the same enabled-iff-configured pattern as the claim-check codec. A
6
+ * malformed key fails the boot rather than silently running plaintext:
7
+ * an operator who set the key intended history to be encrypted.
8
+ *
9
+ * Key rotation: payloads carry the id of the key that encrypted them.
10
+ * During a rotation window the previous key stays readable via
11
+ * STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY(_ID) while new payloads are
12
+ * written under the primary key.
13
+ */
14
+
15
+ export interface EncryptionKey {
16
+ readonly keyId: string;
17
+ /** 32-byte AES-256 key. */
18
+ readonly key: Buffer;
19
+ }
20
+
21
+ export interface PayloadEncryptionConfig {
22
+ /** Key used to encrypt outgoing payloads (and decrypt its own). */
23
+ readonly primary: EncryptionKey;
24
+ /** Decrypt-only key accepted during rotation windows. */
25
+ readonly secondary?: EncryptionKey;
26
+ }
27
+
28
+ const KEY_ENV = "STIGMER_PAYLOAD_ENCRYPTION_KEY";
29
+ const KEY_ID_ENV = "STIGMER_PAYLOAD_ENCRYPTION_KEY_ID";
30
+ const SECONDARY_KEY_ENV = "STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY";
31
+ const SECONDARY_KEY_ID_ENV = "STIGMER_PAYLOAD_ENCRYPTION_SECONDARY_KEY_ID";
32
+
33
+ const AES_256_KEY_BYTES = 32;
34
+
35
+ /**
36
+ * Returns the encryption config, or undefined when encryption is not
37
+ * configured (the codec is then simply not installed).
38
+ *
39
+ * @throws when a key is present but malformed, or a key id is missing —
40
+ * key misconfiguration must stop the boot, not degrade to plaintext.
41
+ */
42
+ export function loadPayloadEncryptionConfig(): PayloadEncryptionConfig | undefined {
43
+ const rawKey = process.env[KEY_ENV];
44
+ if (!rawKey) {
45
+ return undefined;
46
+ }
47
+
48
+ const primary: EncryptionKey = {
49
+ keyId: requireKeyId(KEY_ID_ENV),
50
+ key: parseKey(rawKey, KEY_ENV),
51
+ };
52
+
53
+ const rawSecondary = process.env[SECONDARY_KEY_ENV];
54
+ const secondary: EncryptionKey | undefined = rawSecondary
55
+ ? {
56
+ keyId: requireKeyId(SECONDARY_KEY_ID_ENV),
57
+ key: parseKey(rawSecondary, SECONDARY_KEY_ENV),
58
+ }
59
+ : undefined;
60
+
61
+ return { primary, secondary };
62
+ }
63
+
64
+ function requireKeyId(envName: string): string {
65
+ const keyId = process.env[envName];
66
+ // An explicit id is required (no default): during rotation two keys
67
+ // coexist, and payloads must name which one encrypted them.
68
+ if (!keyId) {
69
+ throw new Error(
70
+ `Payload encryption misconfigured: ${envName} is required when the ` +
71
+ `corresponding key is set`,
72
+ );
73
+ }
74
+ return keyId;
75
+ }
76
+
77
+ function parseKey(rawBase64: string, envName: string): Buffer {
78
+ let key: Buffer;
79
+ try {
80
+ key = Buffer.from(rawBase64, "base64");
81
+ } catch {
82
+ throw new Error(`Payload encryption misconfigured: ${envName} is not valid base64`);
83
+ }
84
+ if (key.length !== AES_256_KEY_BYTES) {
85
+ throw new Error(
86
+ `Payload encryption misconfigured: ${envName} must decode to ` +
87
+ `${AES_256_KEY_BYTES} bytes (AES-256), got ${key.length}`,
88
+ );
89
+ }
90
+ return key;
91
+ }
@@ -0,0 +1,3 @@
1
+ export { EncryptionPayloadCodec } from "./payload-codec.js";
2
+ export { loadPayloadEncryptionConfig } from "./config.js";
3
+ export type { PayloadEncryptionConfig, EncryptionKey } from "./config.js";
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Temporal PayloadCodec that encrypts payloads at rest in workflow
3
+ * history (stigmer-cloud#227).
4
+ *
5
+ * Why: the workflow engine runs inside the Temporal deterministic
6
+ * sandbox, so decrypted execution-context values cross the history
7
+ * boundary in many places — the hydrate activity result, the runtime
8
+ * env passed as an input to every per-task activity, and expression
9
+ * results recorded as local-activity markers. Encrypting at the payload
10
+ * codec layer closes the entire class with one mechanism instead of
11
+ * chasing each crossing.
12
+ *
13
+ * Envelope (cross-SDK contract — the Java decode-only codec in
14
+ * stigmer-cloud's temporal-starter must match it byte-for-byte, pinned
15
+ * by the conformance fixture in __tests__/fixtures/):
16
+ *
17
+ * metadata: encoding = "binary/encrypted"
18
+ * encryption-key-id = <key id that encrypted this payload>
19
+ * data: iv (12 bytes) ‖ AES-256-GCM(ciphertext ‖ tag (16 bytes))
20
+ *
21
+ * The plaintext is the serialized ORIGINAL Payload proto (metadata AND
22
+ * data), so decode restores the payload exactly — including its
23
+ * original encoding — with no side channel.
24
+ *
25
+ * Decode passes through payloads it did not encode. This is what keeps
26
+ * plaintext signals from the Java/Go orchestrators and pre-rollout
27
+ * in-flight histories working with zero migration. Everything else
28
+ * fails closed: unknown key id, missing key id, and ciphertext tampering
29
+ * all throw rather than surfacing bogus payloads.
30
+ */
31
+
32
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
33
+ import type { Payload, PayloadCodec } from "@temporalio/common";
34
+ // Default-import + destructure, NOT `import { temporal } from …`:
35
+ // @temporalio/proto is CommonJS and its `temporal` export defeats Node's
36
+ // cjs-module-lexer named-export detection, so the named form loads under
37
+ // tsx/vitest (their interop is looser) but crashes plain `node dist/main.js`
38
+ // at startup with "Named export 'temporal' not found" (stigmer/stigmer#399
39
+ // boot regression). Pinned by scripts/verify-dist-boot.mjs in CI.
40
+ import proto from "@temporalio/proto";
41
+ import type { EncryptionKey, PayloadEncryptionConfig } from "./config.js";
42
+
43
+ const { temporal } = proto;
44
+
45
+ const ENCODING_METADATA_KEY = "encoding";
46
+ const ENCRYPTED_ENCODING_VALUE = "binary/encrypted";
47
+ const KEY_ID_METADATA_KEY = "encryption-key-id";
48
+
49
+ /** AES-GCM parameters shared with the Java implementation. */
50
+ const IV_BYTES = 12;
51
+ const AUTH_TAG_BYTES = 16;
52
+
53
+ export class EncryptionPayloadCodec implements PayloadCodec {
54
+ private readonly decryptKeysById: Map<string, Buffer>;
55
+
56
+ constructor(private readonly config: PayloadEncryptionConfig) {
57
+ this.decryptKeysById = new Map([[config.primary.keyId, config.primary.key]]);
58
+ if (config.secondary) {
59
+ this.decryptKeysById.set(config.secondary.keyId, config.secondary.key);
60
+ }
61
+ }
62
+
63
+ async encode(payloads: Payload[]): Promise<Payload[]> {
64
+ return payloads.map((p) => this.encodePayload(p));
65
+ }
66
+
67
+ async decode(payloads: Payload[]): Promise<Payload[]> {
68
+ return payloads.map((p) => this.decodePayload(p));
69
+ }
70
+
71
+ private encodePayload(payload: Payload): Payload {
72
+ // Data-less payloads (binary/null from void results) stay as-is:
73
+ // there is nothing to protect, and the cross-language parents that
74
+ // await our workflows as void must be able to read them without a key.
75
+ if (!payload.data || payload.data.length === 0) {
76
+ return payload;
77
+ }
78
+
79
+ const plaintext = temporal.api.common.v1.Payload.encode(payload).finish();
80
+ return {
81
+ metadata: {
82
+ [ENCODING_METADATA_KEY]: Buffer.from(ENCRYPTED_ENCODING_VALUE),
83
+ [KEY_ID_METADATA_KEY]: Buffer.from(this.config.primary.keyId),
84
+ },
85
+ data: encrypt(plaintext, this.config.primary),
86
+ };
87
+ }
88
+
89
+ private decodePayload(payload: Payload): Payload {
90
+ if (!isEncryptedPayload(payload)) {
91
+ return payload;
92
+ }
93
+
94
+ const keyIdBytes = payload.metadata?.[KEY_ID_METADATA_KEY];
95
+ if (!keyIdBytes) {
96
+ throw new Error(
97
+ "Encrypted payload is missing its encryption-key-id metadata — refusing to decode",
98
+ );
99
+ }
100
+ const keyId = Buffer.from(keyIdBytes).toString("utf-8");
101
+ const key = this.decryptKeysById.get(keyId);
102
+ if (!key) {
103
+ throw new Error(
104
+ `Encrypted payload uses unknown key id '${keyId}' — configure it as the ` +
105
+ `primary or secondary payload encryption key (rotation window?)`,
106
+ );
107
+ }
108
+
109
+ const plaintext = decrypt(payload.data!, key, keyId);
110
+ return temporal.api.common.v1.Payload.decode(plaintext);
111
+ }
112
+ }
113
+
114
+ function isEncryptedPayload(payload: Payload): boolean {
115
+ const encoding = payload.metadata?.[ENCODING_METADATA_KEY];
116
+ if (!encoding) return false;
117
+ return Buffer.from(encoding).toString("utf-8") === ENCRYPTED_ENCODING_VALUE;
118
+ }
119
+
120
+ function encrypt(plaintext: Uint8Array, key: EncryptionKey): Buffer {
121
+ const iv = randomBytes(IV_BYTES);
122
+ const cipher = createCipheriv("aes-256-gcm", key.key, iv);
123
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
124
+ // Layout iv ‖ ciphertext ‖ tag matches Java's AES/GCM/NoPadding, whose
125
+ // doFinal() output is ciphertext ‖ tag.
126
+ return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]);
127
+ }
128
+
129
+ function decrypt(data: Uint8Array, key: Buffer, keyId: string): Buffer {
130
+ if (data.length < IV_BYTES + AUTH_TAG_BYTES) {
131
+ throw new Error(
132
+ `Encrypted payload under key id '${keyId}' is truncated (${data.length} bytes)`,
133
+ );
134
+ }
135
+ const buf = Buffer.from(data);
136
+ const iv = buf.subarray(0, IV_BYTES);
137
+ const ciphertext = buf.subarray(IV_BYTES, buf.length - AUTH_TAG_BYTES);
138
+ const tag = buf.subarray(buf.length - AUTH_TAG_BYTES);
139
+
140
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
141
+ decipher.setAuthTag(tag);
142
+ try {
143
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
144
+ } catch {
145
+ // GCM auth failure: tampered ciphertext or a key that does not match
146
+ // its advertised id. Never surface partially decrypted bytes.
147
+ throw new Error(
148
+ `Failed to decrypt payload under key id '${keyId}' — ciphertext is ` +
149
+ `corrupt or the configured key does not match`,
150
+ );
151
+ }
152
+ }