@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
@@ -0,0 +1,468 @@
1
+ # Durable task settlement
2
+
3
+ Human approvals and provider callbacks often commit outside the request that
4
+ created an AdCP task. There are three distinct durability boundaries:
5
+
6
+ 1. Commit the business outcome and an exact task-settlement intent together.
7
+ 2. Apply that intent to the SDK task registry (and, for push-enabled tasks,
8
+ atomically checkpoint the terminal webhook).
9
+ 3. Deliver the terminal webhook with at-least-once recovery.
10
+
11
+ `createPostgresTaskSettlementIntentQueue()` protects the first boundary.
12
+ `createPostgresTaskSettlementCoordinator()` protects the second. The webhook
13
+ delivery recovery worker protects the third.
14
+
15
+ ## Provision the queue
16
+
17
+ Run the bootstrap SQL during database provisioning:
18
+
19
+ ```ts
20
+ import { getTaskSettlementIntentMigration } from '@adcp/sdk/server';
21
+
22
+ await pool.query(
23
+ getTaskSettlementIntentMigration({
24
+ tableName: 'seller_task_settlement_intents',
25
+ })
26
+ );
27
+ ```
28
+
29
+ Then construct one queue per trusted deployment or tenant namespace:
30
+
31
+ ```ts
32
+ import {
33
+ createPostgresTaskSettlementIntentQueue,
34
+ TASK_SETTLEMENT_INTENT_IDEMPOTENCY_HORIZON_MS,
35
+ } from '@adcp/sdk/server';
36
+
37
+ const settlementIntents = createPostgresTaskSettlementIntentQueue({
38
+ db: pool,
39
+ namespace: 'seller-prod',
40
+ tableName: 'seller_task_settlement_intents',
41
+ // Keep this at least as long as every upstream retry/replay window.
42
+ idempotencyHorizonMs: TASK_SETTLEMENT_INTENT_IDEMPOTENCY_HORIZON_MS,
43
+ });
44
+ ```
45
+
46
+ The namespace and complete `DurableTaskSettlementRef` form the isolation key.
47
+ The queue requires `registryId`, `accountId`, and `ownerScope`; a public
48
+ `task_id` alone is not a safe worker credential. `ScopedTaskRef.registryId`
49
+ remains optional for legacy custom registries, so narrow a framework-issued
50
+ handle before building an intent:
51
+
52
+ ```ts
53
+ import type { DurableTaskSettlementRef } from '@adcp/sdk/server';
54
+
55
+ if (!taskRef.registryId) {
56
+ throw new Error('Durable settlement requires a registry-bound task handle');
57
+ }
58
+ const durableTaskRef: DurableTaskSettlementRef = {
59
+ ...taskRef,
60
+ registryId: taskRef.registryId,
61
+ };
62
+ ```
63
+
64
+ ## Commit the domain decision and intent together
65
+
66
+ Pass the application's active transaction client to `enqueue`. An exact retry
67
+ returns the same checkpoint. Reusing the same scoped task for a changed result
68
+ or error throws `TaskSettlementIntentConflictError`, including after
69
+ acknowledgement: acknowledgement turns the row into a fingerprint tombstone
70
+ for `idempotencyHorizonMs` (seven days by default). Set that horizon to at least
71
+ the longest application, provider, or transport retry/replay window. Recovery
72
+ compacts the acknowledged row so it no longer retains the result/error payload
73
+ and prunes expired tombstones in bounded batches; an expired tombstone may also
74
+ be atomically replaced by a newly enqueued intent.
75
+
76
+ ```ts
77
+ import {
78
+ canonicalizeTaskSettlementIntent,
79
+ type TaskSettlementIntent,
80
+ } from '@adcp/sdk/server';
81
+
82
+ const intent: TaskSettlementIntent = canonicalizeTaskSettlementIntent({
83
+ taskRef: durableTaskRef,
84
+ action: 'complete',
85
+ result: { media_buy_id: mediaBuyId, media_buy_status: 'active' },
86
+ });
87
+
88
+ const checkpoint = await withTransaction(async tx => {
89
+ await approvals.markApproved(tx, approvalId);
90
+ return settlementIntents.enqueue(intent, { db: tx });
91
+ });
92
+ ```
93
+
94
+ After commit, a polling-only task can try settlement immediately. Acknowledge
95
+ only after the intended terminal state is proven:
96
+
97
+ ```ts
98
+ await applyPollingSettlementIntent(taskRegistry, intent);
99
+ await settlementIntents.acknowledge(checkpoint);
100
+ ```
101
+
102
+ If the process dies between those calls, recovery safely repeats the same
103
+ idempotent settlement.
104
+
105
+ ## Settle polling-only tasks safely
106
+
107
+ For a polling-only task, the following helper handles both terminal actions
108
+ and proves that an `already_terminal` outcome contains the exact artifact from
109
+ the intent. It deliberately throws for a scope miss or conflicting terminal
110
+ write so the queue retains the intent.
111
+
112
+ Build the immediate-path object with `canonicalizeTaskSettlementIntent()` as
113
+ shown above. `enqueue` applies the same clone, validation, and wire sanitizer,
114
+ so both paths compare the same artifact. Recovery verifies the immutable
115
+ fingerprint against the exact stored payload before applying the current wire
116
+ sanitizer, so intents written by an older SDK remain compatible with the
117
+ current task registry after an upgrade.
118
+
119
+ ```ts
120
+ import { isDeepStrictEqual } from 'node:util';
121
+ import {
122
+ canonicalizeTaskSettlementIntent,
123
+ completeScopedTask,
124
+ failScopedTask,
125
+ type TaskRegistry,
126
+ type TaskSettlementIntent,
127
+ } from '@adcp/sdk/server';
128
+
129
+ export async function applyPollingSettlementIntent(
130
+ registry: TaskRegistry,
131
+ intent: TaskSettlementIntent
132
+ ): Promise<'settled'> {
133
+ const outcome =
134
+ intent.action === 'complete'
135
+ ? await completeScopedTask(registry, intent.taskRef, intent.result)
136
+ : await failScopedTask(
137
+ registry,
138
+ intent.taskRef,
139
+ intent.error,
140
+ intent.result
141
+ );
142
+
143
+ if (outcome.outcome === 'applied') return 'settled';
144
+ if (outcome.outcome === 'not_found_in_scope') {
145
+ throw new Error('Settlement task was not found in its trusted scope');
146
+ }
147
+
148
+ const stored = await registry.getTask(
149
+ intent.taskRef.taskId,
150
+ intent.taskRef
151
+ );
152
+ if (!stored) {
153
+ throw new Error('Terminal task disappeared from its trusted scope');
154
+ }
155
+
156
+ let storedIntent: TaskSettlementIntent | undefined;
157
+ if (stored.status === 'completed' && Object.hasOwn(stored, 'result')) {
158
+ storedIntent = canonicalizeTaskSettlementIntent({
159
+ taskRef: intent.taskRef,
160
+ action: 'complete',
161
+ result: stored.result,
162
+ });
163
+ } else if (stored.status === 'failed' && stored.error) {
164
+ storedIntent = canonicalizeTaskSettlementIntent({
165
+ taskRef: intent.taskRef,
166
+ action: 'fail',
167
+ error: stored.error,
168
+ ...(Object.hasOwn(stored, 'result') && { result: stored.result }),
169
+ });
170
+ }
171
+
172
+ const sameArtifact =
173
+ storedIntent !== undefined && isDeepStrictEqual(storedIntent, intent);
174
+
175
+ if (!sameArtifact) {
176
+ throw new Error('Task is terminal with a conflicting settlement artifact');
177
+ }
178
+ return 'settled';
179
+ }
180
+ ```
181
+
182
+ This helper is only for tasks without push notifications.
183
+ `completeScopedTask()` and `failScopedTask()` reject registry-only settlement
184
+ for a push-enabled task.
185
+
186
+ ## Settle push-enabled tasks safely
187
+
188
+ For push-enabled tasks, persist the original push route and its protected
189
+ authentication configuration in durable application state in the same domain
190
+ transaction as the intent. Recovery must reconstruct that configuration; an
191
+ in-memory callback route can disappear in the exact crash this queue protects.
192
+
193
+ Use the PostgreSQL settlement coordinator instead of the polling helper. Its
194
+ compatible `already_terminal` outcome proves both the exact task artifact and
195
+ the immutable webhook checkpoint:
196
+
197
+ ```ts
198
+ import {
199
+ completeScopedPushTask,
200
+ failScopedPushTask,
201
+ type PostgresTaskSettlementCoordinator,
202
+ type TaskPushSettlementConfig,
203
+ type TaskSettlementIntent,
204
+ } from '@adcp/sdk/server';
205
+
206
+ async function applyPushSettlementIntent(
207
+ coordinator: PostgresTaskSettlementCoordinator,
208
+ push: TaskPushSettlementConfig,
209
+ intent: TaskSettlementIntent
210
+ ): Promise<'settled'> {
211
+ const outcome =
212
+ intent.action === 'complete'
213
+ ? await completeScopedPushTask(
214
+ coordinator,
215
+ intent.taskRef,
216
+ push,
217
+ intent.result
218
+ )
219
+ : await failScopedPushTask(
220
+ coordinator,
221
+ intent.taskRef,
222
+ push,
223
+ intent.error,
224
+ intent.result
225
+ );
226
+
227
+ if (outcome.outcome === 'applied') return 'settled';
228
+ if (
229
+ outcome.outcome === 'already_terminal' &&
230
+ outcome.compatibility === 'compatible'
231
+ ) {
232
+ return 'settled';
233
+ }
234
+ throw new Error('Push task has a scope or settlement compatibility conflict');
235
+ }
236
+ ```
237
+
238
+ Never return `settled` for `not_found_in_scope`, a conflicting terminal state,
239
+ or a push-settlement compatibility conflict.
240
+
241
+ After the domain transaction commits, use the push helper before acknowledging
242
+ the same checkpoint:
243
+
244
+ ```ts
245
+ const push = await protectedPushRoutes.load(intent.taskRef);
246
+ if (!push) throw new Error('Durable push configuration was not found');
247
+
248
+ await applyPushSettlementIntent(settlementCoordinator, push, intent);
249
+ await settlementIntents.acknowledge(checkpoint);
250
+ ```
251
+
252
+ ## Recover intents
253
+
254
+ Run `recover` from a scheduled worker. The callback must be idempotent and
255
+ must return the literal `settled` only after it proves the intended state.
256
+ Thrown errors are retried with exponential backoff and eventually retained as
257
+ dead letters. Only the error class name is persisted; use `onError` for
258
+ application observability.
259
+
260
+ ```ts
261
+ const metrics = await settlementIntents.recover({
262
+ workerId: `settlement-worker:${process.pid}`,
263
+ // Maximum callbacks handled by this invocation. Each row is claimed only
264
+ // when its callback is ready to start, so earlier work does not age its lease.
265
+ batchSize: 25,
266
+ async settle(intent, claim) {
267
+ // Renew again during work that can exceed leaseMs.
268
+ if (!(await claim.extendLease())) throw new Error('Settlement intent lease lost');
269
+ return applyPollingSettlementIntent(taskRegistry, intent);
270
+ },
271
+ onError(error, context) {
272
+ telemetry.captureException(error, context);
273
+ },
274
+ });
275
+ ```
276
+
277
+ For push-enabled recovery, load the protected push configuration by the full
278
+ `intent.taskRef` and call `applyPushSettlementIntent()` instead. Run multiple
279
+ workers for concurrency; each worker should use a stable, unique `workerId`.
280
+ Throwing keeps the intent recoverable and reports through `onError` until the
281
+ underlying configuration is corrected.
282
+
283
+ ## Inspect, monitor, and requeue dead letters
284
+
285
+ The queue intentionally has no broad administrative mutation API. Use the full
286
+ namespace, registry, account, owner, task, and fingerprint binding for every
287
+ operator write. The examples below use `psql` variables; replace the table name
288
+ if you configured a different one.
289
+
290
+ Inspect one intent without selecting its potentially sensitive payload:
291
+
292
+ ```sql
293
+ \set queue_namespace 'seller-prod'
294
+ \set registry_id 'prod-eu1:seller-tasks'
295
+ \set account_id 'account-42'
296
+ \set owner_scope 'api_key:buyer-7'
297
+ \set task_id 'task-01JQ8V8YMBXQ1TQ9G1K9V0P4N7'
298
+
299
+ SELECT queue_namespace, registry_id, account_id, owner_scope, task_id,
300
+ scope_fingerprint, action, intent_fingerprint, state, attempt_count, next_attempt_at,
301
+ lease_owner, lease_expires_at, last_error, retain_until, created_at, updated_at,
302
+ pg_column_size(payload) AS payload_bytes
303
+ FROM seller_task_settlement_intents
304
+ WHERE queue_namespace = :'queue_namespace'
305
+ AND registry_id = :'registry_id'
306
+ AND account_id = :'account_id'
307
+ AND owner_scope = :'owner_scope'
308
+ AND task_id = :'task_id';
309
+ ```
310
+
311
+ Monitor one trusted namespace:
312
+
313
+ ```sql
314
+ \set queue_namespace 'seller-prod'
315
+
316
+ SELECT count(*) FILTER (WHERE state = 'pending') AS pending_count,
317
+ count(*) FILTER (WHERE state = 'dead_letter') AS dead_letter_count,
318
+ count(*) FILTER (WHERE state = 'acknowledged') AS acknowledged_tombstone_count,
319
+ clock_timestamp() - min(created_at)
320
+ FILTER (WHERE state = 'pending') AS oldest_pending_age,
321
+ sum(pg_column_size(payload)) AS payload_bytes
322
+ FROM seller_task_settlement_intents
323
+ WHERE queue_namespace = :'queue_namespace';
324
+
325
+ SELECT pg_size_pretty(
326
+ pg_total_relation_size('seller_task_settlement_intents')
327
+ ) AS total_table_size;
328
+ ```
329
+
330
+ Schedule pruning independently of recovery traffic. Each call is bounded and
331
+ returns the number of deleted, already-expired acknowledgement tombstones:
332
+
333
+ ```ts
334
+ const deleted = await settlementIntents.pruneAcknowledged({ limit: 1000 });
335
+ ```
336
+
337
+ After correcting the cause, requeue exactly one inspected dead letter. Copy
338
+ the fingerprint from the inspection result and require `UPDATE 1`; zero rows
339
+ means the binding or state changed and must be inspected again.
340
+
341
+ ```sql
342
+ \set intent_fingerprint '6b45e3f3eb04a98b68eaf96e186f57d5904b55074738109f131b26b0a82b2d2c'
343
+ \set scope_fingerprint '98723053ca67c0248a142b3fe5d7e610201089a322b649219cac9f4a05616bde'
344
+
345
+ BEGIN;
346
+ UPDATE seller_task_settlement_intents
347
+ SET state = 'pending',
348
+ attempt_count = 0,
349
+ next_attempt_at = clock_timestamp(),
350
+ lease_owner = NULL,
351
+ lease_claim_id = NULL,
352
+ lease_version = lease_version + 1,
353
+ lease_expires_at = NULL,
354
+ last_error = NULL,
355
+ updated_at = clock_timestamp()
356
+ WHERE queue_namespace = :'queue_namespace'
357
+ AND registry_id = :'registry_id'
358
+ AND account_id = :'account_id'
359
+ AND owner_scope = :'owner_scope'
360
+ AND task_id = :'task_id'
361
+ AND scope_fingerprint = :'scope_fingerprint'
362
+ AND intent_fingerprint = :'intent_fingerprint'
363
+ AND state = 'dead_letter'
364
+ RETURNING queue_namespace, registry_id, account_id, owner_scope, task_id,
365
+ intent_fingerprint, state, attempt_count;
366
+ COMMIT;
367
+ ```
368
+
369
+ For retention, archive an exact, aged dead letter before deleting it. Protect
370
+ the archive as application data because `archived_row` contains the terminal
371
+ result or error. Provision the archive table once:
372
+
373
+ ```sql
374
+ CREATE TABLE IF NOT EXISTS seller_task_settlement_intents_archive (
375
+ queue_namespace TEXT NOT NULL,
376
+ registry_id TEXT NOT NULL,
377
+ account_id TEXT NOT NULL,
378
+ owner_scope TEXT NOT NULL,
379
+ task_id TEXT NOT NULL,
380
+ scope_fingerprint TEXT NOT NULL,
381
+ intent_fingerprint TEXT NOT NULL,
382
+ archived_row JSONB NOT NULL,
383
+ archived_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
384
+ PRIMARY KEY (
385
+ queue_namespace, scope_fingerprint,
386
+ intent_fingerprint
387
+ )
388
+ );
389
+ ```
390
+
391
+ Then archive and remove only the inspected row after the retention cutoff:
392
+
393
+ ```sql
394
+ \set retain_before '2026-07-01T00:00:00Z'
395
+
396
+ BEGIN;
397
+ INSERT INTO seller_task_settlement_intents_archive (
398
+ queue_namespace, registry_id, account_id, owner_scope, task_id,
399
+ scope_fingerprint, intent_fingerprint, archived_row
400
+ )
401
+ SELECT queue_namespace, registry_id, account_id, owner_scope, task_id,
402
+ scope_fingerprint, intent_fingerprint, to_jsonb(q)
403
+ FROM seller_task_settlement_intents AS q
404
+ WHERE queue_namespace = :'queue_namespace'
405
+ AND registry_id = :'registry_id'
406
+ AND account_id = :'account_id'
407
+ AND owner_scope = :'owner_scope'
408
+ AND task_id = :'task_id'
409
+ AND scope_fingerprint = :'scope_fingerprint'
410
+ AND intent_fingerprint = :'intent_fingerprint'
411
+ AND state = 'dead_letter'
412
+ AND updated_at < :'retain_before'::timestamptz
413
+ ON CONFLICT DO NOTHING;
414
+
415
+ DELETE FROM seller_task_settlement_intents AS q
416
+ WHERE q.queue_namespace = :'queue_namespace'
417
+ AND q.registry_id = :'registry_id'
418
+ AND q.account_id = :'account_id'
419
+ AND q.owner_scope = :'owner_scope'
420
+ AND q.task_id = :'task_id'
421
+ AND q.scope_fingerprint = :'scope_fingerprint'
422
+ AND q.intent_fingerprint = :'intent_fingerprint'
423
+ AND q.state = 'dead_letter'
424
+ AND q.updated_at < :'retain_before'::timestamptz
425
+ AND EXISTS (
426
+ SELECT 1
427
+ FROM seller_task_settlement_intents_archive AS a
428
+ WHERE a.queue_namespace = q.queue_namespace
429
+ AND a.registry_id = q.registry_id
430
+ AND a.account_id = q.account_id
431
+ AND a.owner_scope = q.owner_scope
432
+ AND a.task_id = q.task_id
433
+ AND a.scope_fingerprint = q.scope_fingerprint
434
+ AND a.intent_fingerprint = q.intent_fingerprint
435
+ )
436
+ RETURNING q.queue_namespace, q.registry_id, q.account_id, q.owner_scope,
437
+ q.task_id, q.intent_fingerprint;
438
+ COMMIT;
439
+ ```
440
+
441
+ ## Operational contract
442
+
443
+ - Settlement delivery is at least once. The settlement callback must be
444
+ idempotent.
445
+ - Claims use `FOR UPDATE SKIP LOCKED`, leases, and fencing tokens so multiple
446
+ workers can share the queue. Each row is claimed immediately before its
447
+ callback. Slow callbacks can call `extendLease()` and should stop work if it
448
+ reports that fencing ownership was lost.
449
+ - Recovery loads at most one capped payload into a worker at a time, even when
450
+ a large recovery batch is requested.
451
+ - Acknowledgement retains the exact intent fingerprint through the configured
452
+ idempotency horizon while discarding the no-longer-needed payload. Each
453
+ recovery invocation prunes at most `batchSize` expired acknowledgement
454
+ tombstones; schedule `pruneAcknowledged()` as well if recovery can be idle.
455
+ - Results are sanitized with the same wire sanitizer as the task registry;
456
+ `ctx_metadata` and `implementation_config` are not persisted.
457
+ - Do not place credentials or application-private secrets in custom result or
458
+ error fields. The queue strips known SDK server-only fields; it cannot infer
459
+ which arbitrary application fields are confidential.
460
+ - Payloads are canonicalized, fingerprinted, and capped at 4 MiB.
461
+ - `probe()` verifies that the configured table and columns are reachable.
462
+ - Dead letters remain in the table for operator inspection. Requeue them only
463
+ with the exact scoped and fingerprinted operator update above after
464
+ correcting the cause; an exact `enqueue` does not reset a dead letter.
465
+ - Monitor pending, dead-letter, and acknowledged-tombstone row counts and table
466
+ size; set alerts for oldest pending age; and apply admission limits before
467
+ untrusted callers can create unbounded asynchronous work. Archive or remove
468
+ resolved dead letters under an application-owned retention policy.
package/docs/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # Ad Context Protocol (AdCP)
2
2
 
