@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.
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/decisioning/context.d.mts +4 -0
- package/dist/lib/server/decisioning/context.d.ts +4 -0
- package/dist/lib/server/decisioning/index.d.mts +1 -0
- package/dist/lib/server/decisioning/index.d.ts +1 -0
- package/dist/lib/server/decisioning/index.js +11 -0
- package/dist/lib/server/decisioning/index.mjs +12 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.js +5 -3
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.mjs +5 -3
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.mts +136 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.ts +136 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.js +745 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.mjs +717 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.js +108 -36
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.mjs +108 -36
- package/dist/lib/server/decisioning/runtime/to-context.js +38 -9
- package/dist/lib/server/decisioning/runtime/to-context.mjs +38 -9
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- package/dist/lib/types/schemas.generated.js +0 -63
- package/dist/lib/types/schemas.generated.mjs +0 -63
- package/dist/lib/utils/well-formed-unicode.d.mts +2 -0
- package/dist/lib/utils/well-formed-unicode.d.ts +2 -0
- package/dist/lib/utils/well-formed-unicode.js +51 -0
- package/dist/lib/utils/well-formed-unicode.mjs +27 -0
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.mjs +3 -3
- package/docs/guides/BUILD-AN-AGENT.md +7 -0
- package/docs/guides/DURABLE-TASK-SETTLEMENT.md +468 -0
- package/docs/llms.txt +5 -2
- package/docs/migration-13-to-14.md +1 -1
- package/docs/migration-task-registry-scoping.md +45 -14
- 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-
|
|
4
|
-
> Library: @adcp/sdk v14.0.0-beta.
|
|
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
|
|
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
|
|
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
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
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.
|
|
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",
|