@adcp/sdk 15.7.1 → 15.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/lib/core/SingleAgentClient.d.mts +14 -0
  2. package/dist/lib/core/SingleAgentClient.d.ts +14 -0
  3. package/dist/lib/core/SingleAgentClient.js +138 -54
  4. package/dist/lib/core/SingleAgentClient.mjs +141 -54
  5. package/dist/lib/core/agent-discovery-cache.d.mts +2 -0
  6. package/dist/lib/core/agent-discovery-cache.d.ts +2 -0
  7. package/dist/lib/core/creative-schema-support.d.mts +3 -0
  8. package/dist/lib/core/creative-schema-support.d.ts +3 -0
  9. package/dist/lib/core/creative-schema-support.js +113 -0
  10. package/dist/lib/core/creative-schema-support.mjs +89 -0
  11. package/dist/lib/core/product-cache.js +4 -3
  12. package/dist/lib/core/product-cache.mjs +4 -3
  13. package/dist/lib/reporting/ledger/handler.d.mts +10 -3
  14. package/dist/lib/reporting/ledger/handler.d.ts +10 -3
  15. package/dist/lib/reporting/ledger/handler.js +17 -4
  16. package/dist/lib/reporting/ledger/handler.mjs +17 -4
  17. package/dist/lib/reporting/ledger/managed.d.mts +3 -1
  18. package/dist/lib/reporting/ledger/managed.d.ts +3 -1
  19. package/dist/lib/reporting/ledger/managed.js +4 -1
  20. package/dist/lib/reporting/ledger/managed.mjs +4 -1
  21. package/dist/lib/reporting/ledger/postgres.d.mts +87 -0
  22. package/dist/lib/reporting/ledger/postgres.d.ts +87 -0
  23. package/dist/lib/reporting/ledger/postgres.js +314 -13
  24. package/dist/lib/reporting/ledger/postgres.mjs +314 -13
  25. package/dist/lib/reporting/ledger/row-storage.d.mts +23 -0
  26. package/dist/lib/reporting/ledger/row-storage.d.ts +23 -0
  27. package/dist/lib/reporting/ledger/row-storage.js +56 -0
  28. package/dist/lib/reporting/ledger/row-storage.mjs +56 -0
  29. package/dist/lib/reporting/ledger/types.d.mts +26 -0
  30. package/dist/lib/reporting/ledger/types.d.ts +26 -0
  31. package/dist/lib/reporting/service/index.d.mts +33 -3
  32. package/dist/lib/reporting/service/index.d.ts +33 -3
  33. package/dist/lib/reporting/service/index.js +20 -2
  34. package/dist/lib/reporting/service/index.mjs +20 -2
  35. package/dist/lib/reporting/service/maintenance.d.mts +20 -1
  36. package/dist/lib/reporting/service/maintenance.d.ts +20 -1
  37. package/dist/lib/reporting/service/maintenance.js +25 -2
  38. package/dist/lib/reporting/service/maintenance.mjs +25 -2
  39. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  40. package/dist/lib/testing/storyboard/request-builder.js +14 -16
  41. package/dist/lib/testing/storyboard/request-builder.mjs +14 -16
  42. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  43. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  44. package/dist/lib/v2/projection/creative-delivery.d.mts +22 -0
  45. package/dist/lib/v2/projection/creative-delivery.d.ts +22 -0
  46. package/dist/lib/v2/projection/creative-delivery.js +92 -0
  47. package/dist/lib/v2/projection/creative-delivery.mjs +91 -0
  48. package/dist/lib/v2/projection/legacy-metadata.d.mts +6 -3
  49. package/dist/lib/v2/projection/legacy-metadata.d.ts +6 -3
  50. package/dist/lib/v2/projection/legacy-metadata.js +17 -0
  51. package/dist/lib/v2/projection/legacy-metadata.mjs +15 -0
  52. package/dist/lib/version.d.mts +3 -3
  53. package/dist/lib/version.d.ts +3 -3
  54. package/dist/lib/version.js +3 -3
  55. package/dist/lib/version.mjs +3 -3
  56. package/docs/TYPE-SUMMARY.md +2 -2
  57. package/docs/guides/CREATIVE-DELIVERY.md +16 -0
  58. package/docs/guides/REPORTING-LEDGER-CUTOVER.md +405 -0
  59. package/docs/guides/REPORTING-LEDGER.md +130 -0
  60. package/docs/guides/REPORTING-OPERATIONS.md +12 -0
  61. package/docs/llms.txt +3 -2
  62. package/docs/migration-14.x-rc-worksheet.md +4 -4
  63. package/package.json +1 -1
@@ -135,6 +135,12 @@ tool is never reused. Unknown peers receive a legacy projection only when the
135
135
  selected product provides one unambiguous ref. Ambiguous or unavailable mappings throw
136
136
  `CreativeFormatProjectionError` before transport.
137
137
 
138
+ Schema inspection follows local JSON Pointer `$ref` targets, including `$defs`,
139
+ and retains the complete listed document through shared discovery-cache reuse.
140
+ Unused definitions and annotations do not establish support. External references,
141
+ named anchors, and dynamic references are not resolved; inspection never fetches
142
+ schema URLs. Cycles and traversal limits cannot force a guessed wire mode.
143
+
138
144
  Before building creatives, ask the client which mode that write will use:
139
145
 