3
- > Generated at: 2026-08-29
4
- > Library: @adcp/sdk v14.0.0-beta.17
3
+ > Generated at: 2026-08-30
4
+ > Library: @adcp/sdk v14.0.0-beta.19
5
5
  > AdCP major version: 3
6
6
  > Canonical URL: https://adcontextprotocol.github.io/adcp-client/llms.txt
7
7
  > Note: the `Library` stamp reflects the package.json version at doc-generation time. The narrative below describes the surface that lands on the next-published minor — including any 6.7 helpers documented here ahead of the release tag.
@@ -1890,9 +1890,11 @@ See docs/TYPE-SUMMARY.md for field-level detail. Key types at a glance:
1890
1890
  | `EstablishedProposalStore` | Durable 3.0/3.1 proposal snapshots, atomic mutation fences, seven-day completion proofs, pruning, and submitted-task reconciliation |
1891
1891
  | `WebhooksConfig.tenantScope` | Explicit trusted webhook namespace for a genuinely single-tenant server; multi-tenant servers derive scope per request |
1892
1892
  | `PostgresTaskSettlementCoordinator` | Atomically commits a push task terminal state and PostgreSQL recovery-outbox checkpoint for different-process workers |
1893
+ | `PostgresTaskSettlementIntentQueue` | Commits an exact terminal intent with application state, then recovers idempotent SDK task settlement after a crash |
1893
1894
 
