@adcp/sdk 14.0.0-beta.17 → 14.0.0-beta.19

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 (35) hide show
  1. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  2. package/dist/lib/server/decisioning/context.d.mts +4 -0
  3. package/dist/lib/server/decisioning/context.d.ts +4 -0
  4. package/dist/lib/server/decisioning/index.d.mts +1 -0
  5. package/dist/lib/server/decisioning/index.d.ts +1 -0
  6. package/dist/lib/server/decisioning/index.js +11 -0
  7. package/dist/lib/server/decisioning/index.mjs +12 -0
  8. package/dist/lib/server/decisioning/runtime/postgres-task-registry.js +5 -3
  9. package/dist/lib/server/decisioning/runtime/postgres-task-registry.mjs +5 -3
  10. package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.mts +136 -0
  11. package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.ts +136 -0
  12. package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.js +745 -0
  13. package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.mjs +717 -0
  14. package/dist/lib/server/decisioning/runtime/postgres-task-settlement.js +108 -36
  15. package/dist/lib/server/decisioning/runtime/postgres-task-settlement.mjs +108 -36
  16. package/dist/lib/server/decisioning/runtime/to-context.js +38 -9
  17. package/dist/lib/server/decisioning/runtime/to-context.mjs +38 -9
  18. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  19. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  20. package/dist/lib/types/schemas.generated.js +0 -63
  21. package/dist/lib/types/schemas.generated.mjs +0 -63
  22. package/dist/lib/utils/well-formed-unicode.d.mts +2 -0
  23. package/dist/lib/utils/well-formed-unicode.d.ts +2 -0
  24. package/dist/lib/utils/well-formed-unicode.js +51 -0
  25. package/dist/lib/utils/well-formed-unicode.mjs +27 -0
  26. package/dist/lib/version.d.mts +3 -3
  27. package/dist/lib/version.d.ts +3 -3
  28. package/dist/lib/version.js +3 -3
  29. package/dist/lib/version.mjs +3 -3
  30. package/docs/guides/BUILD-AN-AGENT.md +7 -0
  31. package/docs/guides/DURABLE-TASK-SETTLEMENT.md +468 -0
  32. package/docs/llms.txt +5 -2
  33. package/docs/migration-13-to-14.md +1 -1
  34. package/docs/migration-task-registry-scoping.md +45 -14
  35. package/package.json +2 -1
@@ -27,6 +27,7 @@ module.exports = __toCommonJS(postgres_task_settlement_exports);
27
27
  var import_node_crypto = require("node:crypto");
28
28
  var import_jcs = require('../../../utils/jcs.js');
29
29
  var import_adcp_version_config = require('../../../utils/adcp-version-config.js');
30
+ var import_well_formed_unicode = require('../../../utils/well-formed-unicode.js');
30
31
  var import_errors = require('../../errors.js');
31
32
  var import_common = require('../../webhook-delivery/common.js');
32
33
  var import_recovery = require('../../webhook-delivery/recovery.js');
@@ -51,17 +52,20 @@ function createPostgresTaskSettlementCoordinator(options) {
51
52
  "Crash-safe task settlement requires a pg Pool with connect(); a query-only adapter cannot own a transaction"
52
53
  );
53
54
  }