140
146
  ```ts
@@ -181,6 +187,16 @@ including polling, deferred, and webhook completions. A creative assigned to
181
187
  multiple packages downgrades only when every package resolves to the same
182
188
  legacy ref set.
183
189
 
190
+ For a canonical seller whose product declares only legacy `format_ids`, those
191
+ authored option refs name IDs minted locally by the SDK. After discovering the
192
+ product with the same client, create/update writes send the equivalent
193
+ `format_kind` + `params` selector and sync writes drop undeclared creative pins.
194
+ Product-cache snapshots retain this seller-origin evidence privately. A selection
195
+ that needs multiple direct selectors, or an undeclared custom format that needs
196
+ its schema identity, fails with `ADCP_CREATIVE_FORMAT_PROJECTION_FAILED` before
197
+ dispatch. Seller-declared option refs remain unchanged. Re-run `getProducts()`
198
+ after restarting before writing selections derived from legacy-only products.
199
+
184
200
  After a process restart, re-run product and media-buy reads that carry enough
185
201
  selector context or configure the explicit canonical-to-legacy resolver below.
186
202
  An accountless write never consumes a tenant-scoped cached package route based
@@ -0,0 +1,405 @@
1
+ # Cutting over to the SDK reporting ledger from your own ledger tables
2
+
3
+ This guide is for a seller that already runs its own Reliable Reporting ledger
4
+ (tables for configuration generations, obligations and revisions, row files in
5
+ object storage and a warehouse, status issues and transitions) and wants
6
+ `@adcp/sdk` to own the ledger from now on. Read the
7
+ [ledger guide](REPORTING-LEDGER.md) first for the SDK side, and the
8
+ [operations runbook](REPORTING-OPERATIONS.md) for production practice.
9
+
10
+ The short version:
11
+
12
+ - Pick **one authoritative ledger per period**. The SDK ledger produces and
13
+ serves every period from a boundary you choose. Your tables become read-only.
14
+ - **Do not migrate data and do not dual-write.** The old tables keep answering
15
+ exact reads for old revision IDs until their retention lapses, through a
16
+ single read-only hook, then you drop them.
17
+ - Everything buyer-visible stays stable across the cutover: old revision IDs
18
+ keep resolving, and new ones come from the SDK.
19
+
20
+ ## Adopt the SDK tables, or implement the store over your own?
21
+
22
+ You have two ways to run the SDK's service on top of what you have.
23
+
24
+ | | Adopt `PostgresReportingLedgerStore` (recommended) | Implement `ReportingLedgerStore` over your tables |
25
+ | --- | --- | --- |
26
+ | Schema | The SDK's `adcp_reporting_*` tables, installed by `REPORTING_LEDGER_MIGRATION` and friends | Yours |
27
+ | Row storage | `rowStorage`: verified chunked rows in your GCS, S3 or Azure bucket, or PostgreSQL | You implement `readRevisionRows` and verify rows against the committed binding yourself |
28
+ | Change feed | `changeFeed: true`: `changesAfter`, current-revision reads, consumer cursors | You implement it, or go without host reads and the warehouse sink |
29
+ | Retention | `maintenance.retention`: period-aligned retirement honouring the change-feed hold | You implement `retireExpiredPeriods` and tombstones, or never retire |
30
+ | Maintenance | `maintenance`: snapshot sweep, abandoned-upload sweep, feed pruning | You write all of it |
31
+ | Warehouse | `@adcp/sdk/reporting/bigquery` sink | Not available: it reads the SDK store |
32
+ | Correctness | Lease fencing, gap-free status checkpoints, immutable generations and the revision binding are enforced in one tested place | Every one of those guarantees is yours to prove |
33
+ | Existing data | Starts empty; old tables stay behind a read-only hook | Old data served natively |
34
+
35
+ Implementing the store over your own tables is a large surface: the store is
36
+ the correctness boundary for leases, fencing, immutability, snapshot
37
+ checkpoints and the binding digest. You also forgo row storage, the change
38
+ feed, retention and the sink, which are all implemented against the SDK
39
+ tables. Choose it only when a hard constraint forbids a second schema. For
40
+ almost every seller with an existing ledger, adopt the SDK tables and put the
41
+ old ones behind the [legacy fallback](#the-legacy-fallback-hook).
42
+
43
+ ## Core-only setup with GCS object rows
44
+
45
+ Run the SDK ledger in its own schema or database. The default table set is
46
+ deployment-wide, so the store requires `acknowledgeIsolatedDatabase: true`.
47
+ You do not need the production service: Core plus `maintenance` covers
48
+ production, serving, row storage, the change feed and retention. If you
49
+ advertise Managed Delivery or Reconciled Billing today, read
50
+ [What a Core-only cutover does not carry](#what-a-core-only-cutover-does-not-carry).
51
+
52
+ ```ts
53
+ import { Pool } from 'pg';
54
+ import { Storage } from '@google-cloud/storage';
55
+ import { PostgresReportingLedgerStore } from '@adcp/sdk/reporting/ledger';
56
+ import { createGcsReportingRowObjectProviderV1 } from '@adcp/sdk/reporting/gcs';
57
+ import { createReliableReportingService } from '@adcp/sdk/reporting/service';
58
+
59
+ // Apply once, in order, with your migration owner (PostgreSQL 13+):
60
+ // REPORTING_LEDGER_MIGRATION, REPORTING_ROW_STORAGE_MIGRATION,
61
+ // REPORTING_LEDGER_CHANGES_MIGRATION
62
+
63
+ const pool = new Pool({ connectionString: process.env.REPORTING_LEDGER_DATABASE_URL });
64
+ const store = new PostgresReportingLedgerStore(pool, {
65
+ acknowledgeIsolatedDatabase: true,
66
+ changeFeed: true,
67
+ rowStorage: {
68
+ deploymentNamespace: 'prod-us',
69
+ bindings: {
70
+ rows: {
71
+ kind: 'object',
72
+ provider: 'gcs',
73
+ location: { bucket: 'acme-reporting' }, // your existing bucket
74
+ prefix: 'adcp-ledger-rows', // a prefix the legacy writer never uses
75
+ compression: 'gzip',
76
+ },
77
+ },
78
+ providers: { gcs: createGcsReportingRowObjectProviderV1({ storage: new Storage() }) },
79
+ selectRowBinding: () => 'rows',
80
+ },
81
+ });
82
+ await store.readyRowStorage();
83
+
84
+ const reporting = createReliableReportingService({
85
+ store,
86
+ statusRetentionDays: 90, // the commitment advertised to buyers; retention uses it
87
+ // ...adapters, contact, resolveSource, resolveCurrency, resolveCoverage
88
+ legacyRevisionReader, // see "The legacy fallback hook"
89
+ maintenance: {
90
+ retention: { enabled: true, recordRetentionDays: 120, limit: 100 },
91
+ changeFeed: { changeRetentionDays: 30, maxFeedHoldDays: 7 },
92
+ },
93
+ });
94
+ ```
95
+
96
+ With object row storage, PostgreSQL holds only revision headers, row-set and
97
+ chunk manifests (digests, sizes, object keys and versions) and lifecycle
98
+ state. The bytes live in your bucket.
99
+
100
+ - **Reusing your bucket.** Point the binding at the existing bucket with a
101
+ prefix the legacy writer never touches. Keys are `{prefix}/{namespace_key}/...`
102
+ with a hashed account segment, and the SDK only creates, reads and deletes
103
+ objects under that prefix.
104
+ - **Bucket requirements.** Uniform bucket-level access and enforced public
105
+ access prevention are required. A lifecycle `Delete` rule that can match live
106
+ objects under the SDK prefix (no `matchesPrefix`, or an overlapping one) makes
107
+ the GCS probe refuse the binding, so scope any bucket-wide lifecycle rule to
108
+ your legacy prefix. Keep retention-driven deletion in the ledger's pruning.
109
+ - **Namespace.** `deploymentNamespace` is mixed into every key. Keep it stable
110
+ across deploys and distinct per environment.
111
+ - **Maintenance.** `reporting.start({ deploymentWide: true, onError })` runs
112
+ `maintenance` at most once per `intervalMilliseconds`; or call
113
+ `reporting.runMaintenance()` from your own scheduler. Retention uses the
114
+ service's `statusRetentionDays`, so it can never retire a period earlier than
115
+ the retention you advertise. See
116
+ [Core-only setup](REPORTING-LEDGER.md#core-only-setup-with-row-storage-change-feed-and-retention).
117
+ Enable `retention` last.
118
+
119
+ ### Keeping your BigQuery tables fed
120
+
121
+ `@adcp/sdk/reporting/bigquery` follows the change feed and loads every
122
+ committed revision's verified rows into BigQuery tables you name
123
+ ([sink details](REPORTING-LEDGER.md#bigquery-warehouse-sink)). The warehouse
124
+ copy is derived and never served.
125
+
126
+ ```ts
127
+ const sink = createBigQueryReportingWarehouseSinkV1({
128
+ store,
129
+ db: pool,
130
+ bigquery: new BigQuery({ projectId: 'analytics-prod' }),
131
+ name: 'warehouse',
132
+ projectId: 'analytics-prod',
133
+ datasetId: 'reporting',
134
+ rowsTableId: 'media_buy_delivery_rows_v2', // new tables, not your legacy ones
135
+ revisionsTableId: 'media_buy_delivery_revisions_v2',
136
+ mapRow: row => ({ media_buy_id: row.media_buy_id, impressions: row.impressions, spend: row.spend }),
137
+ });
138
+ ```
139
+
140
+ Give the sink its **own new tables** rather than your legacy ones. It adds its
141
+ own fixed columns (`reporting_revision_id`, `reporting_obligation_id`,
142
+ `account_id`, `revision_number`, `finality`, `period_date`, `ordinal`), and
143
+ loading into a table another writer owns invites double counting. Give analysts
144
+ one view that `UNION ALL`s the legacy table, bounded to periods before the
145
+ cutover boundary, with the sink's `current_rows` view, bounded to periods from
146
+ it. Query `current_rows`, never the raw rows table, because pacing snapshots
147
+ are cumulative.
148
+
149
+ ## Cutover, step by step
150
+
151
+ Choose a **cutover boundary** `B`: the start of a reporting period (for a daily
152
+ schedule, a midnight in the source timezone). Periods that start before `B`
153
+ belong to your legacy ledger forever. Periods that start at or after `B`
154
+ belong to the SDK ledger.
155
+
156
+ 1. **Stand up the SDK ledger without serving it.** Apply the migrations, build
157
+ the store and service, run `runMaintenance()`, and confirm the GCS probe
158
+ passes in each environment. Do not call `reporting.install(platform)` yet
159
+ and do not start its scheduler. Nothing is buyer-visible.
160
+ 2. **Wire the legacy fallback hook** (below) over your existing exact-revision
161
+ read, and exercise it in staging with a revision ID from your legacy tables.
162
+ 3. **Rehearse in staging** with a shadow service (see
163
+ [Shadow comparison](#shadow-comparison)) over a few real periods until row
164
+ counts, control totals and row digests match.
165
+ 4. **Install configurations in the last legacy period.** The SDK plans
166
+ obligations from `max(schedule.anchor, installedAt)`, rounded up to the next
167
+ period boundary. Install each active configuration through
168
+ `installConfiguration` (your `accounts.upsert` mapping) after the start of
169
+ the last legacy period and before `B`, with `schedule.anchor` no later than
170
+ `B`. The first period the SDK produces is then `B`, never the period in
171
+ progress. Verify that no SDK obligation exists for a period before `B`.
172
+ 5. **Stop the legacy writer for periods at or after `B`.** The legacy
173
+ production job must not plan or write any period starting at or after `B`,
174
+ and must not publish new revisions for earlier ones (see
175
+ [Corrections to pre-cutover periods](#corrections-to-pre-cutover-periods)).
176
+ Do this by configuration (disable the planner for the new periods), not by
177
+ revoking database permissions mid-write.
178
+ 6. **Switch the serving path.** Deploy with `reporting.install(platform)` and
179
+ `reporting.start({ deploymentWide: true, onError })`. From this deploy the
180
+ SDK's handlers answer `get_reporting_status` and `get_media_buy_delivery`,
181
+ the capability document is the SDK's, and exact reads for old revision IDs
182
+ flow through the fallback. Make the legacy tables read-only now (revoke
183
+ `INSERT`, `UPDATE` and `DELETE` from the application role).
184
+ 7. **Watch the first SDK period close:** worker progress, the first revision's
185
+ binding, a buyer exact read, the warehouse sink's `runOnce()` result, and a
186
+ legacy-ID exact read through the fallback.
187
+ 8. **Decommission** the legacy tables after retention
188
+ ([below](#decommissioning-the-old-tables)).
189
+
190
+ Because the boundary is a period boundary, no period is ever produced by both
191
+ ledgers, so no revision ID or `revision_content_sha256` is ever ambiguous.
192
+
193
+ ### What `get_reporting_status` shows after cutover
194
+
195
+ History starts at the cutover. The SDK ledger knows only the periods it owns,
196
+ from the first boundary at or after each configuration's install instant. A
197
+ status read over a wide range returns the SDK's obligations, revisions and
198
+ health for periods at or after `B` and nothing for earlier periods. Those
199
+ periods are outside the SDK's ownership, so they are absent rather than
200
+ reported as missing history. `HISTORY_UNAVAILABLE` is reserved for periods the
201
+ SDK owns whose obligation records are gone (retired by retention or
202
+ compacted).
203
+
204
+ Buyers that need pre-cutover data keep using the revision IDs they already
205
+ hold: exact `get_media_buy_delivery` reads for those IDs are served by the
206
+ fallback for as long as your legacy tables retain them. Tell buyers that status
207
+ listing starts at the boundary while exact reads of known revisions continue.
208
+
209
+ The SDK has no hook for listing pre-cutover periods through
210
+ `get_reporting_status`. Do not synthesize SDK obligations for old periods: that
211
+ is a data migration, with all the identity problems described below.
212
+
213
+ ### Corrections to pre-cutover periods
214
+
215
+ A late correction to a period that started before `B` needs a decision. The
216
+ SDK cannot produce a revision for a period it never owned, and your legacy
217
+ writer is read-only. Either keep the legacy writer enabled **for corrections of
218
+ pre-cutover periods only**, and keep those tables writable until the last such
219
+ period leaves its correction window, or handle them out of band. The rule that
220
+ must hold is that every period has exactly one authoritative writer.
221
+
222
+ ## The legacy fallback hook
223
+
224
+ `createReliableReportingService` (and
225
+ `createPostgresReliableReportingProductionService`) takes an optional
226
+ `legacyRevisionReader`. The SDK handler always answers first. Only when the SDK
227
+ ledger has no such revision for the resolved account does it call your reader
228
+ with the same request and context.
229
+
230
+ ```ts
231
+ import type { ReportingLegacyRevisionReaderV1 } from '@adcp/sdk/reporting/ledger';
232
+
233
+ const legacyRevisionReader: ReportingLegacyRevisionReaderV1 = async (request, context) => {
234
+ // `context.account` is the framework-resolved account, not request.account.
235
+ const accountId = (context.account as { id: string }).id;
236
+ const row = await legacyDb.findRevision({
237
+ reporting_revision_id: request.reporting_revision_id,
238
+ account_id: accountId, // scope by the resolved account
239
+ });
240
+ if (!row) return null; // not found here either
241
+ return buildGetMediaBuyDeliveryResponse(row, request.pagination);
242
+ };
243
+ ```
244
+
245
+ The contract:
246
+
247
+ - **Signature.** `(request, context) => Promise<GetMediaBuyDeliveryResponse | null | undefined>`.
248
+ The request and context are exactly what the SDK handler received, including
249
+ `pagination` and `context.signal`.
250
+ - **Not found.** Return `null` or `undefined`. The caller gets the SDK's own
251
+ nondisclosing `REFERENCE_NOT_FOUND` with `field: "reporting_revision_id"`,
252
+ identical to an unknown ID on a pure-SDK deployment.
253
+ - **Errors.** An error your reader throws propagates unchanged. Throw
254
+ `AdcpError('SERVICE_UNAVAILABLE')` for your own outages instead of returning
255
+ `null`, so buyers retry instead of concluding the revision is gone.
256
+ - **Authorization is yours.** The SDK has resolved the account and cannot judge
257
+ whether a legacy revision belongs to it. Your reader **must** scope the lookup
258
+ by `context.account` and return `null` for another account's revision. The SDK
259
+ also routes a request for an SDK-owned revision that belongs to a *different*
260
+ account to your reader, so it must never answer for a revision it cannot
261
+ attribute to the resolved account.
262
+ - **When it is called.** Only for a revision the SDK ledger does not hold for
263
+ the resolved account.
264
+ - **When it is never called.** For an SDK-owned revision that is expired, whose
265
+ rows are missing, corrupt or disagree with the committed binding, or that
266
+ comes with an unusable cursor; and on read capacity exhaustion or any other
267
+ error. Falling back there could serve different content under an ID the SDK
268
+ owns.
269
+ - **Read-only and bounded.** The hook must not write to the SDK ledger. The SDK
270
+ releases its per-account read slot before calling you, so bound your reader's
271
+ own concurrency and timeouts.
272
+ - **Shape.** Return a full `get_media_buy_delivery` response for that revision,
273
+ including `reporting_revision_binding` and `reporting_revision` with the
274
+ revision ID and `revision_content_sha256` your buyers already recorded. Page
275
+ rows with the same `pagination` contract; your cursors only need to round-trip
276
+ through your reader.
277
+ - **Identity.** Keep legacy revision IDs distinguishable from SDK IDs (`rrev_`
278
+ plus a 32-character digest) so a legacy ID can never collide with one the SDK
279
+ later derives.
280
+
281
+ ## Why not dual-write
282
+
283
+ A second authoritative ledger looks like a safe migration path and is the most
284
+ dangerous one.
285
+
286
+ - **Revision IDs and content hashes are buyer-visible.** The SDK derives a
287
+ revision ID from the obligation and the source publication, and
288
+ `revision_content_sha256` is the SHA-256 of the canonical JSON of
289
+ `{ reporting_revision_id, row_count, control_totals, reporting_rows }`. Two
290
+ ledgers writing the same period produce **two different IDs and two different
291
+ hashes**, and the buyer's receipts bind to whichever one they saw.
292
+ - **Divergence is silent.** Two writers can disagree on a restatement, an
293
+ official finality decision or an adjustment, and nothing detects it until a
294
+ buyer reconciles a bill against a revision the other ledger never recorded.
295
+ - **Immutability is per ledger.** An official revision is terminal in one
296
+ ledger while the other may still treat the period as open.
297
+ - **Leases and fencing do not span ledgers.** Each fences only its own writers.
298
+
299
+ A **shadow write for comparison is fine** as long as it is never served: a
300
+ separate database, never installed on the platform, never advertised, never
301
+ notifying. The moment a shadow ledger can answer a buyer, it is a second
302
+ authoritative ledger.
303
+
304
+ ## Shadow comparison
305
+
306
+ Before the boundary, run the SDK service against a separate shadow database and
307
+ feed it the same source periods. Compare what the two ledgers committed for the
308
+ same period. Do not compare `revision_content_sha256` directly: it includes the
309
+ revision ID, and IDs differ between ledgers by construction. Compare the inputs
310
+ to that hash:
311
+
312
+ ```ts
313
+ import { createHash } from 'node:crypto';
314
+ import { canonicalize } from '@adcp/sdk';
315
+
316
+ const rowsDigest = (rows: readonly unknown[]) => createHash('sha256').update(canonicalize(rows), 'utf8').digest('hex');
317
+
318
+ // SDK side, against the shadow store
319
+ const current = await shadowStore.getCurrentRevision({ account_id, reporting_obligation_id });
320
+ const page = await shadowStore.readRevisionRows({
321
+ reporting_revision_id: current.reporting_revision_id,
322
+ account_id,
323
+ offset: 0,
324
+ limit: Math.max(1, current.binding.rowCount),
325
+ });
326
+
327
+ // Compare with the legacy revision for the same account and period:
328
+ // row count, control totals (name and exact decimal value), rowsDigest(rows)
329
+ ```
330
+
331
+ - **Parity means** equal row count, equal `control_totals`, and equal canonical
332
+ row digests. Array order is part of the canonical form, so sort both sides by
333
+ your natural key first if your legacy writer does not guarantee order.
334
+ - **Expected differences:** revision ID, `revision_content_sha256`, timestamps,
335
+ freshness and manifest evidence.
336
+ - Run the comparison until a full set of periods, including one restatement,
337
+ matches. Then tear the shadow down; do not leave a second ledger running.
338
+
339
+ ## Decommissioning the old tables
340
+
341
+ After cutover the legacy tables answer exact reads for revision IDs buyers
342
+ already hold. The retention you advertised bounds how long.
343
+
344
+ 1. The last legacy period ends at `B`. Keep its revisions for the legacy
345
+ deployment's advertised `status_retention_days`, counted from that close.
346
+ 2. When that window has lapsed for every legacy period, the fallback has
347
+ nothing buyers can still legitimately ask for. Remove `legacyRevisionReader`,
348
+ deploy, and confirm an exact read of an old ID returns `REFERENCE_NOT_FOUND`.
349
+ 3. Stop the legacy export jobs, drop the legacy tables, delete or archive the
350
+ legacy objects (under their own prefix, never under the SDK prefix), and
351
+ retire the legacy BigQuery tables once analysts have moved to the new view.
352
+
353
+ Keep the legacy tables for the whole window, not only until the last revision
354
+ was read: buyers verify revisions up to the advertised retention, and that value
355
+ is a commitment. The SDK's `statusRetentionDays` applies to new periods only.
356
+
357
+ ## What a Core-only cutover does not carry
358
+
359
+ Core-only advertises Reliable Reporting Core: `get_reporting_status` and
360
+ `get_media_buy_delivery`. It does not deliver files to buyer destinations
361
+ (Managed Delivery) and does not accept consumer receipts (Reconciled Billing).
362
+ If your legacy materializations feed buyer-owned destinations, either keep
363
+ delivering them from your own pipeline, reading revisions through
364
+ `listCurrentRevisions` or the change feed rather than from your old tables, or
365
+ adopt the production service with the managed-delivery add-on as a separate,
366
+ later step. Do not advertise a tier the new ledger does not implement.
367
+
368
+ ## Mapping from typical custom tables to the SDK tables
369
+
370
+ Your names will differ. Map by role.
371
+
372
+ | Your concept (typical custom table) | SDK table | Notes |
373
+ | --- | --- | --- |
374
+ | Configuration generation (`reporting_core_config_generation`) | `adcp_reporting_configurations` | One immutable generation per `(account_id, delivery_config_id, delivery_config_version)`, with a semantic fingerprint and the frozen source scope and currency in `data` (JSONB). Installed by `installConfiguration`; a changed configuration is a new generation. |
375
+ | Obligation (`reporting_obligation`) | `adcp_reporting_obligations` | One row per configuration and period ordinal: period bounds, `state` (`pending` or `terminal`), `next_attempt_at`, lease owner, generation and expiry. The rest is in `data`. |
376
+ | Revision with explicit columns (`reporting_revision`, 39 columns) | `adcp_reporting_revisions` | Promoted columns are `revision_id`, `obligation_id`, `revision_number`, `finality`, `kind`, `supersedes_revision_id` and `content_sha256`. The remainder (report definition and digest, profile, schema version, row count, control totals, freshness, manifest and finality evidence) is the wire revision inside `data` (JSONB). Do not recreate your 39 columns: use `getCurrentRevision`, `listCurrentRevisions` and the change feed. |
377
+ | Row files (GCS objects plus BigQuery) | `adcp_reporting_row_bindings`, `adcp_reporting_row_sets`, `adcp_reporting_row_chunks`, plus your bucket | Postgres holds the manifest only: digests, sizes, object key and native version per chunk. Rows are verified canonical JSONL chunks in the object store. An unchanged pulse shares its predecessor's row set. BigQuery is fed by the sink, not by the ledger. |
378
+ | Corrections after an official revision | `adcp_reporting_adjustments` | Immutable adjustments bound to the official revision, never superseding revisions. |
379
+ | Status issues | `adcp_reporting_issues` | Obligation-scoped issues with observed and resolved times. |
380
+ | Status transitions and lifecycle | `adcp_reporting_transitions`, `adcp_reporting_lifecycle_state` | Append-only health transitions with a sequence and a durable notified marker, plus a per-obligation reconciliation watermark. Current health is derived from these on read. |
381
+ | Materializations (delivery evidence for buyer destinations) | `adcp_reporting_materializations`, `adcp_reporting_managed_bindings`, `adcp_reporting_receipts` | Managed Delivery add-on only; absent in a Core-only deployment. |
382
+ | Snapshots as status read caches | `adcp_reporting_snapshots`, `adcp_reporting_checkpoints` | Ephemeral cursor state with a 15-minute expiry, swept by maintenance. Not a durable record. |
383
+ | Snapshots as intraday revisions | `adcp_reporting_revisions` with `finality = 'snapshot'` | A pacing snapshot is a revision whose finality is `snapshot`; `official` is terminal. |
384
+ | Retired periods | `adcp_reporting_obligation_tombstones` | A compact record that refuses replays and preserves the audit conclusion after retention deletes a period. |
385
+ | Change log | `adcp_reporting_changes`, `adcp_reporting_feed_consumers` (plus horizons) | With `changeFeed: true`. Replaces polling your revision table. |
386
+ | Row upload bookkeeping | `adcp_reporting_object_write_*` | Intent and fencing records for object uploads. |
387
+
388
+ ## Cutover checklist
389
+
390
+ - [ ] SDK ledger in an isolated schema or database, migrations applied, probes
391
+ green in every environment.
392
+ - [ ] Row bucket prefix is separate from legacy objects; no bucket lifecycle rule
393
+ can delete live SDK objects.
394
+ - [ ] `legacyRevisionReader` scopes by `context.account`, returns `null` for
395
+ unknown, and throws for its own outages.
396
+ - [ ] Cutover boundary `B` chosen; each configuration installed during the last
397
+ legacy period; no SDK obligation exists before `B`.
398
+ - [ ] Legacy writer disabled for periods at or after `B` (and a correction path
399
+ for earlier periods decided).
400
+ - [ ] Legacy tables read-only after the switch deploy.
401
+ - [ ] Buyers told: status listing starts at `B`; old revision IDs keep working
402
+ until the legacy retention lapses.
403
+ - [ ] Shadow ledger (if any) is unreachable from the serving path.
404
+ - [ ] Decommission date recorded: end of the last legacy period plus the legacy
405
+ `status_retention_days`.
@@ -233,6 +233,14 @@ empty revision association. A deployment with per-tenant workers should pass
233
233
  the resolved `account_id` to both `planObligations()` and `runWorker()`;
234
234
  omitting it intentionally runs a deployment-wide worker.
235
235
 
236
+ ### Already running your own ledger tables?
237
+
238
+ Do not dual-write, and do not migrate data. Cut over at a period boundary: the
239
+ SDK ledger produces and serves new periods, and your old tables become
240
+ read-only behind the optional `legacyRevisionReader`, which answers exact
241
+ `get_media_buy_delivery` reads only for revisions the SDK ledger does not hold.
242
+ See [Cutting over to the SDK reporting ledger](REPORTING-LEDGER-CUTOVER.md).
243
+
236
244
  ### Migrating an existing manual lifecycle
237
245
 
238
246
  Keep the same `PostgresReportingLedgerStore` and run the same
@@ -373,6 +381,22 @@ await store.readyRowStorage();
373
381
  segment, never a raw account ID. Override `keyTemplate` with the placeholders
374
382
  in `REPORTING_ROW_KEY_PLACEHOLDERS`; templates must include
375
383
  `{content_sha256}` and `{chunk_index}`.
384
+ Enabling snapshot compaction additionally requires `{revision_key}` (see
385
+ [Compacting superseded snapshots](#compacting-superseded-snapshots-opt-in)).
386
+ - A custom `keyTemplate` without `{account_key}` and `{revision_key}` can map
387
+ row sets from different obligations or accounts with identical chunk content
388
+ (an adjustment's digest covers its rows alone) to one object, which the
389
+ second writer adopts. Every delete path (period retirement, compaction and
390
+ the intent sweep) therefore skips an object while a live row set outside the
391
+ operation still references the same binding, key and native version, or an
392
+ open write intent created it. The reference check and the delete run in one
393
+ transaction under a per-object advisory lock, and a commit that adopts an
394
+ existing object takes the same lock, re-reads the object and holds the lock
395
+ until its chunk rows commit. A delete that wins the lock makes the adopting
396
+ commit fail with `STATE_UNAVAILABLE` (the writer re-uploads once its intent
397
+ lapses); a commit that wins is seen by the delete, which skips. A skipped
398
+ object is deleted by the retirement, compaction or sweep of the last row set
399
+ that referenced it, so it is not leaked.
376
400
  - An upload runs only while the obligation lease still covers
377
401
  `writeDeadlineMilliseconds` plus five seconds. A fenced write intent records
378
402
  every object the upload created, and the ledger commit closes the intent in
@@ -546,6 +570,111 @@ because the retired obligations no longer exist.
546
570
  always sweeps abandoned row uploads and expired cursor snapshots and
547
571
  checkpoints, and retires periods when retention is enabled.
548
572
 
573
+ ### Compacting superseded snapshots (opt-in)
574
+
575
+ > **Do not enable this in production before the next eligible AdCP minor after
576
+ > November 20, 2026.** Until then the published protocol still describes
577
+ > superseded snapshots as preserved. The option is off by default, and
578
+ > retaining the whole chain, as the ledger does by default, stays conformant.
579
+
580
+ [adcontextprotocol/adcp#8100](https://github.com/adcontextprotocol/adcp/pull/8100)
581
+ separates the two things `status_retention_days` covers. **Metadata** for every
582
+ revision stays queryable for the whole window. **Content** (the rows) is owed
583
+ only for the revisions a buyer is likely to read, so a seller that publishes a
584
+ snapshot every 15 minutes need not keep thousands of full snapshots per period.
585
+
586
+ The window runs from the revision's `created_at`. Once a later revision names it
587
+ in `supersedes_reporting_revision_id`, it runs from that successor's
588
+ `created_at` instead. Within its window the seller must keep content readable
589
+ by exact read when the revision is:
590
+
591
+ - (a) the current, unsuperseded revision of its slice, which includes every
592
+ official revision;
593
+ - (b) the revision the current revision supersedes;
594
+ - (c) a superseded snapshot inside its recovery grace, `max(automated recovery
595
+ window, one hour)` after its successor's `created_at`;
596
+ - (d) named, for the requesting caller, by that caller's own reporting receipt
597
+ in any status, or by its current consumer status statement with status
598
+ `received`, `unreadable` or `content_mismatch`.
599
+
600
+ Any other superseded snapshot may be compacted to metadata only. An exact read
601
+ of a compacted revision returns the same nondisclosing `REFERENCE_NOT_FOUND` as
602
+ an unknown ID. A revision in (a) to (d) whose content cannot be served is
603
+ `SERVICE_UNAVAILABLE` (`REVISION_UNREADABLE`), never not-found.
604
+
605
+ ```ts
606
+ // Core-only service
607
+ createReliableReportingService({
608
+ // ...
609
+ maintenance: { compaction: { enabled: true, limit: 100 } },
610
+ });
611
+
612
+ // Production service
613
+ await createPostgresReliableReportingProductionService({
614
+ // ...
615
+ compaction: { enabled: true, limit: 100 },
616
+ });
617
+
618
+ // Or call the store directly
619
+ await store.compactSupersededSnapshots({
620
+ statusRetentionDays: 30,
621
+ automatedRecoveryWindowSeconds: 3600,
622
+ limit: 100,
623
+ });
624
+ ```
625
+
626
+ Both options take `{ enabled: true; limit?: number }` (default `limit` 100 per
627
+ pass) and reuse the service's `statusRetentionDays` and
628
+ `automatedRecoveryWindowSeconds`. The maintenance task is named
629
+ `snapshot compaction`.
630
+
631
+ A snapshot is compacted only when all of these hold:
632
+
633
+ - A later revision supersedes it, and it is not the revision the current
634
+ revision supersedes. Current and official revisions are never compacted.
635
+ - Its recovery grace has passed: `max(automatedRecoveryWindowSeconds, 3600)`
636
+ seconds since its successor's `created_at`.
637
+ - No caller is owed it. The check spans every caller: a receipt in any status
638
+ (current or superseded), or a current `received`, `unreadable` or
639
+ `content_mismatch` consumer status statement, from any caller keeps the
640
+ revision while its window (`statusRetentionDays` from its successor's
641
+ `created_at`) is open. After that window nothing is owed any more.
642
+ - The obligation holds no live worker lease, no Managed Delivery
643
+ materialization of that revision is still `pending`, and no live change-feed
644
+ consumer (updated within `maxFeedHoldDays`) has yet to pass that revision's
645
+ change row. This is the same hold retirement applies, narrowed to the
646
+ revision.
647
+ - It has rows. A revision with no rows has nothing to reclaim.
648
+
649
+ Compaction drops row content only. The revision header, its manifest and every
650
+ metadata listing stay, and `listRevisionMetadata` and the status views keep
651
+ listing it. `listRevisions`, which hydrates rows, omits compacted revisions.
652
+ The `revision` view of `get_reporting_status` for a compacted revision answers
653
+ as an unavailable lookup, like an exact read.
654
+
655
+ It uses the same three resumable phases as retirement. Under the account lock it
656
+ re-checks eligibility and marks the row set `pruning` (reads answer as expired;
657
+ no new revision can share its chunks). It then deletes external objects at their
658
+ recorded versions through the fenced delete path, skipping any object a row set
659
+ that is not being pruned still references. Finally it deletes the chunk
660
+ manifest and any PostgreSQL chunk body nothing else uses, and marks the row set
661
+ `pruned`. A crash after the first phase leaves a `pruning` row set that the next
662
+ pass finishes. Chunks shared by byte-identical revisions survive until the last
663
+ revision referencing them is compacted.
664
+
665
+ Compaction records no change-feed row: the revision's metadata did not change,
666
+ and a sink still catching up holds the revision back (above).
667
+
668
+ Compaction requires every object row binding's `keyTemplate` to include
669
+ `{revision_key}` (the default template does). Deletes now skip objects another
670
+ row set still references (see [Object storage](#object-storage-bindings)),
671
+ so this is defense in depth rather than the only guard: with `{revision_key}`
672
+ no two revisions ever name the same object, so compaction never depends on the
673
+ reference check. The SDK fails closed: `createReliableReportingService` and
674
+ `createPostgresReliableReportingProductionService` throw at construction when
675
+ compaction is enabled and a binding's template omits `{revision_key}`, and
676
+ `compactSupersededSnapshots` throws the same error before deleting anything.
677
+
549
678
  ### Core-only setup with row storage, change feed and retention
550
679
 
551
680
  You do not need the production service (managed delivery, notifications and
@@ -611,6 +740,7 @@ Tasks, in order:
611
740
  | `row upload sweep` | always | Resolves abandoned object uploads (100 per pass); no-op without row storage. |
612
741
  | `change-feed pruning` | when `maintenance.changeFeed` is set | `pruneChanges` with `changeRetentionDays` and `maxFeedHoldDays`. |
613
742
  | `retention` | when `maintenance.retention.enabled` is `true` | `retireExpiredPeriods` with the service's `statusRetentionDays` and the feed's `maxFeedHoldDays`. |
743
+ | `snapshot compaction` | when `maintenance.compaction.enabled` is `true` | `compactSupersededSnapshots` with the service's `statusRetentionDays` and `automatedRecoveryWindowSeconds` (see [Compacting superseded snapshots](#compacting-superseded-snapshots-opt-in)). |
614
744
 
615
745
  Notes:
616
746
 
@@ -120,6 +120,15 @@ service runs for you. See
120
120
  `status_retention_days` is the window you intend to keep. Retirement deletes
121
121
  rows and ledger records for whole periods and cannot be undone. Keep backups
122
122
  for any longer audit requirement.
123
+ 7. **Snapshot compaction (optional).** Off by default; enable `compaction`
124
+ (`maintenance.compaction` on the Core-only service) only to reclaim the
125
+ content of superseded snapshots. Do not enable it in production before the
126
+ next eligible AdCP minor after November 20, 2026, when the protocol takes
127
+ compaction into effect. Roll it out after retention, with a small `limit`,
128
+ and watch the `snapshot compaction` task's `failed` IDs. See
129
+ [Compacting superseded snapshots](REPORTING-LEDGER.md#compacting-superseded-snapshots-opt-in)
130
+ for the rule and what it keeps. Compaction cannot be undone; backups hold
131
+ any longer evidence.
123
132
 
124
133
  Alert on these row-storage signals:
125
134
 
@@ -127,6 +136,9 @@ Alert on these row-storage signals:
127
136
  normal, and buyers see `SERVICE_UNAVAILABLE` for the affected revision.
128
137
  - A growing count of `adcp_reporting_row_write_intents` in `sweeping` state,
129
138
  or `sweepRowWriteIntents` reporting failures. Provider deletes are failing.
139
+ - The `snapshot compaction` task reporting `failed` revisions. Provider deletes
140
+ are failing; the revisions stay `pruning` (reads already answer not-found) and
141
+ the next pass resumes them.
130
142
  - `retireExpiredPeriods` returning `failed` IDs. Usually a binding was removed
131
143
  from configuration while revisions still reference it; keep retired bindings
132
144
  configured.
package/docs/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # Ad Context Protocol (AdCP)
2
2
 
3
- > Generated at: 2026-10-10
4
- > Library: @adcp/sdk v15.7.1
3
+ > Generated at: 2026-10-11
4
+ > Library: @adcp/sdk v15.8.0
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.
@@ -2140,6 +2140,7 @@ These docs are available locally in the repo and hosted at https://adcontextprot
2140
2140
  | Conformance (property-based fuzzing) | docs/guides/CONFORMANCE.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/CONFORMANCE.md) |
2141
2141
  | Reporting source executor (seller adapters) | docs/guides/REPORTING-SOURCE-EXECUTOR.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/REPORTING-SOURCE-EXECUTOR.md) |
2142
2142
  | Seller reporting ledger | docs/guides/REPORTING-LEDGER.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/REPORTING-LEDGER.md) |
2143
+ | Reporting ledger cutover from custom tables | docs/guides/REPORTING-LEDGER-CUTOVER.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/REPORTING-LEDGER-CUTOVER.md) |
2143
2144
  | Buyer reporting reconciliation | docs/guides/REPORTING-RECONCILIATION.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/REPORTING-RECONCILIATION.md) |
2144
2145
  | Reliable Reporting production operations | docs/guides/REPORTING-OPERATIONS.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/REPORTING-OPERATIONS.md) |
2145
2146
  | Validate your agent (5-command checklist) | docs/guides/VALIDATE-YOUR-AGENT.md | [link](https://adcontextprotocol.github.io/adcp-client/guides/VALIDATE-YOUR-AGENT.md) |
@@ -11,8 +11,8 @@ below as the publication/deployment gate.
11
11
 
12
12
  | Fact | Value |
13
13
  | --- | --- |
14
- | Exact npm package | `@adcp/sdk@15.7.1` |
15
- | npm integrity | registry-derived after publication; run `npm view @adcp/sdk@15.7.1 dist.integrity` |
14
+ | Exact npm package | `@adcp/sdk@15.8.0` |
15
+ | npm integrity | registry-derived after publication; run `npm view @adcp/sdk@15.8.0 dist.integrity` |
16
16
  | Node.js runtime | `^20.19.0 || >=22.12.0` |
17
17
  | Default AdCP wire release | `3.2.3` |
18
18
  | Maintained wire releases | `v2.5`, `v2.6`, `v3`, `3.0.0`, `3.0`, `3.0.1`, `3.0.2`, `3.0.3`, `3.0.4`, `3.0.5`, `3.0.6`, `3.0.7`, `3.0.8`, `3.0.9`, `3.0.10`, `3.0.11`, `3.0.12`, `3.0.13`, `3.0.14`, `3.0.15`, `3.0.16`, `3.0.17`, `3.0.18`, `3.0.19`, `3.0.20`, `3.0.21`, `3.0.22`, `3.0.23`, `3.0.24`, `3.0.25`, `3.1.0`, `3.1`, `3.1.1`, `3.1.2`, `3.1.3`, `3.1.4`, `3.1.5`, `3.1.6`, `3.1.7`, `3.1.8`, `3.1.9`, `3.1.10`, `3.1.11`, `3.1.12`, `3.1.13`, `3.1.14`, `3.1.15`, `3.1.16`, `3.1.17`, `3.1.18`, `3.1.19`, `3.1.20`, `3.1.21`, `3.1.22`, `3.1.23`, `3.1.24`, `3.2.1`, `3.2`, `3.2.2`, `3.2.3` |
@@ -21,8 +21,8 @@ below as the publication/deployment gate.
21
21
  Install exact production inputs rather than a moving range or dist-tag:
22
22
 
23
23
  ```bash
24
- npm install --save-exact '@adcp/sdk@15.7.1'
25
- npm view '@adcp/sdk@15.7.1' dist.integrity
24
+ npm install --save-exact '@adcp/sdk@15.8.0'
25
+ npm view '@adcp/sdk@15.8.0' dist.integrity
26
26
  ```
27
27
 
28
28
  ### Required and optional peers