1894
1895
  Production webhook publishers may construct an unbound emitter and call `forTenantScope(trustedTenant)` before every delivery. Direct unbound `emit()` fails before checkpointing or network access. `createAdcpServer` derives scope from trusted request context; configure `webhooks.tenantScope` only for a genuinely single-tenant factory.
1895
1896
  Push-enabled decisioning tasks settled by another process must return `ctx.handoffToTask(producer, { settlement: 'external' })`; the framework withholds `submitted` until the producer durably queues the complete scoped handle and encrypted route. Workers use `createPostgresTaskSettlementCoordinator()` with `completeScopedPushTask()` / `failScopedPushTask()`; the task mutation and encrypted recovery outbox checkpoint commit together. Acknowledge work only for `applied` or compatible `already_terminal`; retry or dead-letter scope misses and conflicts.
1897
+ When application state commits before SDK task settlement, call `createPostgresTaskSettlementIntentQueue().enqueue(intent, { db: tx })` in the same domain transaction. Acknowledgement discards the payload and retains an immutable fingerprint tombstone through the configured idempotency horizon; `pruneAcknowledged()` removes expired tombstones in bounded batches. Recovery callbacks are at-least-once and must prove the exact terminal artifact before returning `settled`; polling tasks compare the scoped registry record, while push tasks use the PostgreSQL settlement coordinator. See `docs/guides/DURABLE-TASK-SETTLEMENT.md` for copyable handlers and scoped dead-letter operations.
1896
1898
 