54
- if (typeof options.registry.registryId !== "string" || options.registry.registryId.length === 0) {
55
+ const registryId = options.registry.registryId;
56
+ if (typeof registryId !== "string" || registryId.length === 0) {
55
57
  throw new TypeError("Crash-safe task settlement requires task registry storageId/registryId binding");
56
58
  }
57
59
  if (typeof options.publisherScope !== "string" || options.publisherScope.length === 0) {
58
60
  throw new TypeError("publisherScope must be a non-empty string");
59
61
  }
62
+ (0, import_well_formed_unicode.assertWellFormedUnicode)(registryId, "Task settlement registryId");
63
+ (0, import_well_formed_unicode.assertWellFormedUnicode)(options.publisherScope, "Task settlement publisherScope");
60
64
  const outboxOptions = options.outbox ?? {};
61
65
  const outboxTable = (0, import_common.quoteWebhookTable)(outboxOptions.tableName ?? "adcp_webhook_outbox");
62
66
  const claimScope = {
63
67
  publisherScope: options.publisherScope,
64
- tenantScope: stableRegistryScope(options.registry.registryId, binding.namespace)
68
+ tenantScope: stableRegistryScope(registryId, binding.namespace)
65
69
  };
66
70
  const backend = (0, import_pg.pgWebhookDeliveryRecoveryBackend)(binding.pool, { ...outboxOptions, claimScope });
67
71
  const recovery = (0, import_recovery.createWebhookDeliveryRecovery)({
@@ -74,22 +78,35 @@ function createPostgresTaskSettlementCoordinator(options) {
74
78
  durability: "durable",
75
79
  recovery,
76
80
  async settle(ref, terminal, push) {
77
- if (ref.registryId !== options.registry.registryId) {
81
+ const settlementRef = snapshotSettlementRef(ref);
82
+ if (settlementRef.registryId !== registryId) {
78
83
  return { outcome: "not_found_in_scope", delivery: "not_applicable" };
79
84
  }
85
+ let clonedTerminal;
86
+ let clonedPush;
87
+ try {
88
+ clonedTerminal = structuredClone(terminal);
89
+ clonedPush = structuredClone(push);
90
+ (0, import_well_formed_unicode.assertWellFormedUnicode)(clonedTerminal, "Task terminal result/error");
91
+ (0, import_well_formed_unicode.assertWellFormedUnicode)(clonedPush, "Task push settlement config");
92
+ } catch (cause) {
93
+ throw new TaskPushSettlementConfigurationError("Task settlement inputs must contain serializable JSON", {
94
+ cause
95
+ });
96
+ }
80
97
  const deliveryKey = {
81
98
  ...claimScope,
82
- deliveryId: stableScope("task-webhook", ref)
99
+ deliveryId: stableScope("task-webhook", settlementRef)
83
100
  };
84
101
  const transactionalPool = pool;
85
102
  let observed;
86
103
  try {
87
- observed = await readTaskRow(transactionalPool, binding.tableName, binding.namespace, ref, false);
104
+ observed = await readTaskRow(transactionalPool, binding.tableName, binding.namespace, settlementRef, false);
88
105
  } catch (cause) {
89
106
  throwSettlementFailure(cause);
90
107
  }
91
108
  if (!observed) return { outcome: "not_found_in_scope", delivery: "not_applicable" };
92
- validatePush(push);
109
+ validatePush(clonedPush);
93
110
  if (observed.has_webhook !== true) {
94
111
  throw new TaskPushSettlementConfigurationError(
95
112
  "Crash-safe push settlement requires a task created with hasWebhook: true"
@@ -98,8 +115,9 @@ function createPostgresTaskSettlementCoordinator(options) {
98
115
  let result;
99
116
  let error;
100
117
  try {
101
- result = terminal.status === "completed" ? (0, import_task_registry.sanitizeTaskResultForWire)(structuredClone(terminal.result), ref) : terminal.result === void 0 ? { errors: [(0, import_errors.sanitizeStructuredAdcpError)(terminal.error)] } : (0, import_task_registry.sanitizeTaskResultForWire)(structuredClone(terminal.result), ref);
102
- error = terminal.status === "failed" ? (0, import_errors.sanitizeStructuredAdcpError)(terminal.error) : void 0;
118
+ result = clonedTerminal.status === "completed" ? (0, import_task_registry.sanitizeTaskResultForWire)(clonedTerminal.result, settlementRef) : clonedTerminal.result === void 0 ? { errors: [(0, import_errors.sanitizeStructuredAdcpError)(clonedTerminal.error)] } : (0, import_task_registry.sanitizeTaskResultForWire)(clonedTerminal.result, settlementRef);
119
+ error = clonedTerminal.status === "failed" ? (0, import_errors.sanitizeStructuredAdcpError)(clonedTerminal.error) : void 0;
120
+ (0, import_well_formed_unicode.assertWellFormedUnicode)({ result, error }, "Task terminal result/error");
103
121
  (0, import_jcs.canonicalJsonSha256)({ result, error });
104
122
  assertPayloadSize(result, error);
105
123
  } catch (cause) {
@@ -108,7 +126,7 @@ function createPostgresTaskSettlementCoordinator(options) {
108
126
  cause
109
127
  });
110
128
  }
111
- if (TERMINAL.has(observed.status) && !terminalMatches(observed, terminal.status, result, error)) {
129
+ if (TERMINAL.has(observed.status) && !terminalMatches(observed, clonedTerminal.status, result, error, settlementRef)) {
112
130
  return {
113
131
  outcome: "already_terminal",
114
132
  status: observed.status,
@@ -135,27 +153,27 @@ function createPostgresTaskSettlementCoordinator(options) {
135
153
  const existingTimestamp = observedOutbox?.state === "pending" ? observedOutbox.snapshot.payload.timestamp : void 0;
136
154
  const payload = {
137
155
  idempotency_key: `pending.${deliveryKey.deliveryId.slice(-48)}`,
138
- operation_id: resolveOperationId(push, observed.tool, ref.taskId),
139
- task_id: ref.taskId,
156
+ operation_id: resolveOperationId(clonedPush, observed.tool, settlementRef.taskId),
157
+ task_id: settlementRef.taskId,
140
158
  task_type: observed.tool,
141
- status: terminal.status,
159
+ status: clonedTerminal.status,
142
160
  timestamp: typeof existingTimestamp === "string" ? existingTimestamp : (/* @__PURE__ */ new Date()).toISOString(),
143
161
  protocol: (0, import_protocol_for_tool.protocolForTool)(observed.tool),
144
162
  result,
145
- ...push.token !== void 0 && { token: push.token },
146
- ...terminal.status === "failed" && { message: error.message }
163
+ ...clonedPush.token !== void 0 && { token: clonedPush.token },
164
+ ...clonedTerminal.status === "failed" && { message: error.message }
147
165
  };
148
166
  let prepared;
149
167
  try {
150
168
  prepared = await recovery.prepare(
151
169
  deliveryKey,
152
170
  {
153
- url: push.url,
171
+ url: clonedPush.url,
154
172
  payload,
155
- authentication: push.authentication ?? null,
173
+ authentication: clonedPush.authentication ?? null,
156
174
  retries
157
175
  },
158
- { protectPayloadToken: push.token !== void 0 }
176
+ { protectPayloadToken: clonedPush.token !== void 0 }
159
177
  );
160
178
  } catch (cause) {
161
179
  if (cause instanceof import_recovery.WebhookAuthenticationProtectionError) throwSettlementFailure(cause);
@@ -163,12 +181,12 @@ function createPostgresTaskSettlementCoordinator(options) {
163
181
  cause
164
182
  });
165
183
  }
166
- const intentFingerprint = taskPushIntentFingerprint(prepared.snapshot);
184
+ const intentFingerprint = taskPushIntentFingerprint(prepared.snapshot, settlementRef);
167
185
  let client;
168
186
  try {
169
187
  client = await transactionalPool.connect();
170
188
  await client.query("BEGIN");
171
- const row = await readTaskRow(client, binding.tableName, binding.namespace, ref, true);
189
+ const row = await readTaskRow(client, binding.tableName, binding.namespace, settlementRef, true);
172
190
  if (!row) {
173
191
  await client.query("ROLLBACK");
174
192
  return { outcome: "not_found_in_scope", delivery: "not_applicable" };
@@ -186,7 +204,7 @@ function createPostgresTaskSettlementCoordinator(options) {
186
204
  `Task type ${row.tool} cannot be emitted by the closed AdCP task-webhook schema`
187
205
  );
188
206
  }
189
- const compatible = terminalMatches(row, terminal.status, result, error);
207
+ const compatible = terminalMatches(row, clonedTerminal.status, result, error, settlementRef);
190
208
  if (TERMINAL.has(row.status) && !compatible) {
191
209
  await client.query("COMMIT");
192
210
  return {
@@ -208,7 +226,9 @@ function createPostgresTaskSettlementCoordinator(options) {
208
226
  if (!TERMINAL.has(row.status)) {
209
227
  throw new Error("Settled webhook outbox row exists for a non-terminal task");
210
228
  }
211
- if (existing.intent_fingerprint && existing.intent_fingerprint !== intentFingerprint) {
229
+ const matchesCurrentIntent = existing.intent_fingerprint === intentFingerprint;
230
+ const matchesLegacyArtifact = existing.intent_fingerprint !== null && existing.intent_fingerprint === taskPushLegacyIntentFingerprint(prepared.snapshot, row.result);
231
+ if (existing.intent_fingerprint !== null && !matchesCurrentIntent && !matchesLegacyArtifact) {
212
232
  throw new TaskPushSettlementConfigurationError(
213
233
  "Terminal webhook delivery identity is already bound to a conflicting route or payload"
214
234
  );
@@ -221,7 +241,7 @@ function createPostgresTaskSettlementCoordinator(options) {
221
241
  delivery: deliveryState(existing)
222
242
  };
223
243
  }
224
- if (existing && !outboxMatchesPrepared(existing, prepared, intentFingerprint)) {
244
+ if (existing && !outboxMatchesPrepared(existing, intentFingerprint, settlementRef)) {
225
245
  throw new TaskPushSettlementConfigurationError(
226
246
  "Terminal webhook delivery identity is already bound to a conflicting route or payload"
227
247
  );
@@ -245,7 +265,7 @@ function createPostgresTaskSettlementCoordinator(options) {
245
265
  );
246
266
  if ((write.rowCount ?? 0) !== 1) {
247
267
  const raced = await readOutbox(client, outboxTable, deliveryKey, true);
248
- if (!raced || !outboxMatchesPrepared(raced, prepared, intentFingerprint)) {
268
+ if (!raced || !outboxMatchesPrepared(raced, intentFingerprint, settlementRef)) {
249
269
  throw new TaskPushSettlementConfigurationError(
250
270
  "Terminal webhook delivery identity is already bound to a conflicting route or payload"
251
271
  );
@@ -257,22 +277,28 @@ function createPostgresTaskSettlementCoordinator(options) {
257
277
  }
258
278
  }
259
279
  if (!TERMINAL.has(row.status)) {
260
- const update = terminal.status === "completed" ? await client.query(
280
+ const update = clonedTerminal.status === "completed" ? await client.query(
261
281
  `UPDATE ${binding.tableName}
262
282
  SET status = 'completed', result = $5::jsonb, error = NULL,
263
283
  status_message = NULL, updated_at = clock_timestamp()
264
284
  WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,
265
- [ref.taskId, binding.namespace, ref.accountId, ref.ownerScope, JSON.stringify(result)]
285
+ [
286
+ settlementRef.taskId,
287
+ binding.namespace,
288
+ settlementRef.accountId,
289
+ settlementRef.ownerScope,
290
+ JSON.stringify(result)
291
+ ]
266
292
  ) : await client.query(
267
293
  `UPDATE ${binding.tableName}
268
294
  SET status = 'failed', result = $5::jsonb, error = $6::jsonb,
269
295
  status_message = $7, updated_at = clock_timestamp()
270
296
  WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,
271
297
  [
272
- ref.taskId,
298
+ settlementRef.taskId,
273
299
  binding.namespace,
274
- ref.accountId,
275
- ref.ownerScope,
300
+ settlementRef.accountId,
301
+ settlementRef.ownerScope,
276
302
  JSON.stringify(result),
277
303
  JSON.stringify(error),
278
304
  error.message
@@ -304,9 +330,33 @@ async function completeScopedPushTask(coordinator, ref, push, result) {
304
330
  async function failScopedPushTask(coordinator, ref, push, error, result) {
305
331
  return coordinator.settle(ref, { status: "failed", error, ...result !== void 0 && { result } }, push);
306
332
  }
307
- function terminalMatches(row, status, result, error) {
333
+ function snapshotSettlementRef(ref) {
334
+ const cloned = structuredClone(ref);
335
+ const snapshot = {
336
+ registryId: requireSettlementRefPart(cloned.registryId, "registryId"),
337
+ accountId: requireSettlementRefPart(cloned.accountId, "accountId"),
338
+ ownerScope: requireSettlementRefPart(cloned.ownerScope, "ownerScope"),
339
+ taskId: requireSettlementRefPart(cloned.taskId, "taskId")
340
+ };
341
+ (0, import_well_formed_unicode.assertWellFormedUnicode)(snapshot, "Task settlement reference");
342
+ return snapshot;
343
+ }
344
+ function requireSettlementRefPart(value, field) {
345
+ if (typeof value !== "string" || value.length === 0) {
346
+ throw new TypeError(`Task settlement ${field} must be a non-empty string`);
347
+ }
348
+ return value;
349
+ }
350
+ function terminalMatches(row, status, result, error, ref) {
308
351
  if (row.status !== status) return false;
309
- return (0, import_jcs.canonicalJsonSha256)(row.result) === (0, import_jcs.canonicalJsonSha256)(result) && (status === "completed" || (0, import_jcs.canonicalJsonSha256)(row.error) === (0, import_jcs.canonicalJsonSha256)(error));
352
+ try {
353
+ (0, import_well_formed_unicode.assertWellFormedUnicode)({ result: row.result, error: row.error }, "Stored task terminal result/error");
354
+ const storedResult = (0, import_task_registry.sanitizeTaskResultForWire)(structuredClone(row.result), ref);
355
+ const storedError = status === "failed" ? (0, import_errors.sanitizeStructuredAdcpError)(structuredClone(row.error)) : void 0;
356
+ return (0, import_jcs.canonicalJsonSha256)(storedResult) === (0, import_jcs.canonicalJsonSha256)(result) && (status === "completed" || (0, import_jcs.canonicalJsonSha256)(storedError) === (0, import_jcs.canonicalJsonSha256)(error));
357
+ } catch {
358
+ return false;
359
+ }
310
360
  }
311
361
  function stableScope(prefix, ref) {
312
362
  const digest = (0, import_node_crypto.createHash)("sha256").update(JSON.stringify([ref.registryId, ref.accountId, ref.ownerScope, ref.taskId])).digest("hex");
@@ -316,20 +366,36 @@ function stableRegistryScope(registryId, namespace) {
316
366
  const digest = (0, import_node_crypto.createHash)("sha256").update(JSON.stringify([registryId, namespace])).digest("hex");
317
367
  return `task-registry:${digest}`;
318
368
  }
319
- function taskPushIntentFingerprint(snapshot) {
320
- const { timestamp: _generatedTimestamp, ...payload } = snapshot.payload;
321
- return (0, import_jcs.canonicalJsonSha256)({
369
+ function taskPushIntentFingerprint(snapshot, ref) {
370
+ const { timestamp: _generatedTimestamp, ...storedPayload } = snapshot.payload;
371
+ const payload = structuredClone(storedPayload);
372
+ if (Object.hasOwn(payload, "result")) {
373
+ payload.result = (0, import_task_registry.sanitizeTaskResultForWire)(payload.result, ref);
374
+ }
375
+ return taskPushIntentFingerprintForPayload(snapshot, payload);
376
+ }
377
+ function taskPushLegacyIntentFingerprint(snapshot, storedResult) {
378
+ const { timestamp: _generatedTimestamp, ...storedPayload } = snapshot.payload;
379
+ const payload = structuredClone(storedPayload);
380
+ payload.result = structuredClone(storedResult);
381
+ return taskPushIntentFingerprintForPayload(snapshot, payload);
382
+ }
383
+ function taskPushIntentFingerprintForPayload(snapshot, payload) {
384
+ const domain = {
322
385
  url: snapshot.url,
323
386
  payload,
324
387
  retries: snapshot.retries,
325
388
  authentication: snapshot.authentication.kind === "none" ? { kind: "none" } : { kind: "protected", fingerprint: snapshot.authentication.fingerprint },
326
389
  ...snapshot.payloadToken && { payloadToken: { fingerprint: snapshot.payloadToken.fingerprint } }
327
- });
390
+ };
391
+ (0, import_well_formed_unicode.assertWellFormedUnicode)(domain, "Task push settlement fingerprint");
392
+ return (0, import_jcs.canonicalJsonSha256)(domain);
328
393
  }
329
394
  function validatePush(push) {
330
395
  if (!push || typeof push !== "object") {
331
396
  throw new TaskPushSettlementConfigurationError("push settlement config is required");
332
397
  }
398
+ (0, import_well_formed_unicode.assertWellFormedUnicode)(push, "Task push settlement config");
333
399
  const allowHttp = process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development" || process.env.ADCP_DECISIONING_ALLOW_HTTP_WEBHOOKS === "1";
334
400
  let parsedUrl;
335
401
  try {
@@ -412,8 +478,14 @@ async function readOutbox(db, table, key, lock) {
412
478
  );
413
479
  return found.rows[0];
414
480
  }
415
- function outboxMatchesPrepared(existing, prepared, intentFingerprint) {
416
- return existing.intent_fingerprint !== null ? existing.intent_fingerprint === intentFingerprint : existing.snapshot_fingerprint === prepared.snapshotFingerprint;
481
+ function outboxMatchesPrepared(existing, intentFingerprint, ref) {
482
+ if (existing.intent_fingerprint === intentFingerprint) return true;
483
+ if (existing.state === "settled") return false;
484
+ try {
485
+ return taskPushIntentFingerprint(existing.snapshot, ref) === intentFingerprint;
486
+ } catch {
487
+ return false;
488
+ }
417
489
  }
418
490
  function throwSettlementFailure(cause) {
419
491
  if (cause instanceof TaskPushSettlementConfigurationError) throw cause;
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { canonicalJsonSha256 } from "../../../utils/jcs.mjs";
3
3
  import { isAdcpVersionAtLeast, isValidAdcpVersion } from "../../../utils/adcp-version-config.mjs";
4
+ import { assertWellFormedUnicode } from "../../../utils/well-formed-unicode.mjs";
4
5
  import { sanitizeStructuredAdcpError } from "../../errors.mjs";
5
6
  import { quoteWebhookTable } from "../../webhook-delivery/common.mjs";
6
7
  import {
@@ -30,17 +31,20 @@ function createPostgresTaskSettlementCoordinator(options) {
30
31
  "Crash-safe task settlement requires a pg Pool with connect(); a query-only adapter cannot own a transaction"
31
32
  );
32
33
  }
33
- if (typeof options.registry.registryId !== "string" || options.registry.registryId.length === 0) {
34
+ const registryId = options.registry.registryId;
35
+ if (typeof registryId !== "string" || registryId.length === 0) {
34
36
  throw new TypeError("Crash-safe task settlement requires task registry storageId/registryId binding");
35
37
  }
36
38
  if (typeof options.publisherScope !== "string" || options.publisherScope.length === 0) {
37
39
  throw new TypeError("publisherScope must be a non-empty string");
38
40
  }
41
+ assertWellFormedUnicode(registryId, "Task settlement registryId");
42
+ assertWellFormedUnicode(options.publisherScope, "Task settlement publisherScope");
39
43
  const outboxOptions = options.outbox ?? {};
40
44
  const outboxTable = quoteWebhookTable(outboxOptions.tableName ?? "adcp_webhook_outbox");
41
45
  const claimScope = {
42
46
  publisherScope: options.publisherScope,
43
- tenantScope: stableRegistryScope(options.registry.registryId, binding.namespace)
47
+ tenantScope: stableRegistryScope(registryId, binding.namespace)
44
48
  };
45
49
  const backend = pgWebhookDeliveryRecoveryBackend(binding.pool, { ...outboxOptions, claimScope });
46
50
  const recovery = createWebhookDeliveryRecovery({
@@ -53,22 +57,35 @@ function createPostgresTaskSettlementCoordinator(options) {
53
57
  durability: "durable",
54
58
  recovery,
55
59
  async settle(ref, terminal, push) {
56
- if (ref.registryId !== options.registry.registryId) {
60
+ const settlementRef = snapshotSettlementRef(ref);
61
+ if (settlementRef.registryId !== registryId) {
57
62
  return { outcome: "not_found_in_scope", delivery: "not_applicable" };
58
63
  }
64
+ let clonedTerminal;
65
+ let clonedPush;
66
+ try {
67
+ clonedTerminal = structuredClone(terminal);
68
+ clonedPush = structuredClone(push);
69
+ assertWellFormedUnicode(clonedTerminal, "Task terminal result/error");
70
+ assertWellFormedUnicode(clonedPush, "Task push settlement config");
71
+ } catch (cause) {
72
+ throw new TaskPushSettlementConfigurationError("Task settlement inputs must contain serializable JSON", {
73
+ cause
74
+ });
75
+ }
59
76
  const deliveryKey = {
60
77
  ...claimScope,
61
- deliveryId: stableScope("task-webhook", ref)
78
+ deliveryId: stableScope("task-webhook", settlementRef)
62
79
  };
63
80
  const transactionalPool = pool;
64
81
  let observed;
65
82
  try {
66
- observed = await readTaskRow(transactionalPool, binding.tableName, binding.namespace, ref, false);
83
+ observed = await readTaskRow(transactionalPool, binding.tableName, binding.namespace, settlementRef, false);
67
84
  } catch (cause) {
68
85
  throwSettlementFailure(cause);
69
86
  }
70
87
  if (!observed) return { outcome: "not_found_in_scope", delivery: "not_applicable" };
71
- validatePush(push);
88
+ validatePush(clonedPush);
72
89
  if (observed.has_webhook !== true) {
73
90
  throw new TaskPushSettlementConfigurationError(
74
91
  "Crash-safe push settlement requires a task created with hasWebhook: true"
@@ -77,8 +94,9 @@ function createPostgresTaskSettlementCoordinator(options) {
77
94
  let result;
78
95
  let error;
79
96
  try {
80
- result = terminal.status === "completed" ? sanitizeTaskResultForWire(structuredClone(terminal.result), ref) : terminal.result === void 0 ? { errors: [sanitizeStructuredAdcpError(terminal.error)] } : sanitizeTaskResultForWire(structuredClone(terminal.result), ref);
81
- error = terminal.status === "failed" ? sanitizeStructuredAdcpError(terminal.error) : void 0;
97
+ result = clonedTerminal.status === "completed" ? sanitizeTaskResultForWire(clonedTerminal.result, settlementRef) : clonedTerminal.result === void 0 ? { errors: [sanitizeStructuredAdcpError(clonedTerminal.error)] } : sanitizeTaskResultForWire(clonedTerminal.result, settlementRef);
98
+ error = clonedTerminal.status === "failed" ? sanitizeStructuredAdcpError(clonedTerminal.error) : void 0;
99
+ assertWellFormedUnicode({ result, error }, "Task terminal result/error");
82
100
  canonicalJsonSha256({ result, error });
83
101
  assertPayloadSize(result, error);
84
102
  } catch (cause) {
@@ -87,7 +105,7 @@ function createPostgresTaskSettlementCoordinator(options) {
87
105
  cause
88
106
  });
89
107
  }
90
- if (TERMINAL.has(observed.status) && !terminalMatches(observed, terminal.status, result, error)) {
108
+ if (TERMINAL.has(observed.status) && !terminalMatches(observed, clonedTerminal.status, result, error, settlementRef)) {
91
109
  return {
92
110
  outcome: "already_terminal",
93
111
  status: observed.status,
@@ -114,27 +132,27 @@ function createPostgresTaskSettlementCoordinator(options) {
114
132
  const existingTimestamp = observedOutbox?.state === "pending" ? observedOutbox.snapshot.payload.timestamp : void 0;
115
133
  const payload = {
116
134
  idempotency_key: `pending.${deliveryKey.deliveryId.slice(-48)}`,
117
- operation_id: resolveOperationId(push, observed.tool, ref.taskId),
118
- task_id: ref.taskId,
135
+ operation_id: resolveOperationId(clonedPush, observed.tool, settlementRef.taskId),
136
+ task_id: settlementRef.taskId,
119
137
  task_type: observed.tool,
120
- status: terminal.status,
138
+ status: clonedTerminal.status,
121
139
  timestamp: typeof existingTimestamp === "string" ? existingTimestamp : (/* @__PURE__ */ new Date()).toISOString(),
122
140
  protocol: protocolForTool(observed.tool),
123
141
  result,
124
- ...push.token !== void 0 && { token: push.token },
125
- ...terminal.status === "failed" && { message: error.message }
142
+ ...clonedPush.token !== void 0 && { token: clonedPush.token },
143
+ ...clonedTerminal.status === "failed" && { message: error.message }
126
144
  };
127
145
  let prepared;
128
146
  try {
129
147
  prepared = await recovery.prepare(
130
148
  deliveryKey,
131
149
  {
132
- url: push.url,
150
+ url: clonedPush.url,
133
151
  payload,
134
- authentication: push.authentication ?? null,
152
+ authentication: clonedPush.authentication ?? null,
135
153
  retries
136
154
  },
137
- { protectPayloadToken: push.token !== void 0 }
155
+ { protectPayloadToken: clonedPush.token !== void 0 }
138
156
  );
139
157
  } catch (cause) {
140
158
  if (cause instanceof WebhookAuthenticationProtectionError) throwSettlementFailure(cause);
@@ -142,12 +160,12 @@ function createPostgresTaskSettlementCoordinator(options) {
142
160
  cause
143
161
  });
144
162
  }
145
- const intentFingerprint = taskPushIntentFingerprint(prepared.snapshot);
163
+ const intentFingerprint = taskPushIntentFingerprint(prepared.snapshot, settlementRef);
146
164
  let client;
147
165
  try {
148
166
  client = await transactionalPool.connect();
149
167
  await client.query("BEGIN");
150
- const row = await readTaskRow(client, binding.tableName, binding.namespace, ref, true);
168
+ const row = await readTaskRow(client, binding.tableName, binding.namespace, settlementRef, true);
151
169
  if (!row) {
152
170
  await client.query("ROLLBACK");
153
171
  return { outcome: "not_found_in_scope", delivery: "not_applicable" };
@@ -165,7 +183,7 @@ function createPostgresTaskSettlementCoordinator(options) {
165
183
  `Task type ${row.tool} cannot be emitted by the closed AdCP task-webhook schema`
166
184
  );
167
185
  }
168
- const compatible = terminalMatches(row, terminal.status, result, error);
186
+ const compatible = terminalMatches(row, clonedTerminal.status, result, error, settlementRef);
169
187
  if (TERMINAL.has(row.status) && !compatible) {
170
188
  await client.query("COMMIT");
171
189
  return {
@@ -187,7 +205,9 @@ function createPostgresTaskSettlementCoordinator(options) {
187
205
  if (!TERMINAL.has(row.status)) {
188
206
  throw new Error("Settled webhook outbox row exists for a non-terminal task");
189
207
  }
190
- if (existing.intent_fingerprint && existing.intent_fingerprint !== intentFingerprint) {
208
+ const matchesCurrentIntent = existing.intent_fingerprint === intentFingerprint;
209
+ const matchesLegacyArtifact = existing.intent_fingerprint !== null && existing.intent_fingerprint === taskPushLegacyIntentFingerprint(prepared.snapshot, row.result);
210
+ if (existing.intent_fingerprint !== null && !matchesCurrentIntent && !matchesLegacyArtifact) {
191
211
  throw new TaskPushSettlementConfigurationError(
192
212
  "Terminal webhook delivery identity is already bound to a conflicting route or payload"
193
213
  );
@@ -200,7 +220,7 @@ function createPostgresTaskSettlementCoordinator(options) {
200
220
  delivery: deliveryState(existing)
201
221
  };
202
222
  }
203
- if (existing && !outboxMatchesPrepared(existing, prepared, intentFingerprint)) {
223
+ if (existing && !outboxMatchesPrepared(existing, intentFingerprint, settlementRef)) {
204
224
  throw new TaskPushSettlementConfigurationError(
205
225
  "Terminal webhook delivery identity is already bound to a conflicting route or payload"
206
226
  );
@@ -224,7 +244,7 @@ function createPostgresTaskSettlementCoordinator(options) {
224
244
  );
225
245
  if ((write.rowCount ?? 0) !== 1) {
226
246
  const raced = await readOutbox(client, outboxTable, deliveryKey, true);
227
- if (!raced || !outboxMatchesPrepared(raced, prepared, intentFingerprint)) {
247
+ if (!raced || !outboxMatchesPrepared(raced, intentFingerprint, settlementRef)) {
228
248
  throw new TaskPushSettlementConfigurationError(
229
249
  "Terminal webhook delivery identity is already bound to a conflicting route or payload"
230
250
  );
@@ -236,22 +256,28 @@ function createPostgresTaskSettlementCoordinator(options) {
236
256
  }
237
257
  }
238
258
  if (!TERMINAL.has(row.status)) {
239
- const update = terminal.status === "completed" ? await client.query(
259
+ const update = clonedTerminal.status === "completed" ? await client.query(
240
260
  `UPDATE ${binding.tableName}
241
261
  SET status = 'completed', result = $5::jsonb, error = NULL,
242
262
  status_message = NULL, updated_at = clock_timestamp()
243
263
  WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,
244
- [ref.taskId, binding.namespace, ref.accountId, ref.ownerScope, JSON.stringify(result)]
264
+ [
265
+ settlementRef.taskId,
266
+ binding.namespace,
267
+ settlementRef.accountId,
268
+ settlementRef.ownerScope,
269
+ JSON.stringify(result)
270
+ ]
245
271
  ) : await client.query(
246
272
  `UPDATE ${binding.tableName}
247
273
  SET status = 'failed', result = $5::jsonb, error = $6::jsonb,
248
274
  status_message = $7, updated_at = clock_timestamp()
249
275
  WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,
250
276
  [
251
- ref.taskId,
277
+ settlementRef.taskId,
252
278
  binding.namespace,
253
- ref.accountId,
254
- ref.ownerScope,
279
+ settlementRef.accountId,
280
+ settlementRef.ownerScope,
255
281
  JSON.stringify(result),
256
282
  JSON.stringify(error),
257
283
  error.message
@@ -283,9 +309,33 @@ async function completeScopedPushTask(coordinator, ref, push, result) {
283
309
  async function failScopedPushTask(coordinator, ref, push, error, result) {
284
310
  return coordinator.settle(ref, { status: "failed", error, ...result !== void 0 && { result } }, push);
285
311
  }
286
- function terminalMatches(row, status, result, error) {
312
+ function snapshotSettlementRef(ref) {
313
+ const cloned = structuredClone(ref);
314
+ const snapshot = {
315
+ registryId: requireSettlementRefPart(cloned.registryId, "registryId"),
316
+ accountId: requireSettlementRefPart(cloned.accountId, "accountId"),
317
+ ownerScope: requireSettlementRefPart(cloned.ownerScope, "ownerScope"),
318
+ taskId: requireSettlementRefPart(cloned.taskId, "taskId")
319
+ };
320
+ assertWellFormedUnicode(snapshot, "Task settlement reference");
321
+ return snapshot;
322
+ }
323
+ function requireSettlementRefPart(value, field) {
324
+ if (typeof value !== "string" || value.length === 0) {
325
+ throw new TypeError(`Task settlement ${field} must be a non-empty string`);
326
+ }
327
+ return value;
328
+ }
329
+ function terminalMatches(row, status, result, error, ref) {
287
330
  if (row.status !== status) return false;
288
- return canonicalJsonSha256(row.result) === canonicalJsonSha256(result) && (status === "completed" || canonicalJsonSha256(row.error) === canonicalJsonSha256(error));
331
+ try {
332
+ assertWellFormedUnicode({ result: row.result, error: row.error }, "Stored task terminal result/error");
333
+ const storedResult = sanitizeTaskResultForWire(structuredClone(row.result), ref);
334
+ const storedError = status === "failed" ? sanitizeStructuredAdcpError(structuredClone(row.error)) : void 0;
335
+ return canonicalJsonSha256(storedResult) === canonicalJsonSha256(result) && (status === "completed" || canonicalJsonSha256(storedError) === canonicalJsonSha256(error));
336
+ } catch {
337
+ return false;
338
+ }
289
339
  }
290
340
  function stableScope(prefix, ref) {
291
341
  const digest = createHash("sha256").update(JSON.stringify([ref.registryId, ref.accountId, ref.ownerScope, ref.taskId])).digest("hex");
@@ -295,20 +345,36 @@ function stableRegistryScope(registryId, namespace) {
295
345
  const digest = createHash("sha256").update(JSON.stringify([registryId, namespace])).digest("hex");
296
346
  return `task-registry:${digest}`;
297
347
  }
298
- function taskPushIntentFingerprint(snapshot) {
299
- const { timestamp: _generatedTimestamp, ...payload } = snapshot.payload;
300
- return canonicalJsonSha256({
348
+ function taskPushIntentFingerprint(snapshot, ref) {
349
+ const { timestamp: _generatedTimestamp, ...storedPayload } = snapshot.payload;
350
+ const payload = structuredClone(storedPayload);
351
+ if (Object.hasOwn(payload, "result")) {
352
+ payload.result = sanitizeTaskResultForWire(payload.result, ref);
353
+ }
354
+ return taskPushIntentFingerprintForPayload(snapshot, payload);
355
+ }
356
+ function taskPushLegacyIntentFingerprint(snapshot, storedResult) {
357
+ const { timestamp: _generatedTimestamp, ...storedPayload } = snapshot.payload;
358
+ const payload = structuredClone(storedPayload);
359
+ payload.result = structuredClone(storedResult);
360
+ return taskPushIntentFingerprintForPayload(snapshot, payload);
361
+ }
362
+ function taskPushIntentFingerprintForPayload(snapshot, payload) {
363
+ const domain = {
301
364
  url: snapshot.url,
302
365
  payload,
303
366
  retries: snapshot.retries,
304
367
  authentication: snapshot.authentication.kind === "none" ? { kind: "none" } : { kind: "protected", fingerprint: snapshot.authentication.fingerprint },
305
368
  ...snapshot.payloadToken && { payloadToken: { fingerprint: snapshot.payloadToken.fingerprint } }
306
- });
369
+ };
370
+ assertWellFormedUnicode(domain, "Task push settlement fingerprint");
371
+ return canonicalJsonSha256(domain);
307
372
  }
308
373
  function validatePush(push) {
309
374
  if (!push || typeof push !== "object") {
310
375
  throw new TaskPushSettlementConfigurationError("push settlement config is required");
311
376
  }
377
+ assertWellFormedUnicode(push, "Task push settlement config");
312
378
  const allowHttp = process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development" || process.env.ADCP_DECISIONING_ALLOW_HTTP_WEBHOOKS === "1";
313
379
  let parsedUrl;
314
380
  try {
@@ -391,8 +457,14 @@ async function readOutbox(db, table, key, lock) {
391
457
  );
392
458
  return found.rows[0];
393
459
  }
394
- function outboxMatchesPrepared(existing, prepared, intentFingerprint) {
395
- return existing.intent_fingerprint !== null ? existing.intent_fingerprint === intentFingerprint : existing.snapshot_fingerprint === prepared.snapshotFingerprint;
460
+ function outboxMatchesPrepared(existing, intentFingerprint, ref) {
461
+ if (existing.intent_fingerprint === intentFingerprint) return true;
462
+ if (existing.state === "settled") return false;
463
+ try {
464
+ return taskPushIntentFingerprint(existing.snapshot, ref) === intentFingerprint;
465
+ } catch {
466
+ return false;
467
+ }
396
468
  }
397
469
  function throwSettlementFailure(cause) {
398
470
  if (cause instanceof TaskPushSettlementConfigurationError) throw cause;