1897
1899
  ## Task Statuses
1898
1900
 
@@ -1939,6 +1941,7 @@ These docs are available locally in the repo and hosted at https://adcontextprot
1939
1941
  | Validate your agent (5-command checklist) | docs/guides/VALIDATE-YOUR-AGENT.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/VALIDATE-YOUR-AGENT.md) |
1940
1942
  | Async patterns (polling, webhooks, deferred) | docs/guides/ASYNC-DEVELOPER-GUIDE.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/ASYNC-DEVELOPER-GUIDE.md) |
1941
1943
  | Async API reference | docs/guides/ASYNC-API-REFERENCE.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/ASYNC-API-REFERENCE.md) |
1944
+ | Durable task settlement intents | docs/guides/DURABLE-TASK-SETTLEMENT.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/DURABLE-TASK-SETTLEMENT.md) |
1942
1945
  | Input handler patterns | docs/guides/HANDLER-PATTERNS-GUIDE.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/HANDLER-PATTERNS-GUIDE.md) |
1943
1946
  | Webhook configuration | docs/guides/PUSH-NOTIFICATION-CONFIG.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/PUSH-NOTIFICATION-CONFIG.md) |
1944
1947
  | Real-world code examples | docs/guides/REAL-WORLD-EXAMPLES.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/REAL-WORLD-EXAMPLES.md) |
@@ -244,7 +244,7 @@ loading; keep using `requires_capability` for a singular predicate.
244
244
  12. Replace webhook emitter `operation_id` arguments with SDK-local `delivery_id` values and upgrade custom stores to `WebhookDeliveryStore`. One delivery ID binds one canonical payload and key; use a fresh delivery ID for each changed status observation while retaining the AdCP `operation_id` inside the payload.
245
245
  13. Ensure custom 3.2 buyers include `push_notification_config.operation_id`, and update A2A integrations to keep the AdCP registration in skill parameters even when native A2A push configuration is also present.
246
246
  14. Treat failed/rejected task results as canonical terminal artifacts when `include_result` is requested; do not discard them while preserving only the summary error.
247
- 15. Persist the complete `ScopedTaskRef` for out-of-process task settlement and acknowledge durable queue items only after `applied` or a compatible `already_terminal` outcome with the intended status. Retry or dead-letter scoped misses and conflicting terminal outcomes. Upgrade populated PostgreSQL task registries with the phased [`getDecisioningTaskRegistryScopeV1Upgrade()` runbook](./migration-task-registry-scoping.md#populated-postgresql-upgrade), not application-boot bootstrap DDL.
247
+ 15. Persist the complete `ScopedTaskRef` for out-of-process task settlement and acknowledge durable queue items only after `applied` or after reading back an `already_terminal` task and proving its exact result/error artifact. Matching terminal status alone is insufficient. Retry or dead-letter scoped misses and conflicting terminal outcomes. Upgrade populated PostgreSQL task registries with the phased [`getDecisioningTaskRegistryScopeV1Upgrade()` runbook](./migration-task-registry-scoping.md#populated-postgresql-upgrade), not application-boot bootstrap DDL.
248
248
  16. For out-of-process settlement, return `ctx.handoffToTask(producer, { settlement: 'external' })`; the producer must durably queue the complete handle before returning, and the framework withholds `submitted` until that commit succeeds. For a push-enabled task, configure `createPostgresTaskSettlementCoordinator()` on the same PostgreSQL pool as the task registry and use `completeScopedPushTask()` / `failScopedPushTask()`. Run the webhook recovery outbox migration and recovery worker; the polling-only scoped helpers still reject push tasks. See [task registry scope migration](./migration-task-registry-scoping.md#out-of-process-settlement).
249
249
  17. Upgrade to Node `^20.19.0 || >=22.12.0`, whose two boundaries enable the `require(esm)` support needed by the SDK's CommonJS dependency graph. Node 21 and Node 22.0–22.11 are not supported. Keep Undici 6 for the fully supported configuration, or use the tested best-effort Undici 7 override on Node 20.19+. See the [Node/Undici compatibility policy](./guides/NODE-UNDICI-COMPATIBILITY.md).
250
250
 
@@ -40,10 +40,12 @@ state: persist the complete object, and never include it in the submitted
40
40
  envelope, webhook payload, logs, or any other buyer-visible surface.
41
41
 
42
42
  ```ts
43
+ import { isDeepStrictEqual } from 'node:util';
43
44
  import {
45
+ canonicalizeTaskSettlementIntent,
44
46
  completeScopedTask,
45
47
  failScopedTask,
46
- type ScopedTaskRef,
48
+ type DurableTaskSettlementRef,
47
49
  } from '@adcp/sdk/server';
48
50
 
49
51
  return ctx.handoffToTask(async taskCtx => {
@@ -54,25 +56,54 @@ return ctx.handoffToTask(async taskCtx => {
54
56
 
55
57
  // A different process after restart:
56
58
  const item = await approvals.claim();
57
- const taskRef = item.taskRef as ScopedTaskRef;
58
- const outcome = item.approved
59
- ? await completeScopedTask(registry, taskRef, item.result)
60
- : await failScopedTask(registry, taskRef, item.error, item.failureArtifact);
59
+ const taskRef = item.taskRef as DurableTaskSettlementRef;
60
+ const intent = canonicalizeTaskSettlementIntent(
61
+ item.approved
62
+ ? { taskRef, action: 'complete', result: item.result }
63
+ : {
64
+ taskRef,
65
+ action: 'fail',
66
+ error: item.error,
67
+ result: item.failureArtifact,
68
+ }
69
+ );
70
+ const outcome =
71
+ intent.action === 'complete'
72
+ ? await completeScopedTask(registry, taskRef, intent.result)
73
+ : await failScopedTask(registry, taskRef, intent.error, intent.result);
61
74
 
62
75
  if (outcome.outcome === 'not_found_in_scope') {
63
76
  // Do not acknowledge: unknown id, namespace mismatch, account mismatch,
64
77
  // owner mismatch, and deleted rows deliberately share this result.
65
78
  await approvals.retryOrDeadLetter(item, 'registry scope did not match');
66
- } else if (
67
- outcome.outcome === 'already_terminal' &&
68
- outcome.status !== (item.approved ? 'completed' : 'failed')
69
- ) {
70
- // The task reached a different terminal disposition. Do not acknowledge the
71
- // requested settlement as successful; reconcile or dead-letter it.
72
- await approvals.retryOrDeadLetter(item, `conflicting terminal state: ${outcome.status}`);
79
+ } else if (outcome.outcome === 'already_terminal') {
80
+ const stored = await registry.getTask(taskRef.taskId, taskRef);
81
+ let storedIntent;
82
+ if (stored?.status === 'completed' && Object.hasOwn(stored, 'result')) {
83
+ storedIntent = canonicalizeTaskSettlementIntent({
84
+ taskRef,
85
+ action: 'complete',
86
+ result: stored.result,
87
+ });
88
+ } else if (stored?.status === 'failed' && stored.error) {
89
+ storedIntent = canonicalizeTaskSettlementIntent({
90
+ taskRef,
91
+ action: 'fail',
92
+ error: stored.error,
93
+ ...(Object.hasOwn(stored, 'result') && { result: stored.result }),
94
+ });
95
+ }
96
+ const exactArtifact = storedIntent !== undefined && isDeepStrictEqual(storedIntent, intent);
97
+
98
+ if (exactArtifact) {
99
+ await approvals.ack(item);
100
+ } else {
101
+ // Matching status alone is insufficient: a different terminal artifact is
102
+ // a conflict and must remain available for reconciliation/dead-lettering.
103
+ await approvals.retryOrDeadLetter(item, 'conflicting terminal artifact');
104
+ }
73
105
  } else {
74
- // `applied`, or an already-terminal result with the intended disposition,
75
- // is safe to acknowledge idempotently.
106
+ // Only the mutation applied by this worker is safe to acknowledge directly.
76
107
  await approvals.ack(item);
77
108
  }
78
109
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adcp/sdk",
3
- "version": "14.0.0-beta.17",
3
+ "version": "14.0.0-beta.19",
4
4
  "description": "AdCP SDK — client, server, and compliance harnesses for the AdContext Protocol (MCP + A2A)",
5
5
  "workspaces": [
6
6
  ".",
@@ -470,6 +470,7 @@
470
470
  "docs/migration-*.md",
471
471
  "MIGRATION-*.md",
472
472
  "docs/guides/BUILD-AN-AGENT.md",
473
+ "docs/guides/DURABLE-TASK-SETTLEMENT.md",
473
474
  "docs/guides/MEDIA-BUY-3.2-COMPATIBILITY.md",
474
475
  "docs/guides/CANONICAL-REFERENCE-RESOLVER.md",
475
476
  "docs/guides/PREVIEW-ASSET-DURABILITY.md",