@adcp/sdk 16.0.0-beta.2 → 16.0.0-beta.4

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 (248) hide show
  1. package/README.md +54 -0
  2. package/dist/lib/auth/oauth/diagnose.js +3 -11
  3. package/dist/lib/auth/oauth/diagnose.mjs +4 -12
  4. package/dist/lib/auth/oauth/diagnostics.d.mts +2 -1
  5. package/dist/lib/auth/oauth/diagnostics.d.ts +2 -1
  6. package/dist/lib/auth/oauth/diagnostics.js +8 -10
  7. package/dist/lib/auth/oauth/diagnostics.mjs +7 -10
  8. package/dist/lib/core/ConversationTypes.d.mts +8 -3
  9. package/dist/lib/core/ConversationTypes.d.ts +8 -3
  10. package/dist/lib/core/SingleAgentClient.d.mts +5 -3
  11. package/dist/lib/core/SingleAgentClient.d.ts +5 -3
  12. package/dist/lib/core/TaskExecutor.js +5 -1
  13. package/dist/lib/core/TaskExecutor.mjs +5 -1
  14. package/dist/lib/errors/index.d.mts +9 -4
  15. package/dist/lib/errors/index.d.ts +9 -4
  16. package/dist/lib/index.d.mts +3 -3
  17. package/dist/lib/index.d.ts +3 -3
  18. package/dist/lib/index.js +8 -0
  19. package/dist/lib/index.mjs +14 -2
  20. package/dist/lib/negotiation/types.d.mts +20 -39
  21. package/dist/lib/negotiation/types.d.ts +20 -39
  22. package/dist/lib/protocols/index.d.mts +60 -24
  23. package/dist/lib/protocols/index.d.ts +60 -24
  24. package/dist/lib/protocols/index.js +9 -4
  25. package/dist/lib/protocols/index.mjs +3 -0
  26. package/dist/lib/protocols/mcp-modern.js +1 -0
  27. package/dist/lib/protocols/mcp-modern.mjs +2 -1
  28. package/dist/lib/protocols/mcp.js +1 -0
  29. package/dist/lib/protocols/mcp.mjs +2 -1
  30. package/dist/lib/protocols/responseSizeLimit.d.mts +91 -5
  31. package/dist/lib/protocols/responseSizeLimit.d.ts +91 -5
  32. package/dist/lib/protocols/responseSizeLimit.js +195 -31
  33. package/dist/lib/protocols/responseSizeLimit.mjs +192 -31
  34. package/dist/lib/reporting/consumer-runtime.js +2 -1
  35. package/dist/lib/reporting/consumer-runtime.mjs +2 -1
  36. package/dist/lib/reporting/ledger/handler.js +50 -11
  37. package/dist/lib/reporting/ledger/handler.mjs +50 -12
  38. package/dist/lib/reporting/ledger/index.d.mts +17 -14
  39. package/dist/lib/reporting/ledger/index.d.ts +17 -14
  40. package/dist/lib/reporting/ledger/index.js +32 -20
  41. package/dist/lib/reporting/ledger/index.mjs +21 -10
  42. package/dist/lib/reporting/ledger/offering-validation.d.mts +9 -0
  43. package/dist/lib/reporting/ledger/offering-validation.d.ts +9 -0
  44. package/dist/lib/reporting/{gcs/bucket-policy.js → ledger/offering-validation.js} +9 -8
  45. package/dist/lib/reporting/ledger/offering-validation.mjs +8 -0
  46. package/dist/lib/reporting/ledger/receipts.d.mts +48 -0
  47. package/dist/lib/reporting/ledger/receipts.d.ts +48 -0
  48. package/dist/lib/reporting/ledger/receipts.js +186 -0
  49. package/dist/lib/reporting/ledger/receipts.mjs +161 -0
  50. package/dist/lib/reporting/ledger/status-ingest.js +3 -3
  51. package/dist/lib/reporting/ledger/status-ingest.mjs +4 -5
  52. package/dist/lib/reporting/ledger/types.d.mts +43 -0
  53. package/dist/lib/reporting/ledger/types.d.ts +43 -0
  54. package/dist/lib/reporting/ledger/types.js +31 -2
  55. package/dist/lib/reporting/ledger/types.mjs +26 -1
  56. package/dist/lib/reporting/pagination.d.mts +6 -0
  57. package/dist/lib/reporting/pagination.d.ts +6 -0
  58. package/dist/lib/reporting/{gcs/errors.js → pagination.js} +12 -17
  59. package/dist/lib/reporting/pagination.mjs +12 -0
  60. package/dist/lib/reporting/reconciliation.d.mts +1 -0
  61. package/dist/lib/reporting/reconciliation.d.ts +1 -0
  62. package/dist/lib/reporting/reconciliation.js +3 -2
  63. package/dist/lib/reporting/reconciliation.mjs +3 -2
  64. package/dist/lib/reporting/service/index.d.mts +79 -153
  65. package/dist/lib/reporting/service/index.d.ts +79 -153
  66. package/dist/lib/reporting/service/index.js +34 -491
  67. package/dist/lib/reporting/service/index.mjs +36 -510
  68. package/dist/lib/reporting/service/scheduler-support.d.mts +43 -0
  69. package/dist/lib/reporting/service/scheduler-support.d.ts +43 -0
  70. package/dist/lib/reporting/service/scheduler-support.js +125 -0
  71. package/dist/lib/reporting/service/scheduler-support.mjs +96 -0
  72. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  73. package/dist/lib/signing/canonicalize.d.mts +4 -0
  74. package/dist/lib/signing/canonicalize.d.ts +4 -0
  75. package/dist/lib/signing/canonicalize.js +14 -8
  76. package/dist/lib/signing/canonicalize.mjs +14 -8
  77. package/dist/lib/signing/content-digest.d.mts +8 -0
  78. package/dist/lib/signing/content-digest.d.ts +8 -0
  79. package/dist/lib/signing/content-digest.js +11 -5
  80. package/dist/lib/signing/content-digest.mjs +10 -5
  81. package/dist/lib/signing/crypto.d.mts +15 -0
  82. package/dist/lib/signing/crypto.d.ts +15 -0
  83. package/dist/lib/signing/crypto.js +75 -3
  84. package/dist/lib/signing/crypto.mjs +78 -3
  85. package/dist/lib/signing/server.d.mts +1 -1
  86. package/dist/lib/signing/server.d.ts +1 -1
  87. package/dist/lib/signing/server.js +2 -0
  88. package/dist/lib/signing/server.mjs +2 -1
  89. package/dist/lib/signing/signer.js +11 -14
  90. package/dist/lib/signing/signer.mjs +13 -16
  91. package/dist/lib/signing/testing.js +2 -6
  92. package/dist/lib/signing/testing.mjs +3 -7
  93. package/dist/lib/signing/verifier.js +2 -2
  94. package/dist/lib/signing/verifier.mjs +3 -3
  95. package/dist/lib/signing/web-bot-auth.js +122 -43
  96. package/dist/lib/signing/web-bot-auth.mjs +128 -44
  97. package/dist/lib/signing/webhook-verifier.js +2 -2
  98. package/dist/lib/signing/webhook-verifier.mjs +3 -3
  99. package/dist/lib/substitution/compile.js +2 -1
  100. package/dist/lib/substitution/compile.mjs +2 -1
  101. package/dist/lib/substitution/consent-macros.d.mts +4 -0
  102. package/dist/lib/substitution/consent-macros.d.ts +4 -0
  103. package/dist/lib/substitution/consent-macros.js +45 -0
  104. package/dist/lib/substitution/consent-macros.mjs +20 -0
  105. package/dist/lib/substitution/index.d.mts +1 -1
  106. package/dist/lib/substitution/index.d.ts +1 -1
  107. package/dist/lib/substitution/index.js +2 -0
  108. package/dist/lib/substitution/index.mjs +2 -1
  109. package/dist/lib/substitution/translate.d.mts +15 -2
  110. package/dist/lib/substitution/translate.d.ts +15 -2
  111. package/dist/lib/substitution/translate.js +148 -26
  112. package/dist/lib/substitution/translate.mjs +147 -26
  113. package/dist/lib/testing/client.js +2 -1
  114. package/dist/lib/testing/client.mjs +2 -1
  115. package/dist/lib/testing/scenarios/capabilities.js +1 -1
  116. package/dist/lib/testing/scenarios/capabilities.mjs +1 -1
  117. package/dist/lib/testing/storyboard/junit.js +16 -3
  118. package/dist/lib/testing/storyboard/junit.mjs +16 -3
  119. package/dist/lib/testing/storyboard/request-signing/builder.js +12 -14
  120. package/dist/lib/testing/storyboard/request-signing/builder.mjs +13 -15
  121. package/dist/lib/testing/storyboard/runner.js +94 -25
  122. package/dist/lib/testing/storyboard/runner.mjs +94 -25
  123. package/dist/lib/testing/storyboard/signer-grader/grader.js +2 -7
  124. package/dist/lib/testing/storyboard/signer-grader/grader.mjs +2 -7
  125. package/dist/lib/testing/storyboard/types.d.mts +6 -0
  126. package/dist/lib/testing/storyboard/types.d.ts +6 -0
  127. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  128. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  129. package/dist/lib/types/enums.generated.d.mts +3 -0
  130. package/dist/lib/types/enums.generated.d.ts +3 -0
  131. package/dist/lib/types/enums.generated.js +9 -0
  132. package/dist/lib/types/enums.generated.mjs +6 -0
  133. package/dist/lib/validation/schema-loader.js +25 -0
  134. package/dist/lib/validation/schema-loader.mjs +24 -0
  135. package/dist/lib/version.d.mts +3 -3
  136. package/dist/lib/version.d.ts +3 -3
  137. package/dist/lib/version.js +3 -3
  138. package/dist/lib/version.mjs +3 -3
  139. package/docs/TYPE-SUMMARY.md +28 -5
  140. package/docs/guides/DISCOVERY-CACHE-AND-SESSION-REUSE.md +3 -2
  141. package/docs/guides/REPORTING-GCS-FENCE.md +3 -3
  142. package/docs/guides/REPORTING-GCS-MANAGED.md +4 -4
  143. package/docs/guides/REPORTING-LEDGER-CUTOVER.md +7 -7
  144. package/docs/guides/REPORTING-LEDGER.md +107 -51
  145. package/docs/guides/REPORTING-OPERATIONS.md +66 -5
  146. package/docs/guides/REPORTING-STABILITY.md +36 -32
  147. package/docs/guides/UNIVERSAL-MACRO-COMPILATION.md +38 -0
  148. package/docs/guides/VALIDATE-YOUR-AGENT.md +12 -0
  149. package/docs/llms.txt +4 -3
  150. package/docs/migration-14.x-rc-worksheet.md +4 -7
  151. package/docs/migration-reporting-storage-package.md +186 -0
  152. package/examples/README.md +1 -1
  153. package/package.json +14 -78
  154. package/dist/lib/reporting/azure/index.d.mts +0 -31
  155. package/dist/lib/reporting/azure/index.d.ts +0 -31
  156. package/dist/lib/reporting/azure/index.js +0 -266
  157. package/dist/lib/reporting/azure/index.mjs +0 -249
  158. package/dist/lib/reporting/bigquery/index.d.mts +0 -246
  159. package/dist/lib/reporting/bigquery/index.d.ts +0 -246
  160. package/dist/lib/reporting/bigquery/index.js +0 -633
  161. package/dist/lib/reporting/bigquery/index.mjs +0 -594
  162. package/dist/lib/reporting/gcs/adapter.d.mts +0 -38
  163. package/dist/lib/reporting/gcs/adapter.d.ts +0 -38
  164. package/dist/lib/reporting/gcs/adapter.js +0 -342
  165. package/dist/lib/reporting/gcs/adapter.mjs +0 -325
  166. package/dist/lib/reporting/gcs/bucket-policy.d.mts +0 -3
  167. package/dist/lib/reporting/gcs/bucket-policy.d.ts +0 -3
  168. package/dist/lib/reporting/gcs/bucket-policy.mjs +0 -7
  169. package/dist/lib/reporting/gcs/errors.d.mts +0 -11
  170. package/dist/lib/reporting/gcs/errors.d.ts +0 -11
  171. package/dist/lib/reporting/gcs/errors.mjs +0 -16
  172. package/dist/lib/reporting/gcs/fence.d.mts +0 -41
  173. package/dist/lib/reporting/gcs/fence.d.ts +0 -41
  174. package/dist/lib/reporting/gcs/fence.js +0 -250
  175. package/dist/lib/reporting/gcs/fence.mjs +0 -230
  176. package/dist/lib/reporting/gcs/index.d.mts +0 -18
  177. package/dist/lib/reporting/gcs/index.d.ts +0 -18
  178. package/dist/lib/reporting/gcs/index.js +0 -44
  179. package/dist/lib/reporting/gcs/index.mjs +0 -14
  180. package/dist/lib/reporting/gcs/io.d.mts +0 -7
  181. package/dist/lib/reporting/gcs/io.d.ts +0 -7
  182. package/dist/lib/reporting/gcs/io.js +0 -95
  183. package/dist/lib/reporting/gcs/io.mjs +0 -69
  184. package/dist/lib/reporting/gcs/reader.d.mts +0 -67
  185. package/dist/lib/reporting/gcs/reader.d.ts +0 -67
  186. package/dist/lib/reporting/gcs/reader.js +0 -193
  187. package/dist/lib/reporting/gcs/reader.mjs +0 -169
  188. package/dist/lib/reporting/gcs/row-provider.d.mts +0 -39
  189. package/dist/lib/reporting/gcs/row-provider.d.ts +0 -39
  190. package/dist/lib/reporting/gcs/row-provider.js +0 -225
  191. package/dist/lib/reporting/gcs/row-provider.mjs +0 -208
  192. package/dist/lib/reporting/gcs/validation.d.mts +0 -5
  193. package/dist/lib/reporting/gcs/validation.d.ts +0 -5
  194. package/dist/lib/reporting/gcs/validation.js +0 -49
  195. package/dist/lib/reporting/gcs/validation.mjs +0 -25
  196. package/dist/lib/reporting/ledger/change-feed.d.mts +0 -69
  197. package/dist/lib/reporting/ledger/change-feed.d.ts +0 -69
  198. package/dist/lib/reporting/ledger/change-feed.js +0 -107
  199. package/dist/lib/reporting/ledger/change-feed.mjs +0 -80
  200. package/dist/lib/reporting/ledger/managed-postgres.d.mts +0 -293
  201. package/dist/lib/reporting/ledger/managed-postgres.d.ts +0 -293
  202. package/dist/lib/reporting/ledger/managed-postgres.js +0 -2346
  203. package/dist/lib/reporting/ledger/managed-postgres.mjs +0 -2332
  204. package/dist/lib/reporting/ledger/managed-router.d.mts +0 -148
  205. package/dist/lib/reporting/ledger/managed-router.d.ts +0 -148
  206. package/dist/lib/reporting/ledger/managed-router.js +0 -280
  207. package/dist/lib/reporting/ledger/managed-router.mjs +0 -258
  208. package/dist/lib/reporting/ledger/managed.d.mts +0 -535
  209. package/dist/lib/reporting/ledger/managed.d.ts +0 -535
  210. package/dist/lib/reporting/ledger/managed.js +0 -761
  211. package/dist/lib/reporting/ledger/managed.mjs +0 -729
  212. package/dist/lib/reporting/ledger/notification-activity.d.mts +0 -332
  213. package/dist/lib/reporting/ledger/notification-activity.d.ts +0 -332
  214. package/dist/lib/reporting/ledger/notification-activity.js +0 -1366
  215. package/dist/lib/reporting/ledger/notification-activity.mjs +0 -1337
  216. package/dist/lib/reporting/ledger/object-writes.d.mts +0 -128
  217. package/dist/lib/reporting/ledger/object-writes.d.ts +0 -128
  218. package/dist/lib/reporting/ledger/object-writes.js +0 -147
  219. package/dist/lib/reporting/ledger/object-writes.mjs +0 -114
  220. package/dist/lib/reporting/ledger/postgres.d.mts +0 -689
  221. package/dist/lib/reporting/ledger/postgres.d.ts +0 -689
  222. package/dist/lib/reporting/ledger/postgres.js +0 -4462
  223. package/dist/lib/reporting/ledger/postgres.mjs +0 -4462
  224. package/dist/lib/reporting/ledger/row-storage-migration.d.mts +0 -59
  225. package/dist/lib/reporting/ledger/row-storage-migration.d.ts +0 -59
  226. package/dist/lib/reporting/ledger/row-storage-migration.js +0 -365
  227. package/dist/lib/reporting/ledger/row-storage-migration.mjs +0 -339
  228. package/dist/lib/reporting/ledger/row-storage-object.d.mts +0 -203
  229. package/dist/lib/reporting/ledger/row-storage-object.d.ts +0 -203
  230. package/dist/lib/reporting/ledger/row-storage-object.js +0 -379
  231. package/dist/lib/reporting/ledger/row-storage-object.mjs +0 -335
  232. package/dist/lib/reporting/ledger/row-storage-provider-io.d.mts +0 -44
  233. package/dist/lib/reporting/ledger/row-storage-provider-io.d.ts +0 -44
  234. package/dist/lib/reporting/ledger/row-storage-provider-io.js +0 -142
  235. package/dist/lib/reporting/ledger/row-storage-provider-io.mjs +0 -111
  236. package/dist/lib/reporting/ledger/row-storage.d.mts +0 -316
  237. package/dist/lib/reporting/ledger/row-storage.d.ts +0 -316
  238. package/dist/lib/reporting/ledger/row-storage.js +0 -812
  239. package/dist/lib/reporting/ledger/row-storage.mjs +0 -802
  240. package/dist/lib/reporting/s3/index.d.mts +0 -34
  241. package/dist/lib/reporting/s3/index.d.ts +0 -34
  242. package/dist/lib/reporting/s3/index.js +0 -347
  243. package/dist/lib/reporting/s3/index.mjs +0 -340
  244. package/dist/lib/reporting/service/maintenance.d.mts +0 -148
  245. package/dist/lib/reporting/service/maintenance.d.ts +0 -148
  246. package/dist/lib/reporting/service/maintenance.js +0 -185
  247. package/dist/lib/reporting/service/maintenance.mjs +0 -159
  248. package/examples/reliable-reporting-service/README.md +0 -169
@@ -1,2346 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
- var managed_postgres_exports = {};
20
- __export(managed_postgres_exports, {
21
- PostgresReportingManagedDeliveryStore: () => PostgresReportingManagedDeliveryStore,
22
- REPORTING_MANAGED_DELIVERY_MIGRATION: () => REPORTING_MANAGED_DELIVERY_MIGRATION
23
- });
24
- module.exports = __toCommonJS(managed_postgres_exports);
25
- var import_node_crypto = require("node:crypto");
26
- var import_jcs = require('../../utils/jcs.js');
27
- var import_evidence = require('../evidence.js');
28
- var import_types = require('./types.js');
29
- var import_row_storage = require('./row-storage.js');
30
- var import_object_writes = require('./object-writes.js');
31
- var import_managed = require('./managed.js');
32
- const REPORTING_MANAGED_DELIVERY_MIGRATION = `
33
- CREATE TABLE IF NOT EXISTS adcp_reporting_destination_authorizations (
34
- account_id TEXT NOT NULL,
35
- destination_ref TEXT NOT NULL,
36
- generation BIGINT NOT NULL,
37
- authorized_at TIMESTAMPTZ NOT NULL,
38
- revoked_at TIMESTAMPTZ,
39
- cleanup_completed_at TIMESTAMPTZ,
40
- cleanup_lease_owner TEXT,
41
- cleanup_lease_generation BIGINT NOT NULL DEFAULT 0,
42
- cleanup_lease_expires_at TIMESTAMPTZ,
43
- changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
44
- data JSONB NOT NULL,
45
- PRIMARY KEY (account_id, destination_ref, generation),
46
- CHECK (generation > 0),
47
- CHECK (revoked_at IS NULL OR revoked_at >= authorized_at)
48
- );
49
- ALTER TABLE adcp_reporting_destination_authorizations
50
- ADD COLUMN IF NOT EXISTS changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp();
51
- CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_destination_authorizations_current
52
- ON adcp_reporting_destination_authorizations (account_id, destination_ref)
53
- WHERE revoked_at IS NULL;
54
- CREATE INDEX IF NOT EXISTS adcp_reporting_destination_authorizations_cleanup
55
- ON adcp_reporting_destination_authorizations (revoked_at, account_id, destination_ref)
56
- WHERE revoked_at IS NOT NULL AND cleanup_completed_at IS NULL;
57
-
58
- CREATE TABLE IF NOT EXISTS adcp_reporting_managed_bindings (
59
- configuration_id TEXT PRIMARY KEY REFERENCES adcp_reporting_configurations(configuration_id),
60
- account_id TEXT NOT NULL,
61
- delivery_config_id TEXT NOT NULL,
62
- delivery_config_version INTEGER NOT NULL,
63
- destination_ref TEXT NOT NULL,
64
- authorization_generation BIGINT NOT NULL,
65
- semantic_fingerprint TEXT NOT NULL,
66
- data JSONB NOT NULL,
67
- created_at TIMESTAMPTZ NOT NULL,
68
- FOREIGN KEY (account_id, destination_ref, authorization_generation)
69
- REFERENCES adcp_reporting_destination_authorizations(account_id, destination_ref, generation),
70
- UNIQUE (account_id, delivery_config_id, delivery_config_version)
71
- );
72
-
73
- CREATE TABLE IF NOT EXISTS adcp_reporting_materializations (
74
- materialization_id TEXT PRIMARY KEY,
75
- account_id TEXT NOT NULL,
76
- configuration_id TEXT NOT NULL REFERENCES adcp_reporting_managed_bindings(configuration_id),
77
- obligation_id TEXT NOT NULL REFERENCES adcp_reporting_obligations(obligation_id),
78
- revision_id TEXT NOT NULL REFERENCES adcp_reporting_revisions(revision_id),
79
- destination_ref TEXT NOT NULL,
80
- authorization_generation BIGINT NOT NULL,
81
- attempt INTEGER NOT NULL,
82
- status TEXT NOT NULL,
83
- data JSONB NOT NULL,
84
- created_at TIMESTAMPTZ NOT NULL,
85
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
86
- changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
87
- lease_owner TEXT,
88
- lease_generation BIGINT NOT NULL DEFAULT 0,
89
- lease_expires_at TIMESTAMPTZ,
90
- UNIQUE (configuration_id, revision_id, attempt),
91
- CHECK (attempt > 0),
92
- CHECK (status IN ('pending', 'available', 'delivered', 'failed'))
93
- );
94
- CREATE INDEX IF NOT EXISTS adcp_reporting_materializations_claim
95
- ON adcp_reporting_materializations (created_at, materialization_id) WHERE status = 'pending';
96
- CREATE INDEX IF NOT EXISTS adcp_reporting_materializations_obligation
97
- ON adcp_reporting_materializations (obligation_id, recorded_at, materialization_id);
98
- CREATE INDEX IF NOT EXISTS adcp_reporting_materializations_account_retention
99
- ON adcp_reporting_materializations (account_id, recorded_at);
100
- CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_materializations_success
101
- ON adcp_reporting_materializations (configuration_id, revision_id)
102
- WHERE status IN ('available', 'delivered');
103
-
104
- CREATE TABLE IF NOT EXISTS adcp_reporting_receipts (
105
- account_id TEXT NOT NULL,
106
- consumer_id TEXT NOT NULL,
107
- reporting_receipt_id TEXT NOT NULL,
108
- receipt_kind TEXT NOT NULL,
109
- subject_id TEXT NOT NULL,
110
- supersedes_receipt_id TEXT,
111
- is_current BOOLEAN NOT NULL,
112
- semantic_fingerprint TEXT NOT NULL,
113
- data JSONB NOT NULL,
114
- received_at TIMESTAMPTZ NOT NULL,
115
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
116
- PRIMARY KEY (account_id, consumer_id, reporting_receipt_id),
117
- CHECK (receipt_kind IN ('revision', 'adjustment'))
118
- );
119
- CREATE UNIQUE INDEX IF NOT EXISTS adcp_reporting_receipts_current
120
- ON adcp_reporting_receipts (account_id, consumer_id, receipt_kind, subject_id) WHERE is_current;
121
- CREATE INDEX IF NOT EXISTS adcp_reporting_receipts_readback
122
- ON adcp_reporting_receipts (account_id, consumer_id, recorded_at, reporting_receipt_id);
123
- -- The as-of-cutoff leaf asks, per candidate, whether anything recorded by the
124
- -- cutoff supersedes it. Without this that question is a scan of the caller's
125
- -- whole receipt history.
126
- CREATE INDEX IF NOT EXISTS adcp_reporting_receipts_supersedes
127
- ON adcp_reporting_receipts (account_id, consumer_id, supersedes_receipt_id)
128
- WHERE supersedes_receipt_id IS NOT NULL;
129
- -- Every lifecycle read is "the receipts for this obligation's subjects". The
130
- -- receipt table is global and its other indexes lead with account and
131
- -- consumer, neither of which that question supplies, so each due obligation
132
- -- re-scanned the whole receipt history.
133
- CREATE INDEX IF NOT EXISTS adcp_reporting_receipts_subject
134
- ON adcp_reporting_receipts (subject_id, receipt_kind, recorded_at);
135
-
136
- CREATE TABLE IF NOT EXISTS adcp_reporting_receipt_batches (
137
- account_id TEXT NOT NULL,
138
- consumer_id TEXT NOT NULL,
139
- idempotency_key TEXT NOT NULL,
140
- request_fingerprint TEXT NOT NULL,
141
- results JSONB NOT NULL,
142
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
143
- PRIMARY KEY (account_id, consumer_id, idempotency_key)
144
- );
145
- -- Pruning used to ask "does any live replay row contain this receipt id" per
146
- -- candidate, which no index could serve for a correlated operand. It now
147
- -- expands the account's replay rows once instead, so no GIN index is needed
148
- -- and the batches table is not burdened with maintaining one.
149
-
150
- -- Agent-wide promises, durable and shared by every store instance and process
151
- -- that talks to this database. An in-memory bound only constrains the process
152
- -- that set it, so two replicas could each believe they were authoritative and
153
- -- publish different windows over the same bindings.
154
- CREATE TABLE IF NOT EXISTS adcp_reporting_managed_policy (
155
- policy_key TEXT PRIMARY KEY,
156
- advertised_recovery_window_seconds BIGINT,
157
- advertised_status_retention_days BIGINT,
158
- advertised_resource_retention_days BIGINT,
159
- advertised_authorization_revocation_seconds BIGINT,
160
- materialization_planning_cursor TEXT,
161
- recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
162
- changed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
163
- );
164
- -- Upgrade registries installed by earlier Managed Delivery release candidates.
165
- ALTER TABLE adcp_reporting_managed_policy
166
- ADD COLUMN IF NOT EXISTS advertised_resource_retention_days BIGINT;
167
- ALTER TABLE adcp_reporting_managed_policy
168
- ADD COLUMN IF NOT EXISTS advertised_authorization_revocation_seconds BIGINT;
169
- -- Durable round-robin position for deployment-wide materialization planning.
170
- -- A process-local cursor restarts at the lexically first tenant after every
171
- -- deploy; without a durable position a continuously busy first account can
172
- -- consume every bounded planning page and a later account never progresses.
173
- ALTER TABLE adcp_reporting_managed_policy
174
- ADD COLUMN IF NOT EXISTS materialization_planning_cursor TEXT;
175
- -- Every policy read or adoption locks this durable row. Creating it in the
176
- -- migration avoids a missing-row predicate gap on a fresh registry.
177
- INSERT INTO adcp_reporting_managed_policy (policy_key)
178
- VALUES ('agent')
179
- ON CONFLICT (policy_key) DO NOTHING;
180
-
181
- -- Permanent, compact identity for a receipt whose body has aged out.
182
- -- Bodies are retained only through the advertised horizon, but identity is
183
- -- forever: a pruned receipt_id must never bind different content later, and a
184
- -- subject that reached a terminal accepted leaf must never reopen because the
185
- -- row proving it was terminal has expired.
186
- CREATE TABLE IF NOT EXISTS adcp_reporting_receipt_tombstones (
187
- account_id TEXT NOT NULL,
188
- consumer_id TEXT NOT NULL,
189
- reporting_receipt_id TEXT NOT NULL,
190
- receipt_kind TEXT NOT NULL,
191
- subject_id TEXT NOT NULL,
192
- status TEXT NOT NULL,
193
- was_current BOOLEAN NOT NULL,
194
- semantic_fingerprint TEXT NOT NULL,
195
- pruned_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
196
- PRIMARY KEY (account_id, consumer_id, reporting_receipt_id)
197
- );
198
- -- When the pruned receipt itself was recorded, and what it superseded. A
199
- -- tombstone without them is a conclusion with no place in time: a historical
200
- -- projection applied an acceptance that had not happened yet at its cutoff,
201
- -- and a chain whose successor was pruned let the predecessor it superseded
202
- -- come back as the live leaf. Nullable for rows written before this column
203
- -- existed; readers fall back to pruned_at, which is never earlier.
204
- ALTER TABLE adcp_reporting_receipt_tombstones
205
- ADD COLUMN IF NOT EXISTS subject_recorded_at TIMESTAMPTZ;
206
- ALTER TABLE adcp_reporting_receipt_tombstones
207
- ADD COLUMN IF NOT EXISTS supersedes_receipt_id TEXT;
208
- CREATE INDEX IF NOT EXISTS adcp_reporting_receipt_tombstones_supersedes
209
- ON adcp_reporting_receipt_tombstones (account_id, consumer_id, supersedes_receipt_id)
210
- WHERE supersedes_receipt_id IS NOT NULL;
211
- CREATE INDEX IF NOT EXISTS adcp_reporting_receipt_tombstones_subject
212
- ON adcp_reporting_receipt_tombstones (account_id, consumer_id, receipt_kind, subject_id);
213
-
214
- -- Compact terminal state for a materialization whose row has been pruned.
215
- -- Attempt history is control state, not evidence: without it a revision whose
216
- -- attempts were exhausted, or which already succeeded, restarts at attempt 1
217
- -- the moment its rows age out.
218
- CREATE TABLE IF NOT EXISTS adcp_reporting_materialization_tombstones (
219
- configuration_id TEXT NOT NULL,
220
- revision_id TEXT NOT NULL,
221
- account_id TEXT NOT NULL,
222
- obligation_id TEXT NOT NULL,
223
- highest_attempt INTEGER NOT NULL,
224
- reached_success BOOLEAN NOT NULL,
225
- pruned_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
226
- -- When the delivery this records actually succeeded, so a projection at an
227
- -- earlier cutoff does not read it as already delivered.
228
- reached_success_at TIMESTAMPTZ,
229
- PRIMARY KEY (configuration_id, revision_id)
230
- );
231
- -- These rows are permanent and every lifecycle projection reads them by
232
- -- obligation and success. The primary key leads with configuration, which
233
- -- that question does not supply, so the scan grew without bound for the life
234
- -- of the deployment.
235
- CREATE INDEX IF NOT EXISTS adcp_reporting_materialization_tombstones_obligation
236
- ON adcp_reporting_materialization_tombstones (obligation_id, reached_success, reached_success_at);
237
-
238
-
239
- ALTER TABLE adcp_reporting_destination_authorizations
240
- ADD COLUMN IF NOT EXISTS cleanup_lease_issued_at TIMESTAMPTZ;
241
-
242
- -- Delivery pattern and transport recorded on the authorization generation, so
243
- -- a revocation can be routed to the transport that holds the provider grant.
244
- -- Both are immutable for a generation (a new delivery contract mints a new
245
- -- destination_ref generation) and NULL for a generation whose contract is not
246
- -- known. The block runs once, in the same statement batch that adds the
247
- -- columns: it backfills a row only when every binding on that exact
248
- -- (account_id, destination_ref, generation) agrees on the method and carries
249
- -- the same transport, and otherwise leaves the row NULL. A later re-run finds
250
- -- the columns present and does nothing, so a generation that was left NULL
251
- -- stays NULL rather than being re-decided by whichever binding arrived since.
252
- DO $pattern_transport$
253
- DECLARE
254
- unresolved BIGINT;
255
- BEGIN
256
- IF NOT EXISTS (
257
- SELECT 1 FROM information_schema.columns
258
- WHERE table_schema = current_schema()
259
- AND table_name = 'adcp_reporting_destination_authorizations'
260
- AND column_name = 'transport'
261
- ) THEN
262
- ALTER TABLE adcp_reporting_destination_authorizations ADD COLUMN IF NOT EXISTS pattern TEXT;
263
- ALTER TABLE adcp_reporting_destination_authorizations ADD COLUMN IF NOT EXISTS transport TEXT;
264
- WITH agreed AS (
265
- SELECT authz.account_id, authz.destination_ref, authz.generation,
266
- MIN(binding.data->>'method') AS pattern,
267
- MIN(binding.data->>'transport') AS transport
268
- FROM adcp_reporting_destination_authorizations authz
269
- JOIN adcp_reporting_managed_bindings binding
270
- ON binding.account_id = authz.account_id
271
- AND binding.destination_ref = authz.destination_ref
272
- AND binding.authorization_generation = authz.generation
273
- WHERE authz.pattern IS NULL AND authz.transport IS NULL
274
- GROUP BY authz.account_id, authz.destination_ref, authz.generation
275
- HAVING COUNT(*) = COUNT(binding.data->>'method')
276
- AND COUNT(DISTINCT binding.data->>'method') = 1
277
- AND COUNT(*) = COUNT(binding.data->>'transport')
278
- AND COUNT(DISTINCT binding.data->>'transport') = 1
279
- )
280
- UPDATE adcp_reporting_destination_authorizations authz
281
- SET pattern = agreed.pattern,
282
- transport = agreed.transport,
283
- data = authz.data || jsonb_build_object('pattern', agreed.pattern, 'transport', agreed.transport)
284
- FROM agreed
285
- WHERE authz.account_id = agreed.account_id
286
- AND authz.destination_ref = agreed.destination_ref
287
- AND authz.generation = agreed.generation;
288
- SELECT COUNT(*) INTO unresolved FROM adcp_reporting_destination_authorizations WHERE transport IS NULL;
289
- IF unresolved > 0 THEN
290
- RAISE NOTICE 'adcp_reporting_destination_authorizations: % generation(s) left without pattern/transport; list them with PostgresReportingManagedDeliveryStore.listDestinationAuthorizationsWithoutContract()', unresolved;
291
- END IF;
292
- END IF;
293
- END
294
- $pattern_transport$;
295
- `.trim();
296
- const GENERIC_RECEIPT_MESSAGE = "Receipt does not match authorized current reporting evidence";
297
- const MAX_PLAN = 1e3;
298
- const MAX_MATERIALIZATIONS_PER_ACCOUNT = 1e5;
299
- const MAX_RECEIPT_BATCHES_PER_CONSUMER = 1e4;
300
- const MAX_RECEIPTS_PER_CONSUMER = 1e5;
301
- const MAX_RECEIPTS_PER_ARRAY = 100;
302
- const MAX_RECEIPT_RESULTS = 100;
303
- const MAX_MATERIALIZATION_ATTEMPTS = 5;
304
- const REVOCATION_RETRY_BACKOFF_MS = 5e3;
305
- const RECEIPT_BATCH_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
306
- const MAX_RECEIPT_BATCH_RESULT_BYTES = 64 * 1024;
307
- class PostgresReportingManagedDeliveryStore {
308
- constructor(pool, options = {}) {
309
- this.pool = pool;
310
- this.rowStorage = new import_row_storage.ReportingRowStorageV1(pool);
311
- if (options.statusRetentionDays !== void 0) positiveInteger(options.statusRetentionDays, "statusRetentionDays");
312
- if (options.evidenceRetentionDays !== void 0) {
313
- positiveInteger(options.evidenceRetentionDays, "evidenceRetentionDays");
314
- const replayFloorDays = Math.ceil(RECEIPT_BATCH_RETENTION_MS / 864e5);
315
- if (options.evidenceRetentionDays < replayFloorDays) {
316
- throw new RangeError(
317
- `evidenceRetentionDays must be at least the ${replayFloorDays}-day receipt replay retention`
318
- );
319
- }
320
- if (options.statusRetentionDays !== void 0 && options.evidenceRetentionDays < options.statusRetentionDays) {
321
- throw new RangeError(
322
- `evidenceRetentionDays must be at least the advertised statusRetentionDays (${options.statusRetentionDays}); pruning inside an advertised horizon breaks it`
323
- );
324
- }
325
- }
326
- if (options.advertisedRecoveryWindowSeconds !== void 0) {
327
- nonnegativeSafeInteger(options.advertisedRecoveryWindowSeconds, "advertisedRecoveryWindowSeconds");
328
- }
329
- this.pendingRecoveryWindowSeconds = options.advertisedRecoveryWindowSeconds;
330
- this.statusRetentionDays = options.statusRetentionDays;
331
- this.evidenceRetentionDays = options.evidenceRetentionDays;
332
- this.notificationActivityPort = options.notificationActivityPort;
333
- }
334
- pool;
335
- evidenceRetentionDays;
336
- statusRetentionDays;
337
- pendingRecoveryWindowSeconds;
338
- policyRegistered = false;
339
- bindingAdmission;
340
- notificationActivityPort;
341
- /**
342
- * Durably records the agent-wide advertised recovery window.
343
- *
344
- * Written to the database, not held in memory: the promise is agent-wide,
345
- * so a second store instance — in this process or another replica — must be
346
- * held to the same value, and `installBinding` must read it inside its own
347
- * transaction rather than trusting whatever the local object happens to
348
- * know. Multiple replicas monotonically retain the smallest registered
349
- * maximum: a shorter recovery promise is stronger and can never be widened
350
- * by a later runtime.
351
- */
352
- async adoptAdvertisedRecoveryWindowSeconds(seconds) {
353
- nonnegativeSafeInteger(seconds, "automatedRecoveryWindowSeconds");
354
- await this.upsertPolicy("advertised_recovery_window_seconds", seconds, {
355
- strongest: Math.min,
356
- // Checked inside the same transaction that would persist it. Writing
357
- // first and validating afterwards poisoned the registry: a rejected
358
- // 60s startup against an installed 900s binding still left a durable
359
- // 60, and the correct 900s restart was then refused as a conflict.
360
- validate: (client, effective) => this.validateRecoveryWindow(client, effective.advertised_recovery_window_seconds)
361
- });
362
- }
363
- /**
364
- * Durably records the advertised `status_retention_days`.
365
- *
366
- * Retention is enforced against the widest promise any runtime registered
367
- * over this database, so a store configured with a short evidence retention
368
- * cannot prune inside a horizon a differently configured runtime published.
369
- */
370
- async adoptAdvertisedStatusRetentionDays(days) {
371
- positiveInteger(days, "statusRetentionDays");
372
- await this.upsertPolicy("advertised_status_retention_days", days, {
373
- strongest: Math.max
374
- });
375
- }
376
- /** Registers the complete capability policy under one lock and transaction. */
377
- async adoptAdvertisedPolicies(policy) {
378
- nonnegativeSafeInteger(policy.automatedRecoveryWindowSeconds, "automatedRecoveryWindowSeconds");
379
- positiveInteger(policy.statusRetentionDays, "statusRetentionDays");
380
- positiveInteger(policy.resourceRetentionDays, "resourceRetentionDays");
381
- nonnegativeSafeInteger(policy.authorizationRevocationSeconds, "authorizationRevocationSeconds");
382
- const adopted = await this.upsertPolicies(
383
- [
384
- {
385
- column: "advertised_recovery_window_seconds",
386
- value: policy.automatedRecoveryWindowSeconds,
387
- strongest: Math.min
388
- },
389
- {
390
- column: "advertised_status_retention_days",
391
- value: policy.statusRetentionDays,
392
- strongest: Math.max
393
- },
394
- {
395
- column: "advertised_resource_retention_days",
396
- value: policy.resourceRetentionDays,
397
- strongest: Math.max
398
- },
399
- {
400
- column: "advertised_authorization_revocation_seconds",
401
- value: policy.authorizationRevocationSeconds,
402
- strongest: Math.min
403
- }
404
- ],
405
- (client, effective) => this.validateRecoveryWindow(client, effective.advertised_recovery_window_seconds)
406
- );
407
- return {
408
- automatedRecoveryWindowSeconds: adopted.advertised_recovery_window_seconds,
409
- statusRetentionDays: adopted.advertised_status_retention_days,
410
- resourceRetentionDays: adopted.advertised_resource_retention_days,
411
- authorizationRevocationSeconds: adopted.advertised_authorization_revocation_seconds
412
- };
413
- }
414
- /**
415
- * Flushes constructor-supplied promises into the durable registry.
416
- *
417
- * Called from every entry point that depends on them, so a store built with
418
- * options behaves identically to one a runtime adopted into.
419
- */
420
- async ensurePolicyRegistered() {
421
- if (this.policyRegistered) return;
422
- if (this.pendingRecoveryWindowSeconds !== void 0 && this.statusRetentionDays !== void 0) {
423
- await this.upsertPolicies(
424
- [
425
- {
426
- column: "advertised_recovery_window_seconds",
427
- value: this.pendingRecoveryWindowSeconds,
428
- strongest: Math.min
429
- },
430
- {
431
- column: "advertised_status_retention_days",
432
- value: this.statusRetentionDays,
433
- strongest: Math.max
434
- }
435
- ],
436
- (client, effective) => this.validateRecoveryWindow(client, effective.advertised_recovery_window_seconds)
437
- );
438
- } else if (this.pendingRecoveryWindowSeconds !== void 0) {
439
- await this.adoptAdvertisedRecoveryWindowSeconds(this.pendingRecoveryWindowSeconds);
440
- } else if (this.statusRetentionDays !== void 0) {
441
- await this.adoptAdvertisedStatusRetentionDays(this.statusRetentionDays);
442
- }
443
- this.policyRegistered = true;
444
- }
445
- async upsertPolicy(column, value, options) {
446
- await this.upsertPolicies([{ column, value, strongest: options.strongest }], options.validate);
447
- }
448
- async upsertPolicies(policies, validate) {
449
- return this.transaction(async (client) => {
450
- const current = await this.readPolicyRow(client, "update");
451
- const effective = { ...current };
452
- for (const policy of policies) {
453
- const existing = current[policy.column];
454
- effective[policy.column] = existing === null ? policy.value : policy.strongest(existing, policy.value);
455
- }
456
- await validate?.(client, effective);
457
- for (const policy of policies) {
458
- await client.query(
459
- `UPDATE adcp_reporting_managed_policy SET ${policy.column} = $1,
460
- changed_at = clock_timestamp() WHERE policy_key = 'agent'`,
461
- [effective[policy.column]]
462
- );
463
- }
464
- return effective;
465
- });
466
- }
467
- async validateRecoveryWindow(client, seconds) {
468
- const widest = await client.query(
469
- `WITH installed AS (
470
- SELECT configuration.data->'schedule'->>'recoveryWindowMilliseconds' AS milliseconds
471
- FROM adcp_reporting_managed_bindings binding
472
- JOIN adcp_reporting_configurations configuration
473
- ON configuration.configuration_id = binding.configuration_id
474
- )
475
- SELECT COUNT(*)::text AS installed,
476
- COUNT(*) FILTER (WHERE milliseconds ~ '^[0-9]+$')::text AS valid,
477
- MAX(CASE WHEN milliseconds ~ '^[0-9]+$'
478
- THEN CEIL(milliseconds::numeric / 1000) END)::text AS widest
479
- FROM installed`
480
- );
481
- const row = widest.rows[0];
482
- if (row?.installed !== row?.valid) {
483
- throw new Error("Every installed managed Core configuration must have a usable non-negative recovery window");
484
- }
485
- const widestInstalled = Number(row?.widest ?? 0);
486
- if (!Number.isSafeInteger(widestInstalled)) {
487
- throw new Error("Installed managed Core recovery windows exceed the supported safe-integer range");
488
- }
489
- if (seconds < widestInstalled) {
490
- throw new Error(
491
- `automatedRecoveryWindowSeconds must be at least the widest installed managed Core recovery window (${widestInstalled}s); advertising ${seconds}s would promise a recovery bound this deployment does not keep for every tenant`
492
- );
493
- }
494
- }
495
- async readPolicyRow(client, lock) {
496
- const lockClause = lock === "share" ? " FOR SHARE" : lock === "update" ? " FOR UPDATE" : "";
497
- const result = await client.query(
498
- `SELECT advertised_recovery_window_seconds::text AS recovery,
499
- advertised_status_retention_days::text AS status,
500
- advertised_resource_retention_days::text AS resource,
501
- advertised_authorization_revocation_seconds::text AS revocation
502
- FROM adcp_reporting_managed_policy WHERE policy_key = 'agent'${lockClause}`
503
- );
504
- const row = result.rows[0];
505
- if (!row) {
506
- throw new Error(
507
- "Managed reporting policy sentinel is missing; apply REPORTING_MANAGED_DELIVERY_MIGRATION before use"
508
- );
509
- }
510
- return {
511
- advertised_recovery_window_seconds: policyNumber(row?.recovery),
512
- advertised_status_retention_days: policyNumber(row?.status),
513
- advertised_resource_retention_days: policyNumber(row?.resource),
514
- advertised_authorization_revocation_seconds: policyNumber(row?.revocation)
515
- };
516
- }
517
- async readPolicy(client, lock) {
518
- const policy = await this.readPolicyRow(client, lock);
519
- return {
520
- recoveryWindowSeconds: policy.advertised_recovery_window_seconds,
521
- statusRetentionDays: policy.advertised_status_retention_days,
522
- resourceRetentionDays: policy.advertised_resource_retention_days,
523
- authorizationRevocationSeconds: policy.advertised_authorization_revocation_seconds
524
- };
525
- }
526
- /** Read-only access to PostgreSQL row chunks for claim hydration. */
527
- rowStorage;
528
- /** SQL fragment scoping a count to the active retention window, if one is set. */
529
- activeScope(column) {
530
- return this.evidenceRetentionDays === void 0 ? "" : ` AND ${column} >= clock_timestamp() - (${this.evidenceRetentionDays}::bigint * INTERVAL '1 day')`;
531
- }
532
- /**
533
- * Deletes managed evidence that has aged out of `evidenceRetentionDays`.
534
- *
535
- * Only ever removes evidence the deployment no longer promises: nothing
536
- * inside the retention window, nothing still `pending` or leased, and no
537
- * receipt batch inside its own replay retention. Immutable evidence stays
538
- * immutable while it is retained — this frees capacity at the far end of the
539
- * window rather than rewriting anything. Returns what it removed so a
540
- * scheduler can page through with `limit`.
541
- */
542
- async pruneExpiredEvidence(input) {
543
- if (this.evidenceRetentionDays === void 0) {
544
- throw new Error("pruneExpiredEvidence requires PostgresReportingManagedDeliveryStore({ evidenceRetentionDays })");
545
- }
546
- await this.ensurePolicyRegistered();
547
- const limit = input.limit ?? 1e3;
548
- positiveInteger(limit, "limit");
549
- const days = this.evidenceRetentionDays;
550
- return this.transaction(async (client) => {
551
- const registered = await this.readPolicy(client, "share");
552
- const retentionFloor = Math.max(registered.statusRetentionDays ?? 0, registered.resourceRetentionDays ?? 0);
553
- if (days < retentionFloor) {
554
- throw new Error(
555
- `evidenceRetentionDays ${days} is shorter than the strongest advertised status/resource retention of ${retentionFloor} days registered for this database; pruning would cut inside an advertised horizon`
556
- );
557
- }
558
- const cutoff = (await client.query(
559
- `SELECT (clock_timestamp() - ($1::bigint * INTERVAL '1 day'))::text AS cutoff`,
560
- [days]
561
- )).rows[0].cutoff;
562
- const doomedReceipts = await client.query(
563
- `WITH live_referenced AS (
564
- SELECT DISTINCT batch.consumer_id,
565
- COALESCE(
566
- elem ->> 'id',
567
- elem ->> 'reporting_receipt_id',
568
- elem -> 'receipt' ->> 'reporting_receipt_id',
569
- elem -> 'adjustment_receipt' ->> 'reporting_receipt_id'
570
- ) AS reporting_receipt_id
571
- FROM adcp_reporting_receipt_batches batch,
572
- LATERAL jsonb_array_elements(batch.results) elem
573
- -- Only a replay row still inside its own retention pins anything.
574
- -- Selection now runs before the batch delete, so without this an
575
- -- already-expired row kept holding its receipts alive forever.
576
- WHERE batch.account_id = $1
577
- AND batch.recorded_at >= clock_timestamp() - ($4::bigint * INTERVAL '1 millisecond')
578
- )
579
- SELECT receipt.consumer_id, receipt.reporting_receipt_id
580
- FROM adcp_reporting_receipts receipt
581
- WHERE receipt.account_id = $1
582
- AND receipt.recorded_at < $3::timestamptz
583
- AND NOT EXISTS (
584
- SELECT 1 FROM live_referenced
585
- WHERE live_referenced.consumer_id = receipt.consumer_id
586
- AND live_referenced.reporting_receipt_id = receipt.reporting_receipt_id
587
- )
588
- -- An acceptance outlives its own age while the resource it
589
- -- accepts is still readable. Pruning it sooner produced a period
590
- -- that reads complete with no receipt to show for it.
591
- --
592
- -- An adjustment receipt names no materialization at all: it
593
- -- names the revision it adjusts. Matching only on
594
- -- reporting_materialization_id therefore held every revision
595
- -- acceptance and no adjustment acceptance, so a period whose
596
- -- revision resource was still readable lost the adjustment
597
- -- evidence behind its own complete.
598
- AND NOT EXISTS (
599
- SELECT 1 FROM adcp_reporting_materializations live
600
- WHERE live.account_id = receipt.account_id
601
- AND (
602
- live.materialization_id = receipt.data ->> 'reporting_materialization_id'
603
- OR live.revision_id = receipt.data ->> 'adjusts_reporting_revision_id'
604
- )
605
- AND (live.data -> 'resource' ->> 'expires_at')::timestamptz > clock_timestamp()
606
- )
607
- ORDER BY receipt.recorded_at
608
- LIMIT $2`,
609
- [input.account_id, limit, cutoff, RECEIPT_BATCH_RETENTION_MS]
610
- );
611
- const doomedMaterializations = await client.query(
612
- `SELECT materialization.materialization_id
613
- FROM adcp_reporting_materializations materialization
614
- WHERE materialization.account_id = $1
615
- AND materialization.recorded_at < $3::timestamptz
616
- AND materialization.status <> 'pending'
617
- AND materialization.lease_owner IS NULL
618
- AND COALESCE(
619
- (materialization.data -> 'resource' ->> 'expires_at')::timestamptz <= clock_timestamp(),
620
- true)
621
- -- No SURVIVING receipt may still name it. Selection runs before
622
- -- the receipt delete now, so a receipt already doomed in this
623
- -- same pass must not keep its materialization alive forever.
624
- AND NOT EXISTS (
625
- SELECT 1 FROM adcp_reporting_receipts receipt
626
- WHERE receipt.account_id = materialization.account_id
627
- AND receipt.data ->> 'reporting_materialization_id' = materialization.materialization_id
628
- AND NOT EXISTS (
629
- SELECT 1 FROM unnest($4::text[], $5::text[]) AS doomed(consumer_id, reporting_receipt_id)
630
- WHERE doomed.consumer_id = receipt.consumer_id
631
- AND doomed.reporting_receipt_id = receipt.reporting_receipt_id
632
- )
633
- )
634
- ORDER BY materialization.recorded_at
635
- LIMIT $2`,
636
- [
637
- input.account_id,
638
- limit,
639
- cutoff,
640
- doomedReceipts.rows.map((row) => row.consumer_id),
641
- doomedReceipts.rows.map((row) => row.reporting_receipt_id)
642
- ]
643
- );
644
- await advisoryLock(client, accountLock(input.account_id));
645
- const batches = await client.query(
646
- `DELETE FROM adcp_reporting_receipt_batches
647
- WHERE account_id = $1
648
- AND recorded_at < clock_timestamp() - ($2::bigint * INTERVAL '1 millisecond')`,
649
- [input.account_id, RECEIPT_BATCH_RETENTION_MS]
650
- );
651
- const doomedConsumerIds = doomedReceipts.rows.map((row) => row.consumer_id);
652
- const doomedReceiptIds = doomedReceipts.rows.map((row) => row.reporting_receipt_id);
653
- const receipts = await client.query(
654
- `WITH doomed_keys AS (
655
- SELECT * FROM unnest($2::text[], $3::text[]) AS pair(consumer_id, reporting_receipt_id)
656
- ), live_referenced AS (
657
- -- Selection ran unlocked, so its answer is a proposal, not a
658
- -- verdict: a replay could commit against a candidate between the
659
- -- select and this delete, and the delete then removed the receipt
660
- -- that replay had just promised to reproduce. Re-expanding the
661
- -- live replay rows under the lock is one pass, and it is narrowed
662
- -- to the candidates consumers rather than the whole account, so
663
- -- the lock still holds no per-candidate join filter.
664
- SELECT DISTINCT batch.consumer_id,
665
- COALESCE(
666
- elem ->> 'id',
667
- elem ->> 'reporting_receipt_id',
668
- elem -> 'receipt' ->> 'reporting_receipt_id',
669
- elem -> 'adjustment_receipt' ->> 'reporting_receipt_id'
670
- ) AS reporting_receipt_id
671
- FROM adcp_reporting_receipt_batches batch,
672
- LATERAL jsonb_array_elements(batch.results) elem
673
- WHERE batch.account_id = $1
674
- AND batch.consumer_id = ANY($2::text[])
675
- AND batch.recorded_at >= clock_timestamp() - ($4::bigint * INTERVAL '1 millisecond')
676
- ), doomed AS (
677
- SELECT receipt.* FROM adcp_reporting_receipts receipt
678
- JOIN doomed_keys ON doomed_keys.consumer_id = receipt.consumer_id
679
- AND doomed_keys.reporting_receipt_id = receipt.reporting_receipt_id
680
- WHERE receipt.account_id = $1
681
- AND receipt.recorded_at < $5::timestamptz
682
- AND NOT EXISTS (
683
- SELECT 1 FROM live_referenced
684
- WHERE live_referenced.consumer_id = receipt.consumer_id
685
- AND live_referenced.reporting_receipt_id = receipt.reporting_receipt_id
686
- )
687
- AND NOT EXISTS (
688
- SELECT 1 FROM adcp_reporting_materializations live
689
- WHERE live.account_id = receipt.account_id
690
- AND (
691
- live.materialization_id = receipt.data ->> 'reporting_materialization_id'
692
- OR live.revision_id = receipt.data ->> 'adjusts_reporting_revision_id'
693
- )
694
- AND (live.data -> 'resource' ->> 'expires_at')::timestamptz > clock_timestamp()
695
- )
696
- ), superseded AS (
697
- -- Successive prunes each tombstoned whatever was current at the
698
- -- time, so a subject accumulated several current-leaf tombstones:
699
- -- prune R1 while current, record R2 over it, prune R2. Reading
700
- -- back, whichever the planner returned first answered for the
701
- -- chain, and picking R1 rejected R3's legitimate succession to
702
- -- R2. A subject has exactly one leaf, so demote the older
703
- -- tombstones the moment a newer leaf is recorded.
704
- UPDATE adcp_reporting_receipt_tombstones prior
705
- SET was_current = false
706
- FROM doomed
707
- WHERE doomed.is_current
708
- AND prior.account_id = doomed.account_id
709
- AND prior.consumer_id = doomed.consumer_id
710
- AND prior.receipt_kind = doomed.receipt_kind
711
- AND prior.subject_id = doomed.subject_id
712
- AND prior.reporting_receipt_id <> doomed.reporting_receipt_id
713
- AND prior.was_current
714
- ), tombstoned AS (
715
- INSERT INTO adcp_reporting_receipt_tombstones
716
- (account_id, consumer_id, reporting_receipt_id, receipt_kind, subject_id,
717
- status, was_current, semantic_fingerprint, subject_recorded_at, supersedes_receipt_id)
718
- SELECT account_id, consumer_id, reporting_receipt_id, receipt_kind, subject_id,
719
- COALESCE(data ->> 'status', 'unknown'), is_current, semantic_fingerprint,
720
- recorded_at, supersedes_receipt_id
721
- FROM doomed
722
- ON CONFLICT (account_id, consumer_id, reporting_receipt_id) DO NOTHING
723
- )
724
- DELETE FROM adcp_reporting_receipts target USING doomed
725
- WHERE target.account_id = doomed.account_id
726
- AND target.consumer_id = doomed.consumer_id
727
- AND target.reporting_receipt_id = doomed.reporting_receipt_id`,
728
- [input.account_id, doomedConsumerIds, doomedReceiptIds, RECEIPT_BATCH_RETENTION_MS, cutoff]
729
- );
730
- const materializations = await client.query(
731
- `WITH referenced AS (
732
- -- Doomed receipts are already gone above, so a hit here is a
733
- -- survivor: either one this pass never selected, or one a
734
- -- concurrent sync recorded after selection read the table.
735
- SELECT DISTINCT receipt.data ->> 'reporting_materialization_id' AS materialization_id
736
- FROM adcp_reporting_receipts receipt
737
- WHERE receipt.account_id = $2
738
- AND receipt.data ->> 'reporting_materialization_id' = ANY($1::text[])
739
- ), doomed AS (
740
- SELECT materialization.* FROM adcp_reporting_materializations materialization
741
- WHERE materialization.materialization_id = ANY($1::text[])
742
- AND materialization.account_id = $2
743
- -- Every selection predicate re-asserted under the lock. A
744
- -- claim, a settle or a re-record between the select and here
745
- -- makes the row live again.
746
- AND materialization.recorded_at < $3::timestamptz
747
- AND materialization.status <> 'pending'
748
- AND materialization.lease_owner IS NULL
749
- AND COALESCE(
750
- (materialization.data -> 'resource' ->> 'expires_at')::timestamptz <= clock_timestamp(),
751
- true)
752
- AND NOT EXISTS (
753
- SELECT 1 FROM referenced
754
- WHERE referenced.materialization_id = materialization.materialization_id
755
- )
756
- ), tombstoned AS (
757
- INSERT INTO adcp_reporting_materialization_tombstones
758
- (configuration_id, revision_id, account_id, obligation_id, highest_attempt, reached_success,
759
- reached_success_at)
760
- SELECT configuration_id, revision_id, account_id, obligation_id,
761
- MAX(attempt), BOOL_OR(status IN ('available', 'delivered')),
762
- MIN(changed_at) FILTER (WHERE status IN ('available', 'delivered'))
763
- FROM doomed GROUP BY configuration_id, revision_id, account_id, obligation_id
764
- ON CONFLICT (configuration_id, revision_id) DO UPDATE SET
765
- highest_attempt = GREATEST(
766
- adcp_reporting_materialization_tombstones.highest_attempt, EXCLUDED.highest_attempt),
767
- reached_success =
768
- adcp_reporting_materialization_tombstones.reached_success OR EXCLUDED.reached_success,
769
- -- The earliest success is what a historical cutoff must compare
770
- -- against: a later one does not make the delivery newer.
771
- reached_success_at = LEAST(
772
- adcp_reporting_materialization_tombstones.reached_success_at, EXCLUDED.reached_success_at)
773
- )
774
- DELETE FROM adcp_reporting_materializations target USING doomed
775
- WHERE target.materialization_id = doomed.materialization_id`,
776
- [doomedMaterializations.rows.map((row) => row.materialization_id), input.account_id, cutoff]
777
- );
778
- return {
779
- materializations: materializations.rowCount ?? 0,
780
- receipts: receipts.rowCount ?? 0,
781
- batches: batches.rowCount ?? 0
782
- };
783
- });
784
- }
785
- async probe(coreStore) {
786
- const authority = coreStore[import_types.REPORTING_LEDGER_AUTHORITY];
787
- if (!authority) {
788
- throw new Error("Managed reporting requires a Core store that exposes REPORTING_LEDGER_AUTHORITY");
789
- }
790
- if (authority.substrate !== this.pool) {
791
- throw new Error("Managed reporting Core and add-on stores must share the same PostgreSQL pool");
792
- }
793
- if (authority.managedDelivery !== true) {
794
- throw new Error("Managed reporting requires PostgresReportingLedgerStore({ managedDelivery: true })");
795
- }
796
- await this.ensurePolicyRegistered();
797
- const result = await this.query(
798
- `SELECT to_regclass('adcp_reporting_managed_bindings') IS NOT NULL
799
- AND to_regclass('adcp_reporting_materializations') IS NOT NULL
800
- AND to_regclass('adcp_reporting_receipts') IS NOT NULL
801
- AND EXISTS (
802
- SELECT 1 FROM information_schema.columns
803
- WHERE table_schema = current_schema()
804
- AND table_name = 'adcp_reporting_managed_policy'
805
- AND column_name = 'materialization_planning_cursor'
806
- ) AS ready`
807
- );
808
- if (result.rows[0]?.ready !== true) {
809
- throw new Error(
810
- "Managed reporting schema is unavailable; apply REPORTING_MANAGED_DELIVERY_MIGRATION after REPORTING_LEDGER_MIGRATION"
811
- );
812
- }
813
- return true;
814
- }
815
- async listInstalledRecoveryWindowSeconds() {
816
- const result = await this.query(
817
- `SELECT DISTINCT configuration.data->'schedule'->>'recoveryWindowMilliseconds' AS milliseconds
818
- FROM adcp_reporting_managed_bindings binding
819
- JOIN adcp_reporting_configurations configuration
820
- ON configuration.configuration_id = binding.configuration_id
821
- ORDER BY milliseconds`
822
- );
823
- return result.rows.map((row) => {
824
- const milliseconds = Number(row.milliseconds);
825
- if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) {
826
- throw new Error("Installed managed Core recovery windows must be non-negative");
827
- }
828
- return Math.ceil(milliseconds / 1e3);
829
- });
830
- }
831
- async authorizeDestination(input) {
832
- positiveInteger(input.generation, "generation");
833
- const pattern = input.pattern ?? null;
834
- const transport = input.transport ?? null;
835
- if (pattern !== null && !import_managed.REPORTING_DESTINATION_PATTERNS_V1.includes(pattern)) {
836
- throw new TypeError(
837
- "Destination authorization pattern must be file_transfer, dataset_share, or warehouse_materialization"
838
- );
839
- }
840
- if (transport !== null) {
841
- if (pattern === null) throw new TypeError("Destination authorization transport requires its pattern");
842
- if (typeof transport !== "string" || !import_managed.REPORTING_DESTINATION_TRANSPORT_PATTERN_V1.test(transport)) {
843
- throw new TypeError("Destination authorization transport must match ^[a-z][a-z0-9_.-]{0,63}$");
844
- }
845
- }
846
- await this.transaction(async (client) => {
847
- await advisoryLock(client, accountLock(input.account_id));
848
- await advisoryLock(client, authLock(input.account_id, input.destination_ref));
849
- const current = await client.query(
850
- `SELECT generation::text, pattern, transport FROM adcp_reporting_destination_authorizations
851
- WHERE account_id = $1 AND destination_ref = $2 AND revoked_at IS NULL`,
852
- [input.account_id, input.destination_ref]
853
- );
854
- if (current.rowCount) {
855
- if (Number(current.rows[0].generation) === input.generation) {
856
- const recorded2 = current.rows[0];
857
- if (pattern !== null && pattern !== recorded2.pattern || transport !== null && transport !== recorded2.transport) {
858
- throw new Error("Destination authorization pattern and transport are immutable for a generation");
859
- }
860
- return;
861
- }
862
- throw new Error("A destination authorization generation is already current");
863
- }
864
- const latest = await client.query(
865
- `SELECT MAX(generation)::text AS generation FROM adcp_reporting_destination_authorizations
866
- WHERE account_id = $1 AND destination_ref = $2`,
867
- [input.account_id, input.destination_ref]
868
- );
869
- if (input.generation <= Number(latest.rows[0]?.generation ?? 0)) {
870
- throw new Error("Destination authorization generation must increase after revocation");
871
- }
872
- await client.query(
873
- // Both ends of the authorization window come from the committing
874
- // database. Mixing a caller's `authorized_at` with a DB-clock
875
- // `revoked_at` also violates the table's own ordering check whenever
876
- // the caller's clock runs ahead.
877
- `INSERT INTO adcp_reporting_destination_authorizations
878
- (account_id, destination_ref, generation, authorized_at, pattern, transport, data)
879
- VALUES ($1, $2, $3, clock_timestamp(), $6::text, $7::text,
880
- $4::jsonb || jsonb_build_object(
881
- 'authorized_at', ${rfc3339Micro("clock_timestamp()")},
882
- 'requested_authorized_at', $5::text))`,
883
- [
884
- input.account_id,
885
- input.destination_ref,
886
- input.generation,
887
- JSON.stringify(input),
888
- input.authorized_at,
889
- pattern,
890
- transport
891
- ]
892
- );
893
- });
894
- }
895
- async revokeDestination(input) {
896
- return this.transaction(async (client) => {
897
- await advisoryLock(client, accountLock(input.account_id));
898
- await advisoryLock(client, authLock(input.account_id, input.destination_ref));
899
- const updated = await client.query(
900
- // The SLA is measured from this instant and evaluated against
901
- // clock_timestamp() everywhere else, so recording a caller's host
902
- // clock let a fast host shorten the promised window and a slow one
903
- // extend it. The caller's value is kept only as stated intent.
904
- `UPDATE adcp_reporting_destination_authorizations
905
- SET revoked_at = clock_timestamp(),
906
- changed_at = clock_timestamp(),
907
- data = data || jsonb_build_object(
908
- 'revoked_at', ${rfc3339Micro("clock_timestamp()")},
909
- 'requested_revoked_at', $4::text
910
- )
911
- WHERE account_id = $1 AND destination_ref = $2 AND generation = $3
912
- AND revoked_at IS NULL`,
913
- [input.account_id, input.destination_ref, input.generation, input.revoked_at]
914
- );
915
- await client.query(
916
- `UPDATE adcp_reporting_materializations SET
917
- status = 'failed',
918
- data = data || jsonb_build_object(
919
- 'status', 'failed',
920
- 'failed_at', ${rfc3339Micro("clock_timestamp()")},
921
- 'failure_code', 'AUTHORIZATION_REVOKED'
922
- ),
923
- changed_at = clock_timestamp(), lease_owner = NULL, lease_expires_at = NULL
924
- WHERE account_id = $1 AND destination_ref = $2 AND authorization_generation = $3
925
- AND status = 'pending'`,
926
- [input.account_id, input.destination_ref, input.generation]
927
- );
928
- return updated.rowCount === 1;
929
- });
930
- }
931
- setBindingAdmission(admit) {
932
- this.bindingAdmission = admit;
933
- }
934
- async installBinding(binding) {
935
- positiveInteger(binding.resource_retention_days, "resource_retention_days");
936
- if (binding.reconciliation_mode === "consumer_receipt" && binding.verification_profile !== "canonical_digest") {
937
- throw new Error("Reconciled Billing bindings require canonical-digest verification");
938
- }
939
- const expectedFingerprint = managedBindingFingerprint(binding);
940
- if (binding.semantic_fingerprint !== expectedFingerprint) {
941
- throw new Error("Managed binding semantic fingerprint does not match its immutable content");
942
- }
943
- this.bindingAdmission?.(binding);
944
- await this.ensurePolicyRegistered();
945
- return this.transaction(async (client) => {
946
- const registeredPolicy = await this.readPolicy(client, "update");
947
- await advisoryLock(client, accountLock(binding.account_id));
948
- await advisoryLock(client, `adcp-reporting-binding:${binding.account_id}:${binding.delivery_config_id}`);
949
- const existing = await client.query(
950
- "SELECT semantic_fingerprint FROM adcp_reporting_managed_bindings WHERE configuration_id = $1",
951
- [binding.configurationId]
952
- );
953
- if (existing.rowCount) {
954
- if (existing.rows[0]?.semantic_fingerprint !== expectedFingerprint) {
955
- throw new Error("Immutable managed binding identity names different content");
956
- }
957
- return { inserted: false };
958
- }
959
- const eligible = await client.query(
960
- `SELECT configuration.data AS configuration,
961
- authz.pattern AS authz_pattern, authz.transport AS authz_transport
962
- FROM adcp_reporting_configurations configuration
963
- JOIN adcp_reporting_destination_authorizations authz
964
- ON authz.account_id = $2 AND authz.destination_ref = $5
965
- AND authz.generation = $6 AND authz.revoked_at IS NULL
966
- WHERE configuration.configuration_id = $1 AND configuration.account_id = $2
967
- AND configuration.delivery_config_id = $3 AND configuration.delivery_config_version = $4
968
- AND NOT EXISTS (
969
- SELECT 1 FROM adcp_reporting_obligations obligation
970
- WHERE obligation.configuration_id = configuration.configuration_id
971
- )`,
972
- [
973
- binding.configurationId,
974
- binding.account_id,
975
- binding.delivery_config_id,
976
- binding.delivery_config_version,
977
- binding.destination_ref,
978
- binding.authorization_generation
979
- ]
980
- );
981
- if (!eligible.rowCount) throw new Error("Managed binding is not authorized for the exact Core configuration");
982
- const configuration = eligible.rows[0].configuration;
983
- const recordedContract = eligible.rows[0];
984
- if (recordedContract.authz_pattern !== null && binding.method !== recordedContract.authz_pattern) {
985
- throw new Error("Managed binding method differs from the pattern recorded on its destination authorization");
986
- }
987
- if (recordedContract.authz_transport !== null && binding.transport !== recordedContract.authz_transport) {
988
- throw new Error(
989
- "Managed binding transport differs from the transport recorded on its destination authorization"
990
- );
991
- }
992
- const advertisedRecoveryWindowSeconds = registeredPolicy.recoveryWindowSeconds;
993
- if (advertisedRecoveryWindowSeconds !== null) {
994
- const windowMilliseconds = Number(configuration.schedule?.recoveryWindowMilliseconds);
995
- if (!Number.isFinite(windowMilliseconds) || windowMilliseconds < 0) {
996
- throw new Error("Managed binding Core configuration has no usable recovery window");
997
- }
998
- const windowSeconds = Math.ceil(windowMilliseconds / 1e3);
999
- if (windowSeconds > advertisedRecoveryWindowSeconds) {
1000
- throw new Error(
1001
- `Managed binding Core recovery window is ${windowSeconds}s but this agent advertises automated_recovery_window_seconds ${advertisedRecoveryWindowSeconds}s; installing it would publish a recovery bound the deployment does not keep`
1002
- );
1003
- }
1004
- }
1005
- if (binding.feed_purpose === "billing" && binding.reconciliation_mode !== "consumer_receipt") {
1006
- throw new Error("Billing managed bindings require consumer-receipt reconciliation");
1007
- }
1008
- if (binding.feed_purpose === "billing" && configuration.requiredFinality !== "official") {
1009
- throw new Error("Billing managed bindings require a Core configuration with official finality");
1010
- }
1011
- if (configuration.feedPurpose !== binding.feed_purpose) {
1012
- throw new Error("Managed binding feed purpose differs from the exact Core configuration");
1013
- }
1014
- if (binding.reconciliation_mode === "consumer_receipt" && !configuration.canonicalization) {
1015
- throw new Error("Reconciled Billing requires a Core configuration with pinned canonicalization");
1016
- }
1017
- const inserted = await client.query(
1018
- `INSERT INTO adcp_reporting_managed_bindings
1019
- (configuration_id, account_id, delivery_config_id, delivery_config_version,
1020
- destination_ref, authorization_generation, semantic_fingerprint, data, created_at)
1021
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)
1022
- ON CONFLICT DO NOTHING RETURNING semantic_fingerprint`,
1023
- [
1024
- binding.configurationId,
1025
- binding.account_id,
1026
- binding.delivery_config_id,
1027
- binding.delivery_config_version,
1028
- binding.destination_ref,
1029
- binding.authorization_generation,
1030
- binding.semantic_fingerprint,
1031
- JSON.stringify(binding),
1032
- binding.created_at
1033
- ]
1034
- );
1035
- if (!inserted.rowCount) {
1036
- const raced = await client.query(
1037
- "SELECT semantic_fingerprint FROM adcp_reporting_managed_bindings WHERE configuration_id = $1",
1038
- [binding.configurationId]
1039
- );
1040
- if (raced.rows[0]?.semantic_fingerprint !== binding.semantic_fingerprint) {
1041
- throw new Error("Immutable managed binding identity names different content");
1042
- }
1043
- }
1044
- return { inserted: inserted.rowCount === 1 };
1045
- });
1046
- }
1047
- async planMaterializations(input = {}) {
1048
- const limit = input.limit ?? MAX_PLAN;
1049
- positiveInteger(limit, "limit");
1050
- if (limit > MAX_PLAN) throw new RangeError(`limit must not exceed ${MAX_PLAN}`);
1051
- if (!input.account_id) {
1052
- const accounts = await this.claimMaterializationPlanningAccounts(limit);
1053
- let planned = 0;
1054
- let firstPass = true;
1055
- while (planned < limit) {
1056
- let progressed = 0;
1057
- for (let index = 0; index < accounts.length && planned < limit; index += 1) {
1058
- const accountsRemaining = accounts.length - index;
1059
- const accountLimit = firstPass ? Math.max(1, Math.floor((limit - planned) / accountsRemaining)) : limit - planned;
1060
- const accountPlanned = await this.planMaterializations({
1061
- account_id: accounts[index],
1062
- limit: accountLimit
1063
- });
1064
- planned += accountPlanned;
1065
- progressed += accountPlanned;
1066
- }
1067
- if (progressed === 0) break;
1068
- firstPass = false;
1069
- }
1070
- return planned;
1071
- }
1072
- return this.transaction(async (client) => {
1073
- const accounts = await client.query(
1074
- `SELECT DISTINCT account_id FROM adcp_reporting_managed_bindings
1075
- WHERE ($1::text IS NULL OR account_id = $1) ORDER BY account_id`,
1076
- [input.account_id ?? null]
1077
- );
1078
- for (const { account_id } of accounts.rows) await advisoryLock(client, accountLock(account_id));
1079
- const capacity = await client.query(
1080
- `SELECT account_id, COUNT(*)::text AS count FROM adcp_reporting_materializations
1081
- WHERE ($1::text IS NULL OR account_id = $1)${this.activeScope("recorded_at")} GROUP BY account_id`,
1082
- [input.account_id ?? null]
1083
- );
1084
- const remainingByAccount = new Map(
1085
- accounts.rows.map(({ account_id }) => [
1086
- account_id,
1087
- MAX_MATERIALIZATIONS_PER_ACCOUNT - Number(capacity.rows.find((value) => value.account_id === account_id)?.count ?? 0)
1088
- ])
1089
- );
1090
- const candidates = await client.query(
1091
- `SELECT binding.data AS binding, obligation.data AS obligation, revision.revision_id AS revision_id,
1092
- GREATEST(COALESCE(MAX(existing.attempt), 0), COALESCE(MAX(tomb.highest_attempt), 0))::integer + 1
1093
- AS attempt
1094
- FROM adcp_reporting_managed_bindings binding
1095
- JOIN adcp_reporting_destination_authorizations authz
1096
- ON authz.account_id = binding.account_id
1097
- AND authz.destination_ref = binding.destination_ref
1098
- AND authz.generation = binding.authorization_generation
1099
- AND authz.revoked_at IS NULL
1100
- JOIN adcp_reporting_obligations obligation ON obligation.configuration_id = binding.configuration_id
1101
- JOIN adcp_reporting_revisions revision ON revision.obligation_id = obligation.obligation_id
1102
- LEFT JOIN adcp_reporting_materializations existing
1103
- ON existing.configuration_id = binding.configuration_id AND existing.revision_id = revision.revision_id
1104
- -- Pruned attempts still count. Without this a revision that exhausted
1105
- -- its attempts, or already succeeded, restarts at attempt 1 as soon as
1106
- -- its rows age out of retention.
1107
- LEFT JOIN adcp_reporting_materialization_tombstones tomb
1108
- ON tomb.configuration_id = binding.configuration_id AND tomb.revision_id = revision.revision_id
1109
- WHERE ($1::text IS NULL OR binding.account_id = $1)
1110
- GROUP BY binding.configuration_id, binding.data, obligation.obligation_id, obligation.data,
1111
- revision.revision_id
1112
- HAVING NOT COALESCE(BOOL_OR(existing.status IN ('pending','available','delivered')), false)
1113
- AND NOT COALESCE(BOOL_OR(tomb.reached_success), false)
1114
- AND GREATEST(COALESCE(MAX(existing.attempt), 0), COALESCE(MAX(tomb.highest_attempt), 0))
1115
- < ${MAX_MATERIALIZATION_ATTEMPTS}
1116
- ORDER BY MIN(revision.recorded_at), revision.revision_id
1117
- LIMIT $2`,
1118
- [input.account_id ?? null, limit]
1119
- );
1120
- let planned = 0;
1121
- for (const candidate of candidates.rows) {
1122
- const binding = candidate.binding;
1123
- const remaining = remainingByAccount.get(binding.account_id) ?? 0;
1124
- if (remaining <= 0) continue;
1125
- const obligation = candidate.obligation;
1126
- const revisionId = candidate.revision_id;
1127
- const created_at = (/* @__PURE__ */ new Date()).toISOString();
1128
- const materialization = {
1129
- reporting_materialization_id: `rmat_${(0, import_node_crypto.randomUUID)()}`,
1130
- reporting_revision_id: revisionId,
1131
- reporting_obligation_id: obligation.reporting_obligation_id,
1132
- delivery_config_id: binding.delivery_config_id,
1133
- delivery_config_version: binding.delivery_config_version,
1134
- destination_ref: binding.destination_ref,
1135
- feed_purpose: binding.feed_purpose,
1136
- method: binding.method,
1137
- ...binding.transport ? { transport: binding.transport } : {},
1138
- attempt: candidate.attempt,
1139
- status: "pending",
1140
- created_at
1141
- };
1142
- const inserted = await client.query(
1143
- `INSERT INTO adcp_reporting_materializations
1144
- (materialization_id, account_id, configuration_id, obligation_id, revision_id,
1145
- destination_ref, authorization_generation, attempt, status, data, created_at)
1146
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9::jsonb,$10)
1147
- ON CONFLICT DO NOTHING`,
1148
- [
1149
- materialization.reporting_materialization_id,
1150
- binding.account_id,
1151
- binding.configurationId,
1152
- obligation.reporting_obligation_id,
1153
- revisionId,
1154
- binding.destination_ref,
1155
- binding.authorization_generation,
1156
- candidate.attempt,
1157
- JSON.stringify(materialization),
1158
- created_at
1159
- ]
1160
- );
1161
- planned += inserted.rowCount ?? 0;
1162
- if (inserted.rowCount) remainingByAccount.set(binding.account_id, remaining - 1);
1163
- }
1164
- return planned;
1165
- });
1166
- }
1167
- /**
1168
- * Claims a bounded, durable round-robin page of accounts that currently
1169
- * have at least one materialization candidate.
1170
- *
1171
- * The policy row serializes only the final cursor compare-and-set. Eligible
1172
- * account discovery runs without that global lock so retained revision
1173
- * history cannot block materialization settlement or revocation. The cursor
1174
- * advances before provider work; a crash may defer a selected account until
1175
- * the ring wraps, but can never pin every future sweep behind it.
1176
- */
1177
- async claimMaterializationPlanningAccounts(limit) {
1178
- for (let contentionAttempt = 0; contentionAttempt < 4; contentionAttempt += 1) {
1179
- const policy = await this.query(
1180
- `SELECT materialization_planning_cursor
1181
- FROM adcp_reporting_managed_policy
1182
- WHERE policy_key = 'agent'`
1183
- );
1184
- if (!policy.rows[0]) {
1185
- throw new Error(
1186
- "Managed reporting policy sentinel is missing; apply REPORTING_MANAGED_DELIVERY_MIGRATION before use"
1187
- );
1188
- }
1189
- const eligiblePageSql = `SELECT DISTINCT binding.account_id
1190
- FROM adcp_reporting_managed_bindings binding
1191
- JOIN adcp_reporting_destination_authorizations authz
1192
- ON authz.account_id = binding.account_id
1193
- AND authz.destination_ref = binding.destination_ref
1194
- AND authz.generation = binding.authorization_generation
1195
- AND authz.revoked_at IS NULL
1196
- JOIN adcp_reporting_obligations obligation
1197
- ON obligation.configuration_id = binding.configuration_id
1198
- JOIN adcp_reporting_revisions revision
1199
- ON revision.obligation_id = obligation.obligation_id
1200
- WHERE ($1::text IS NULL OR CASE WHEN $3::boolean
1201
- THEN binding.account_id > $1
1202
- ELSE binding.account_id <= $1
1203
- END)
1204
- AND NOT EXISTS (
1205
- SELECT 1 FROM adcp_reporting_materializations existing
1206
- WHERE existing.configuration_id = binding.configuration_id
1207
- AND existing.revision_id = revision.revision_id
1208
- AND existing.status IN ('pending', 'available', 'delivered')
1209
- )
1210
- AND NOT EXISTS (
1211
- SELECT 1 FROM adcp_reporting_materialization_tombstones tomb
1212
- WHERE tomb.configuration_id = binding.configuration_id
1213
- AND tomb.revision_id = revision.revision_id
1214
- AND tomb.reached_success
1215
- )
1216
- AND GREATEST(
1217
- COALESCE((
1218
- SELECT MAX(existing.attempt)
1219
- FROM adcp_reporting_materializations existing
1220
- WHERE existing.configuration_id = binding.configuration_id
1221
- AND existing.revision_id = revision.revision_id
1222
- ), 0),
1223
- COALESCE((
1224
- SELECT tomb.highest_attempt
1225
- FROM adcp_reporting_materialization_tombstones tomb
1226
- WHERE tomb.configuration_id = binding.configuration_id
1227
- AND tomb.revision_id = revision.revision_id
1228
- ), 0)
1229
- ) < ${MAX_MATERIALIZATION_ATTEMPTS}
1230
- AND (
1231
- SELECT COUNT(*) FROM adcp_reporting_materializations capacity
1232
- WHERE capacity.account_id = binding.account_id${this.activeScope("capacity.recorded_at")}
1233
- ) < ${MAX_MATERIALIZATIONS_PER_ACCOUNT}
1234
- ORDER BY binding.account_id
1235
- LIMIT $2`;
1236
- const cursor = policy.rows[0].materialization_planning_cursor;
1237
- const after = await this.query(eligiblePageSql, [cursor, limit, true]);
1238
- const selected = after.rows.map((row) => row.account_id);
1239
- if (cursor !== null && selected.length < limit) {
1240
- const wrapped = await this.query(eligiblePageSql, [
1241
- cursor,
1242
- limit - selected.length,
1243
- false
1244
- ]);
1245
- selected.push(...wrapped.rows.map((row) => row.account_id));
1246
- }
1247
- if (selected.length === 0) return [];
1248
- const advanced = await this.transaction(async (client) => {
1249
- const current = await client.query(
1250
- `SELECT materialization_planning_cursor
1251
- FROM adcp_reporting_managed_policy
1252
- WHERE policy_key = 'agent'
1253
- FOR UPDATE`
1254
- );
1255
- if (!current.rows[0]) {
1256
- throw new Error(
1257
- "Managed reporting policy sentinel is missing; apply REPORTING_MANAGED_DELIVERY_MIGRATION before use"
1258
- );
1259
- }
1260
- if (current.rows[0].materialization_planning_cursor !== cursor) return false;
1261
- await client.query(
1262
- `UPDATE adcp_reporting_managed_policy
1263
- SET materialization_planning_cursor = $1, changed_at = clock_timestamp()
1264
- WHERE policy_key = 'agent'`,
1265
- [selected[selected.length - 1]]
1266
- );
1267
- return true;
1268
- });
1269
- if (advanced) return selected;
1270
- }
1271
- return [];
1272
- }
1273
- async claimMaterialization(input) {
1274
- positiveInteger(input.lease_milliseconds, "lease_milliseconds");
1275
- const lease = await this.transaction(async (client) => {
1276
- const selected = await client.query(
1277
- `WITH candidate AS (
1278
- SELECT materialization.materialization_id
1279
- FROM adcp_reporting_materializations materialization
1280
- JOIN adcp_reporting_destination_authorizations authz
1281
- ON authz.account_id = materialization.account_id
1282
- AND authz.destination_ref = materialization.destination_ref
1283
- AND authz.generation = materialization.authorization_generation
1284
- AND authz.revoked_at IS NULL
1285
- WHERE materialization.status = 'pending'
1286
- AND ($1::text IS NULL OR materialization.account_id = $1)
1287
- -- Every claim of a pending row is a delivery attempt, settled
1288
- -- or not. The attempt column only advances in the planner, so a
1289
- -- row whose settle kept losing its lease was reclaimed without
1290
- -- bound and re-delivered each time. Lease generation is the
1291
- -- durable count of those attempts.
1292
- AND materialization.lease_generation < ${MAX_MATERIALIZATION_ATTEMPTS}
1293
- -- One authoritative clock. settleMaterialization fences on
1294
- -- clock_timestamp(), so issuing the lease from the caller's clock
1295
- -- made ordinary NTP drift fatal: the claim succeeded, the settle
1296
- -- matched zero rows, and the row stayed 'pending' at attempt 1
1297
- -- where the planner's HAVING cannot see it \u2014 so the attempt cap
1298
- -- never engaged and the adapter was asked to deliver again, and
1299
- -- again, with no bound.
1300
- AND (materialization.lease_expires_at IS NULL OR materialization.lease_expires_at <= clock_timestamp())
1301
- ORDER BY materialization.created_at, materialization.materialization_id
1302
- FOR UPDATE OF materialization SKIP LOCKED LIMIT 1
1303
- ), claimed AS (
1304
- UPDATE adcp_reporting_materializations materialization
1305
- SET lease_owner = $2, lease_generation = lease_generation + 1,
1306
- lease_expires_at = clock_timestamp() + ($3::bigint * INTERVAL '1 millisecond')
1307
- FROM candidate WHERE materialization.materialization_id = candidate.materialization_id
1308
- RETURNING materialization.*
1309
- )
1310
- SELECT claimed.materialization_id, claimed.lease_generation::text AS generation,
1311
- claimed.lease_expires_at AS expires_at, claimed.data AS materialization,
1312
- binding.data AS binding, obligation.data AS obligation, revision.data AS revision
1313
- FROM claimed
1314
- JOIN adcp_reporting_managed_bindings binding ON binding.configuration_id = claimed.configuration_id
1315
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = claimed.obligation_id
1316
- JOIN adcp_reporting_revisions revision ON revision.revision_id = claimed.revision_id`,
1317
- [input.account_id ?? null, input.owner, input.lease_milliseconds]
1318
- );
1319
- const row = selected.rows[0];
1320
- if (!row) return null;
1321
- return {
1322
- materialization: structuredClone(row.materialization),
1323
- binding: structuredClone(row.binding),
1324
- obligation: structuredClone(row.obligation),
1325
- revision: structuredClone(row.revision),
1326
- owner: input.owner,
1327
- generation: Number(row.generation),
1328
- expires_at: row.expires_at.toISOString()
1329
- };
1330
- });
1331
- if (!lease || Array.isArray(lease.revision.rows)) return lease;
1332
- try {
1333
- const handle = await this.rowStorage.find(
1334
- lease.revision.reporting_revision_id,
1335
- lease.obligation.account.account_id
1336
- );
1337
- if (handle?.kind === "postgres") {
1338
- lease.revision = {
1339
- ...lease.revision,
1340
- rows: await this.rowStorage.readAll(handle, lease.revision.wireRevision.control_totals)
1341
- };
1342
- }
1343
- } catch (error) {
1344
- if (!(0, import_row_storage.isReportingRowStoreError)(error)) throw error;
1345
- }
1346
- return lease;
1347
- }
1348
- /**
1349
- * Fails materializations that have used every delivery attempt.
1350
- *
1351
- * The claim predicate stops handing them out, so without this they would
1352
- * stay `pending` — invisible to the planner, which skips an obligation with
1353
- * any pending row, and so never retried or replanned.
1354
- */
1355
- async failExhaustedMaterializations(input) {
1356
- const limit = input?.limit ?? 1e3;
1357
- positiveInteger(limit, "limit");
1358
- return this.transaction(async (client) => {
1359
- const accounts = await client.query(
1360
- `SELECT DISTINCT account_id FROM adcp_reporting_materializations
1361
- WHERE status = 'pending'
1362
- AND lease_generation >= ${MAX_MATERIALIZATION_ATTEMPTS}
1363
- AND (lease_expires_at IS NULL OR lease_expires_at <= clock_timestamp())
1364
- AND ($1::text IS NULL OR account_id = $1)
1365
- ORDER BY account_id`,
1366
- [input?.account_id ?? null]
1367
- );
1368
- for (const { account_id } of accounts.rows) await advisoryLock(client, accountLock(account_id));
1369
- const lockedAccounts = accounts.rows.map((row) => row.account_id);
1370
- const result = await client.query(
1371
- `UPDATE adcp_reporting_materializations SET status = 'failed',
1372
- data = data || jsonb_build_object(
1373
- 'status', 'failed',
1374
- 'failed_at', ${rfc3339Micro("clock_timestamp()")},
1375
- 'failure_code', 'DELIVERY_ATTEMPTS_EXHAUSTED'
1376
- ),
1377
- changed_at = clock_timestamp(), lease_owner = NULL, lease_expires_at = NULL
1378
- WHERE
1379
- -- Re-evaluated after the row lock, not only as a subquery. The
1380
- -- subquery runs against the statement's snapshot, so a settle that
1381
- -- committed while this UPDATE waited on the row was overwritten:
1382
- -- a delivered materialization became failed /
1383
- -- DELIVERY_ATTEMPTS_EXHAUSTED. Repeating the predicates here makes
1384
- -- PostgreSQL recheck them against the committed row.
1385
- status = 'pending'
1386
- AND lease_generation >= ${MAX_MATERIALIZATION_ATTEMPTS}
1387
- AND (lease_expires_at IS NULL OR lease_expires_at <= clock_timestamp())
1388
- AND account_id = ANY($2::text[])
1389
- AND materialization_id IN (
1390
- SELECT materialization_id FROM adcp_reporting_materializations
1391
- WHERE status = 'pending'
1392
- AND lease_generation >= ${MAX_MATERIALIZATION_ATTEMPTS}
1393
- AND (lease_expires_at IS NULL OR lease_expires_at <= clock_timestamp())
1394
- AND account_id = ANY($2::text[])
1395
- ORDER BY created_at LIMIT $1)`,
1396
- [limit, lockedAccounts]
1397
- );
1398
- return result.rowCount ?? 0;
1399
- });
1400
- }
1401
- async settleMaterialization(input) {
1402
- const requested = input.minimum_resource_retention_days;
1403
- if (requested !== void 0 && (!Number.isSafeInteger(requested) || requested < 0)) {
1404
- throw new RangeError("minimum_resource_retention_days must be a non-negative safe integer");
1405
- }
1406
- if (input.outcome.status !== "failed" && input.outcome.resource.expires_at !== void 0) {
1407
- (0, import_managed.assertMaterializationOutcome)(
1408
- input.lease,
1409
- input.outcome,
1410
- input.now,
1411
- Math.max(requested ?? 0, input.lease.binding.resource_retention_days)
1412
- );
1413
- }
1414
- return this.transaction(async (client) => {
1415
- const { lease } = input;
1416
- const registeredPolicy = await this.readPolicy(client, "share");
1417
- await advisoryLock(client, accountLock(lease.binding.account_id));
1418
- const minimumRetentionDays = Math.max(
1419
- requested ?? 0,
1420
- lease.binding.resource_retention_days,
1421
- registeredPolicy.resourceRetentionDays ?? 0
1422
- );
1423
- const authorized = await client.query(
1424
- `SELECT 1 FROM adcp_reporting_destination_authorizations
1425
- WHERE account_id = $1 AND destination_ref = $2 AND generation = $3 AND revoked_at IS NULL`,
1426
- [lease.binding.account_id, lease.binding.destination_ref, lease.binding.authorization_generation]
1427
- );
1428
- const outcome = authorized.rowCount ? input.outcome : { status: "failed", failure_code: "AUTHORIZATION_REVOKED" };
1429
- const materialization = {
1430
- ...lease.materialization,
1431
- status: outcome.status,
1432
- ...outcome.status === "failed" ? { failed_at: input.now, failure_code: outcome.failure_code } : { ready_at: input.now, resource: outcome.resource, verification: outcome.verification }
1433
- };
1434
- const updated = await client.query(
1435
- `UPDATE adcp_reporting_materializations SET status = $5, data = $6::jsonb,
1436
- changed_at = clock_timestamp(), lease_owner = NULL, lease_expires_at = NULL
1437
- WHERE materialization_id = $1 AND lease_owner = $2 AND lease_generation = $3
1438
- AND lease_expires_at > clock_timestamp() AND status = 'pending' AND authorization_generation = $4
1439
- -- Retention is judged entirely by the clock that stores it. A
1440
- -- worker running behind could otherwise satisfy the window on its
1441
- -- own clock and publish a resource the database already considers
1442
- -- expired, or under-retain one \u2014 leaving a delivered
1443
- -- materialization whose bytes no reader can fetch.
1444
- -- Exempt only a failed outcome, which retains nothing. Treating
1445
- -- a NULL expiry as exempt let a direct caller settle a
1446
- -- successful materialization with no expiry at all: permanently
1447
- -- readable to the projection, permanently unreadable in fact,
1448
- -- and never revisited because it is not pending.
1449
- AND ($5 = 'failed'
1450
- OR ($7::timestamptz IS NOT NULL
1451
- AND $7::timestamptz >= clock_timestamp() + ($8::bigint * INTERVAL '1 day')))`,
1452
- [
1453
- lease.materialization.reporting_materialization_id,
1454
- lease.owner,
1455
- lease.generation,
1456
- lease.binding.authorization_generation,
1457
- outcome.status,
1458
- JSON.stringify(materialization),
1459
- outcome.status === "failed" ? null : outcome.resource.expires_at,
1460
- minimumRetentionDays
1461
- ]
1462
- );
1463
- if (updated.rowCount === 1 && authorized.rowCount === 1 && outcome.status !== "failed" && this.notificationActivityPort?.recordDeliveryReady) {
1464
- await this.notificationActivityPort.recordDeliveryReady(
1465
- {
1466
- obligation: lease.obligation,
1467
- revision: lease.revision,
1468
- materialization
1469
- },
1470
- client
1471
- );
1472
- }
1473
- if (updated.rowCount === 0 && outcome.status !== "failed") {
1474
- await client.query(
1475
- `UPDATE adcp_reporting_materializations SET status = 'failed',
1476
- data = data || jsonb_build_object(
1477
- 'status', 'failed',
1478
- 'failed_at', ${rfc3339Micro("clock_timestamp()")},
1479
- 'failure_code', 'RESOURCE_RETENTION_INSUFFICIENT'
1480
- ),
1481
- changed_at = clock_timestamp(), lease_owner = NULL, lease_expires_at = NULL
1482
- -- Deliberately not fenced on an unexpired lease. If the settle
1483
- -- waited on the account lock until the lease expired, both it and
1484
- -- this terminalizer rejected, leaving the row pending \u2014
1485
- -- reclaimable forever, and re-delivered each time. Owner and
1486
- -- generation still fence it against a newer holder.
1487
- WHERE materialization_id = $1 AND lease_owner = $2 AND lease_generation = $3
1488
- AND status = 'pending'`,
1489
- [lease.materialization.reporting_materialization_id, lease.owner, lease.generation]
1490
- );
1491
- }
1492
- return updated.rowCount === 1 && authorized.rowCount === 1 && outcome.status !== "failed";
1493
- });
1494
- }
1495
- async claimRevocation(input) {
1496
- positiveInteger(input.lease_milliseconds, "lease_milliseconds");
1497
- if (input.authorization_revocation_seconds !== void 0) {
1498
- nonnegativeSafeInteger(input.authorization_revocation_seconds, "authorization_revocation_seconds");
1499
- }
1500
- return this.transaction(async (client) => {
1501
- const registeredPolicy = await this.readPolicy(client, "share");
1502
- const revocationSeconds = registeredPolicy.authorizationRevocationSeconds === null ? input.authorization_revocation_seconds : Math.min(
1503
- registeredPolicy.authorizationRevocationSeconds,
1504
- input.authorization_revocation_seconds ?? registeredPolicy.authorizationRevocationSeconds
1505
- );
1506
- const result = await client.query(
1507
- `WITH candidate AS (
1508
- SELECT account_id, destination_ref, generation
1509
- FROM adcp_reporting_destination_authorizations
1510
- WHERE revoked_at IS NOT NULL AND cleanup_completed_at IS NULL
1511
- -- Same single-clock rule as claimMaterialization: completeRevocation
1512
- -- fences on clock_timestamp(), so the lease must be issued from it
1513
- -- too or cleanup can never commit and the grant is never torn down.
1514
- --
1515
- -- The last arm reclaims at the SLA boundary. A worker that
1516
- -- crashed holding a long lease would otherwise make the grant
1517
- -- unreclaimable for the rest of that lease while the advertised
1518
- -- window elapsed. Lease generation fences the old holder, so its
1519
- -- late completion cannot commit over the new one.
1520
- -- One stable policy. Reclaiming purely because the SLA had
1521
- -- elapsed let every worker steal from every other on an already
1522
- -- late grant: generations bumped each pass and each holder's
1523
- -- completion was invalidated by the next, so cleanup never
1524
- -- committed at all. A holder is displaced only once it has had a
1525
- -- full attempt's worth of time and still not finished, which
1526
- -- recovers a crashed worker without disturbing a live one.
1527
- AND (cleanup_lease_expires_at IS NULL
1528
- OR cleanup_lease_expires_at <= clock_timestamp()
1529
- OR ($4::bigint IS NOT NULL
1530
- AND revoked_at + ($4::bigint * INTERVAL '1 second') <= clock_timestamp()
1531
- AND cleanup_lease_issued_at IS NOT NULL
1532
- AND cleanup_lease_issued_at + ($5::bigint * INTERVAL '1 millisecond')
1533
- <= clock_timestamp()))
1534
- AND ($3::text IS NULL OR account_id = $3)
1535
- ORDER BY cleanup_lease_generation, revoked_at, account_id, destination_ref
1536
- FOR UPDATE SKIP LOCKED LIMIT 1
1537
- )
1538
- UPDATE adcp_reporting_destination_authorizations target SET
1539
- cleanup_lease_owner = $1, cleanup_lease_generation = target.cleanup_lease_generation + 1,
1540
- cleanup_lease_issued_at = clock_timestamp(),
1541
- cleanup_lease_expires_at = clock_timestamp() + ($2::bigint * INTERVAL '1 millisecond')
1542
- FROM candidate
1543
- WHERE target.account_id = candidate.account_id AND target.destination_ref = candidate.destination_ref
1544
- AND target.generation = candidate.generation
1545
- RETURNING target.data || jsonb_strip_nulls(
1546
- jsonb_build_object('pattern', target.pattern, 'transport', target.transport)
1547
- ) AS data,
1548
- target.cleanup_lease_generation::text AS generation,
1549
- target.cleanup_lease_expires_at AS expires_at,
1550
- target.revoked_at AS revoked_at,
1551
- CASE WHEN $4::bigint IS NULL THEN NULL
1552
- ELSE target.revoked_at + ($4::bigint * INTERVAL '1 second') <= clock_timestamp()
1553
- END AS overdue,
1554
- CASE WHEN $4::bigint IS NULL THEN NULL
1555
- ELSE EXTRACT(EPOCH FROM (target.revoked_at
1556
- + ($4::bigint * INTERVAL '1 second') - clock_timestamp())) * 1000
1557
- END AS remaining_milliseconds`,
1558
- [
1559
- input.owner,
1560
- input.lease_milliseconds,
1561
- input.account_id ?? null,
1562
- revocationSeconds ?? null,
1563
- input.steal_after_milliseconds ?? input.lease_milliseconds
1564
- ]
1565
- );
1566
- const row = result.rows[0];
1567
- if (!row) return null;
1568
- return {
1569
- authorization: structuredClone(row.data),
1570
- owner: input.owner,
1571
- generation: Number(row.generation),
1572
- expires_at: row.expires_at.toISOString(),
1573
- // Every SLA fact comes from the database that committed the
1574
- // revocation, never from the worker host: a skewed worker must not be
1575
- // able to decide a grant is still inside its promised window.
1576
- revoked_at: row.revoked_at.toISOString(),
1577
- ...row.overdue === null ? {} : { overdue: row.overdue },
1578
- ...row.remaining_milliseconds === null ? {} : { remaining_milliseconds: Number(row.remaining_milliseconds) }
1579
- };
1580
- });
1581
- }
1582
- async completeRevocation(input) {
1583
- const result = await this.query(
1584
- `UPDATE adcp_reporting_destination_authorizations SET cleanup_completed_at = $6::timestamptz,
1585
- cleanup_lease_owner = NULL, cleanup_lease_expires_at = NULL,
1586
- data = data || jsonb_build_object('cleanup_completed_at', $6::text)
1587
- WHERE account_id = $1 AND destination_ref = $2 AND generation = $3
1588
- AND cleanup_lease_owner = $4 AND cleanup_lease_generation = $5
1589
- AND cleanup_lease_expires_at > clock_timestamp()
1590
- AND revoked_at IS NOT NULL AND cleanup_completed_at IS NULL`,
1591
- [
1592
- input.lease.authorization.account_id,
1593
- input.lease.authorization.destination_ref,
1594
- input.lease.authorization.generation,
1595
- input.lease.owner,
1596
- input.lease.generation,
1597
- input.completed_at
1598
- ]
1599
- );
1600
- return result.rowCount === 1;
1601
- }
1602
- async releaseRevocation(input) {
1603
- const result = await this.query(
1604
- `UPDATE adcp_reporting_destination_authorizations
1605
- -- Release enables a later recovery, not an immediate one. Clearing
1606
- -- the lease outright let the very next iteration of the same worker
1607
- -- tick reclaim the same grant, so one broken provider consumed
1608
- -- every iteration and nothing else was ever cleaned up. Holding a
1609
- -- short backoff keeps the grant reclaimable soon without that
1610
- -- tight loop; the generation bump still fences the failed holder.
1611
- SET cleanup_lease_owner = NULL, cleanup_lease_issued_at = NULL,
1612
- cleanup_lease_expires_at = clock_timestamp() + ($6::bigint * INTERVAL '1 millisecond')
1613
- WHERE account_id = $1 AND destination_ref = $2 AND generation = $3
1614
- AND cleanup_lease_owner = $4 AND cleanup_lease_generation = $5
1615
- AND cleanup_completed_at IS NULL`,
1616
- [
1617
- input.lease.authorization.account_id,
1618
- input.lease.authorization.destination_ref,
1619
- input.lease.authorization.generation,
1620
- input.lease.owner,
1621
- input.lease.generation,
1622
- input.backoff_milliseconds ?? REVOCATION_RETRY_BACKOFF_MS
1623
- ]
1624
- );
1625
- return result.rowCount === 1;
1626
- }
1627
- async getReadableResource(input) {
1628
- const result = await this.query(
1629
- `SELECT materialization.data AS materialization, binding.data AS binding
1630
- FROM adcp_reporting_materializations materialization
1631
- JOIN adcp_reporting_managed_bindings binding ON binding.configuration_id = materialization.configuration_id
1632
- JOIN adcp_reporting_destination_authorizations authz
1633
- ON authz.account_id = materialization.account_id
1634
- AND authz.destination_ref = materialization.destination_ref
1635
- AND authz.generation = materialization.authorization_generation
1636
- AND authz.revoked_at IS NULL
1637
- WHERE materialization.account_id = $1
1638
- AND materialization.status IN ('available','delivered')
1639
- AND materialization.data->'resource'->>'resource_ref' = $2
1640
- AND (materialization.data->'resource'->>'expires_at')::timestamptz > clock_timestamp()`,
1641
- [input.account_id, input.resource_ref]
1642
- );
1643
- const row = result.rows[0];
1644
- return row ? { materialization: structuredClone(row.materialization), binding: structuredClone(row.binding) } : null;
1645
- }
1646
- /** Freeze all writable names under the same locks as revokeDestination. */
1647
- async registerObjectWritePlan(input, context = {}) {
1648
- const plan = structuredClone(input);
1649
- const fingerprint = (0, import_object_writes.reportingObjectWritePlanFingerprint)(plan);
1650
- return this.transaction(async (client) => {
1651
- await advisoryLock(client, accountLock(plan.account_id));
1652
- await advisoryLock(client, authLock(plan.account_id, plan.destination_ref));
1653
- const authorized = await client.query(
1654
- `SELECT 1 FROM adcp_reporting_destination_authorizations
1655
- WHERE account_id = $1 AND destination_ref = $2 AND generation = $3 AND revoked_at IS NULL`,
1656
- [plan.account_id, plan.destination_ref, plan.generation]
1657
- );
1658
- if (!authorized.rowCount) return "revoked";
1659
- await client.query(
1660
- `INSERT INTO adcp_reporting_object_write_bindings (account_id,destination_ref,generation,bucket,namespace_key)
1661
- VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`,
1662
- [plan.account_id, plan.destination_ref, plan.generation, plan.provider.bucket, plan.provider.namespace_key]
1663
- );
1664
- const binding = await client.query(
1665
- `SELECT bucket,namespace_key FROM adcp_reporting_object_write_bindings
1666
- WHERE account_id=$1 AND destination_ref=$2 AND generation=$3`,
1667
- [plan.account_id, plan.destination_ref, plan.generation]
1668
- );
1669
- if (binding.rows[0]?.bucket !== plan.provider.bucket || binding.rows[0]?.namespace_key !== plan.provider.namespace_key)
1670
- throw new import_object_writes.ReportingObjectWriteConflictError();
1671
- const inserted = await client.query(
1672
- `INSERT INTO adcp_reporting_object_write_plans (account_id, destination_ref, generation, plan_id, fingerprint)
1673
- VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`,
1674
- [plan.account_id, plan.destination_ref, plan.generation, plan.plan_id, fingerprint]
1675
- );
1676
- if (!inserted.rowCount) {
1677
- const existing = await client.query(
1678
- `SELECT fingerprint FROM adcp_reporting_object_write_plans
1679
- WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND plan_id=$4`,
1680
- [plan.account_id, plan.destination_ref, plan.generation, plan.plan_id]
1681
- );
1682
- if (existing.rows[0]?.fingerprint !== fingerprint) throw new import_object_writes.ReportingObjectWriteConflictError();
1683
- return "unchanged";
1684
- }
1685
- await client.query(
1686
- `INSERT INTO adcp_reporting_object_writes
1687
- (account_id,destination_ref,generation,plan_id,object_index,bucket,object_name,sha256,size_bytes)
1688
- SELECT $1,$2,$3,$4,(ordinality-1)::integer,value->>'bucket',value->>'object_name',value->>'sha256',
1689
- (value->>'size_bytes')::bigint FROM jsonb_array_elements($5::jsonb) WITH ORDINALITY`,
1690
- [
1691
- plan.account_id,
1692
- plan.destination_ref,
1693
- plan.generation,
1694
- plan.plan_id,
1695
- JSON.stringify(
1696
- plan.objects.map(({ bucket, object_name, sha256, size_bytes }) => ({
1697
- bucket,
1698
- object_name,
1699
- sha256,
1700
- size_bytes
1701
- }))
1702
- )
1703
- ]
1704
- );
1705
- return "registered";
1706
- }, context.signal);
1707
- }
1708
- async listRevokedObjectWrites(inputScope, inputOptions = {}, context = {}) {
1709
- const scope = structuredClone(inputScope);
1710
- const options = structuredClone(inputOptions);
1711
- (0, import_object_writes.assertReportingObjectWriteScope)(scope);
1712
- const limit = options.limit ?? 100;
1713
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e3 || options.after && (!/^[a-f0-9]{64}$/.test(options.after.plan_id) || !Number.isSafeInteger(options.after.object_index) || options.after.object_index < 0 || options.after.object_index >= 128)) {
1714
- throw new Error("Invalid reporting object-write page");
1715
- }
1716
- return this.transaction(async (client) => {
1717
- await advisoryLock(client, accountLock(scope.account_id));
1718
- await advisoryLock(client, authLock(scope.account_id, scope.destination_ref));
1719
- const revoked = await client.query(
1720
- `SELECT 1 FROM adcp_reporting_destination_authorizations
1721
- WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND revoked_at IS NOT NULL`,
1722
- [scope.account_id, scope.destination_ref, scope.generation]
1723
- );
1724
- if (!revoked.rowCount) throw new import_object_writes.ReportingObjectWriteNotRevokedError();
1725
- const rows = await client.query(
1726
- `SELECT plan_id,object_index,bucket,object_name,sha256,size_bytes::float8 AS size_bytes
1727
- FROM adcp_reporting_object_writes WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND fenced_at IS NULL
1728
- AND ($4::text IS NULL OR (plan_id,object_index) > ($4,$5::integer))
1729
- ORDER BY plan_id,object_index LIMIT $6`,
1730
- [
1731
- scope.account_id,
1732
- scope.destination_ref,
1733
- scope.generation,
1734
- options.after?.plan_id ?? null,
1735
- options.after?.object_index ?? null,
1736
- limit
1737
- ]
1738
- );
1739
- return rows.rows;
1740
- }, context.signal);
1741
- }
1742
- async markObjectWriteFenced(input, context = {}) {
1743
- const frozen = structuredClone(input);
1744
- (0, import_object_writes.assertReportingObjectWriteScope)(frozen);
1745
- if (!/^[a-f0-9]{64}$/.test(frozen.plan_id) || !Number.isSafeInteger(frozen.object_index) || frozen.object_index < 0 || frozen.object_index >= 128 || !/^[1-9][0-9]{0,39}$/.test(frozen.tombstone_generation)) {
1746
- throw new Error("Invalid reporting object-write fence");
1747
- }
1748
- await this.transaction(async (client) => {
1749
- await advisoryLock(client, accountLock(frozen.account_id));
1750
- await advisoryLock(client, authLock(frozen.account_id, frozen.destination_ref));
1751
- const updated = await client.query(
1752
- `UPDATE adcp_reporting_object_writes object SET fenced_at=COALESCE(fenced_at,clock_timestamp()), tombstone_generation=$6
1753
- WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND plan_id=$4 AND object_index=$5
1754
- AND (tombstone_generation IS NULL OR tombstone_generation=$6)
1755
- AND EXISTS (SELECT 1 FROM adcp_reporting_destination_authorizations authz
1756
- WHERE authz.account_id=object.account_id AND authz.destination_ref=object.destination_ref
1757
- AND authz.generation=object.generation AND authz.revoked_at IS NOT NULL)`,
1758
- [
1759
- frozen.account_id,
1760
- frozen.destination_ref,
1761
- frozen.generation,
1762
- frozen.plan_id,
1763
- frozen.object_index,
1764
- frozen.tombstone_generation
1765
- ]
1766
- );
1767
- if (updated.rowCount !== 1) throw new Error("Reporting object-write fence is not eligible");
1768
- }, context.signal);
1769
- }
1770
- async getObjectWriteBinding(input, context = {}) {
1771
- const scope = structuredClone(input);
1772
- (0, import_object_writes.assertReportingObjectWriteScope)(scope);
1773
- return this.transaction(async (client) => {
1774
- await advisoryLock(client, accountLock(scope.account_id));
1775
- await advisoryLock(client, authLock(scope.account_id, scope.destination_ref));
1776
- const revoked = await client.query(
1777
- `SELECT 1 FROM adcp_reporting_destination_authorizations
1778
- WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND revoked_at IS NOT NULL`,
1779
- [scope.account_id, scope.destination_ref, scope.generation]
1780
- );
1781
- if (!revoked.rowCount) throw new import_object_writes.ReportingObjectWriteNotRevokedError();
1782
- const binding = await client.query(
1783
- `SELECT bucket,namespace_key FROM adcp_reporting_object_write_bindings
1784
- WHERE account_id=$1 AND destination_ref=$2 AND generation=$3`,
1785
- [scope.account_id, scope.destination_ref, scope.generation]
1786
- );
1787
- return binding.rows[0] ?? null;
1788
- }, context.signal);
1789
- }
1790
- async probeObjectWriteAuthority(context = {}) {
1791
- await this.transaction(async (client) => {
1792
- await client.query(
1793
- `SELECT installation_id,schema_version FROM adcp_reporting_object_write_authority WHERE singleton AND schema_version=1`
1794
- );
1795
- await client.query(
1796
- `SELECT account_id,destination_ref,generation,bucket,namespace_key FROM adcp_reporting_object_write_bindings LIMIT 0`
1797
- );
1798
- await client.query(
1799
- `SELECT account_id,destination_ref,generation,plan_id,fingerprint FROM adcp_reporting_object_write_plans LIMIT 0`
1800
- );
1801
- await client.query(
1802
- `SELECT plan_id,object_index,bucket,object_name,sha256,size_bytes,fenced_at,tombstone_generation FROM adcp_reporting_object_writes LIMIT 0`
1803
- );
1804
- }, context.signal);
1805
- await this.getObjectWriteAuthorityId(context);
1806
- return true;
1807
- }
1808
- async getObjectWriteAuthorityId(context = {}) {
1809
- return this.transaction(async (client) => {
1810
- const result = await client.query(
1811
- `SELECT installation_id::text FROM adcp_reporting_object_write_authority WHERE singleton AND schema_version=1`
1812
- );
1813
- const id = result.rows[0]?.installation_id;
1814
- if (result.rowCount !== 1 || !id || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id))
1815
- throw new Error("Reporting object-write authority migration is unavailable");
1816
- return id;
1817
- }, context.signal);
1818
- }
1819
- async getAuthorizedObjectWriteBinding(input, context = {}) {
1820
- const scope = {
1821
- account_id: input.account_id,
1822
- destination_ref: input.destination_ref,
1823
- generation: input.generation
1824
- };
1825
- (0, import_object_writes.assertReportingObjectWriteScope)(scope);
1826
- return this.transaction(async (client) => {
1827
- const result = await client.query(
1828
- `SELECT binding.bucket,binding.namespace_key FROM adcp_reporting_object_write_bindings binding
1829
- JOIN adcp_reporting_destination_authorizations authz USING (account_id,destination_ref,generation)
1830
- WHERE binding.account_id=$1 AND binding.destination_ref=$2 AND binding.generation=$3 AND authz.revoked_at IS NULL`,
1831
- [scope.account_id, scope.destination_ref, scope.generation]
1832
- );
1833
- return result.rows[0] ?? null;
1834
- }, context.signal);
1835
- }
1836
- async getObjectWritePlan(input, planId, context = {}) {
1837
- const scope = {
1838
- account_id: input.account_id,
1839
- destination_ref: input.destination_ref,
1840
- generation: input.generation
1841
- };
1842
- (0, import_object_writes.assertReportingObjectWriteScope)(scope);
1843
- if (typeof planId !== "string" || !/^[a-f0-9]{64}$/.test(planId))
1844
- throw new Error("Invalid reporting object-write plan identity");
1845
- return this.transaction(async (client) => {
1846
- const selected = await client.query(
1847
- `SELECT binding.bucket,binding.namespace_key,plan.fingerprint FROM adcp_reporting_object_write_plans plan
1848
- JOIN adcp_reporting_object_write_bindings binding USING (account_id,destination_ref,generation)
1849
- JOIN adcp_reporting_destination_authorizations authz USING (account_id,destination_ref,generation)
1850
- WHERE plan.account_id=$1 AND plan.destination_ref=$2 AND plan.generation=$3 AND plan.plan_id=$4 AND authz.revoked_at IS NULL`,
1851
- [scope.account_id, scope.destination_ref, scope.generation, planId]
1852
- );
1853
- if (!selected.rows[0]) return null;
1854
- const selectedBinding = selected.rows[0];
1855
- const objects = await client.query(
1856
- `SELECT bucket,object_name,sha256,size_bytes::float8 AS size_bytes FROM adcp_reporting_object_writes
1857
- WHERE account_id=$1 AND destination_ref=$2 AND generation=$3 AND plan_id=$4 ORDER BY object_index`,
1858
- [scope.account_id, scope.destination_ref, scope.generation, planId]
1859
- );
1860
- const plan = {
1861
- ...scope,
1862
- plan_id: planId,
1863
- provider: { bucket: selectedBinding.bucket, namespace_key: selectedBinding.namespace_key },
1864
- objects: objects.rows
1865
- };
1866
- if ((0, import_object_writes.reportingObjectWritePlanFingerprint)(plan) !== selectedBinding.fingerprint)
1867
- throw new import_object_writes.ReportingObjectWriteConflictError();
1868
- return plan;
1869
- }, context.signal);
1870
- }
1871
- /**
1872
- * Generations that carry no recorded pattern/transport, with why the
1873
- * migration backfill could not infer them. These are routed to a router's
1874
- * `legacyDefault` (or fail revocation with a typed error) until the host
1875
- * supplies the contract. Read-only; it never edits a row.
1876
- *
1877
- * `no_binding`: nothing to infer from. `binding_without_transport`: at least
1878
- * one binding names no transport. `bindings_disagree`: the bindings name
1879
- * different methods or transports. `not_recorded`: the bindings agree, but
1880
- * the generation was created without a contract after the one-time backfill
1881
- * ran, so nothing records it.
1882
- */
1883
- async listDestinationAuthorizationsWithoutContract(input = {}) {
1884
- const limit = input.limit ?? 1e3;
1885
- positiveInteger(limit, "limit");
1886
- if (limit > MAX_PLAN) throw new RangeError(`limit must not exceed ${MAX_PLAN}`);
1887
- const result = await this.query(
1888
- `SELECT authz.account_id, authz.destination_ref, authz.generation::text AS generation,
1889
- COUNT(binding.configuration_id)::text AS bindings,
1890
- COUNT(DISTINCT binding.data->>'method')::text AS methods,
1891
- COUNT(DISTINCT binding.data->>'transport')::text AS transports,
1892
- COUNT(binding.data->>'transport')::text AS with_transport
1893
- FROM adcp_reporting_destination_authorizations authz
1894
- LEFT JOIN adcp_reporting_managed_bindings binding
1895
- ON binding.account_id = authz.account_id
1896
- AND binding.destination_ref = authz.destination_ref
1897
- AND binding.authorization_generation = authz.generation
1898
- WHERE (authz.pattern IS NULL OR authz.transport IS NULL)
1899
- AND ($1::text IS NULL OR authz.account_id = $1)
1900
- GROUP BY authz.account_id, authz.destination_ref, authz.generation
1901
- ORDER BY authz.account_id, authz.destination_ref, authz.generation
1902
- LIMIT $2`,
1903
- [input.account_id ?? null, limit]
1904
- );
1905
- return result.rows.map((row) => {
1906
- const bindings = Number(row.bindings);
1907
- const reason = bindings === 0 ? "no_binding" : Number(row.with_transport) < bindings ? "binding_without_transport" : Number(row.methods) > 1 || Number(row.transports) > 1 ? "bindings_disagree" : "not_recorded";
1908
- return {
1909
- account_id: row.account_id,
1910
- destination_ref: row.destination_ref,
1911
- generation: Number(row.generation),
1912
- reason
1913
- };
1914
- });
1915
- }
1916
- async isAuthorizationCurrent(input) {
1917
- const result = await this.query(
1918
- `SELECT 1 FROM adcp_reporting_destination_authorizations
1919
- WHERE account_id = $1 AND destination_ref = $2 AND generation = $3 AND revoked_at IS NULL`,
1920
- [input.account_id, input.destination_ref, input.generation]
1921
- );
1922
- return result.rowCount === 1;
1923
- }
1924
- async syncReceiptBatch(input) {
1925
- const revisionCount = input.entries.filter((entry) => entry.kind === "revision").length;
1926
- if (!/^[A-Za-z0-9_.:-]{16,255}$/.test(input.idempotency_key) || input.entries.length < 1 || input.entries.length > MAX_RECEIPT_RESULTS || revisionCount > MAX_RECEIPTS_PER_ARRAY || input.entries.length - revisionCount > MAX_RECEIPTS_PER_ARRAY || input.entries.some(
1927
- (entry) => (entry.kind === "revision" ? !(0, import_evidence.isReportingReceiptEvidence)(entry.receipt) : !(0, import_evidence.isReportingAdjustmentReceiptEvidence)(entry.receipt)) || Buffer.byteLength(JSON.stringify(entry.receipt), "utf8") > 64 * 1024
1928
- )) {
1929
- throw new Error("Reporting receipt transaction failed");
1930
- }
1931
- return this.transaction(async (client) => {
1932
- await advisoryLock(client, accountLock(input.account_id));
1933
- await advisoryLock(client, `adcp-reporting-receipts:${input.account_id}:${input.consumer_id}`);
1934
- const receivedAt = (await client.query(`SELECT ${rfc3339Micro("clock_timestamp()")} AS now`)).rows[0].now;
1935
- const prior = await client.query(
1936
- `SELECT request_fingerprint, results FROM adcp_reporting_receipt_batches
1937
- WHERE account_id = $1 AND consumer_id = $2 AND idempotency_key = $3`,
1938
- [input.account_id, input.consumer_id, input.idempotency_key]
1939
- );
1940
- if (prior.rows[0]) {
1941
- if (prior.rows[0].request_fingerprint !== input.request_fingerprint) {
1942
- return input.entries.map((entry) => idempotencyConflict(entry.receipt.reporting_receipt_id));
1943
- }
1944
- return this.replayReceiptBatch(client, input, prior.rows[0].results);
1945
- }
1946
- await client.query(
1947
- `DELETE FROM adcp_reporting_receipt_batches
1948
- WHERE account_id = $1 AND consumer_id = $2
1949
- AND recorded_at < clock_timestamp() - ($3::bigint * INTERVAL '1 millisecond')`,
1950
- [input.account_id, input.consumer_id, RECEIPT_BATCH_RETENTION_MS]
1951
- );
1952
- const capacity = await client.query(
1953
- `SELECT
1954
- (SELECT COUNT(*) FROM adcp_reporting_receipt_batches
1955
- WHERE account_id = $1 AND consumer_id = $2)::text AS batches,
1956
- (SELECT COUNT(*) FROM adcp_reporting_receipts
1957
- WHERE account_id = $1 AND consumer_id = $2${this.activeScope("recorded_at")})::text AS receipts`,
1958
- [input.account_id, input.consumer_id]
1959
- );
1960
- if (Number(capacity.rows[0]?.batches ?? 0) >= MAX_RECEIPT_BATCHES_PER_CONSUMER) {
1961
- return input.entries.map((entry) => failed(entry.receipt.reporting_receipt_id));
1962
- }
1963
- let remainingReceipts = MAX_RECEIPTS_PER_CONSUMER - Number(capacity.rows[0]?.receipts ?? 0);
1964
- const duplicateIds = duplicates(input.entries.map((entry) => entry.receipt.reporting_receipt_id));
1965
- const alreadyStored = await client.query(
1966
- `SELECT reporting_receipt_id, semantic_fingerprint FROM adcp_reporting_receipts
1967
- WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = ANY($3::text[])`,
1968
- [input.account_id, input.consumer_id, input.entries.map((entry) => entry.receipt.reporting_receipt_id)]
1969
- );
1970
- const storedFingerprints = new Map(
1971
- alreadyStored.rows.map((row) => [row.reporting_receipt_id, row.semantic_fingerprint])
1972
- );
1973
- const buried = await client.query(
1974
- `SELECT reporting_receipt_id, semantic_fingerprint FROM adcp_reporting_receipt_tombstones
1975
- WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = ANY($3::text[])`,
1976
- [input.account_id, input.consumer_id, input.entries.map((entry) => entry.receipt.reporting_receipt_id)]
1977
- );
1978
- for (const row of buried.rows) storedFingerprints.set(row.reporting_receipt_id, row.semantic_fingerprint);
1979
- const resolvesToStored = input.entries.map(
1980
- (entry) => storedFingerprints.get(entry.receipt.reporting_receipt_id) === digest(entry.receipt)
1981
- );
1982
- const duplicateSubjects = duplicates(
1983
- input.entries.filter((_entry, index) => !resolvesToStored[index]).map((entry) => subjectKey(entry))
1984
- );
1985
- const results = [];
1986
- for (const [index, entry] of input.entries.entries()) {
1987
- if (duplicateIds.has(entry.receipt.reporting_receipt_id) || !resolvesToStored[index] && duplicateSubjects.has(subjectKey(entry))) {
1988
- results.push(failed(entry.receipt.reporting_receipt_id));
1989
- continue;
1990
- }
1991
- const result = await this.recordReceipt(client, input, entry, receivedAt, remainingReceipts);
1992
- results.push(result);
1993
- if (result.result === "recorded") remainingReceipts -= 1;
1994
- }
1995
- const stored = results.map((result, index) => ({
1996
- kind: result.result,
1997
- id: receiptResultId(result, input.entries[index]),
1998
- entry: input.entries[index].kind,
1999
- ...result.result === "failed" ? { errorCode: "INVALID_REQUEST" } : {}
2000
- }));
2001
- const storedJson = JSON.stringify(stored);
2002
- if (Buffer.byteLength(storedJson, "utf8") > MAX_RECEIPT_BATCH_RESULT_BYTES) {
2003
- throw new Error("Reporting receipt batch replay record exceeds its byte budget");
2004
- }
2005
- await client.query(
2006
- `INSERT INTO adcp_reporting_receipt_batches
2007
- (account_id, consumer_id, idempotency_key, request_fingerprint, results)
2008
- VALUES ($1,$2,$3,$4,$5::jsonb)`,
2009
- [input.account_id, input.consumer_id, input.idempotency_key, input.request_fingerprint, storedJson]
2010
- );
2011
- return results;
2012
- });
2013
- }
2014
- /**
2015
- * Rebuilds a prior batch response from the compact replay record plus the
2016
- * append-only receipt rows, re-checking authorization exactly as the first
2017
- * call did so a revoked destination cannot be replayed back into disclosure.
2018
- */
2019
- /**
2020
- * Replays a prior batch response exactly.
2021
- *
2022
- * An exact same-key replay is side-effect free and returns the caller its own
2023
- * previously recorded verdict, so later revocation of the destination does
2024
- * not change it. Downgrading a replay to `failed` once authorization ended
2025
- * broke the protocol-wide rule that a replayed idempotency key returns the
2026
- * same response, and bought nothing: the body is the caller's own receipt
2027
- * echoed back with the verdict it already received, so a revoked caller
2028
- * learns nothing it did not already hold. Fail-closed still governs every
2029
- * path that *accepts* new evidence — `loadReceiptEvidence` joins
2030
- * `revoked_at IS NULL` — and nothing here reads another consumer's rows.
2031
- */
2032
- async replayReceiptBatch(client, input, stored) {
2033
- const replay = [];
2034
- for (const [index, entry] of input.entries.entries()) {
2035
- const result = stored[index];
2036
- const legacy = legacyReceiptBatchResult(result);
2037
- if (legacy) {
2038
- replay.push(structuredClone(legacy));
2039
- continue;
2040
- }
2041
- if (!result || result.kind === "failed") {
2042
- replay.push(failed(entry.receipt.reporting_receipt_id));
2043
- continue;
2044
- }
2045
- const row = await client.query(
2046
- `SELECT data FROM adcp_reporting_receipts
2047
- WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = $3`,
2048
- [input.account_id, input.consumer_id, result.id]
2049
- );
2050
- let data = row.rows[0]?.data;
2051
- if (!data) {
2052
- const tombstone = await client.query(
2053
- `SELECT ${rfc3339Micro("COALESCE(subject_recorded_at, pruned_at)")} AS recorded_at, semantic_fingerprint
2054
- FROM adcp_reporting_receipt_tombstones
2055
- WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = $3`,
2056
- [input.account_id, input.consumer_id, result.id]
2057
- );
2058
- const buried = tombstone.rows[0];
2059
- if (buried && result.id === entry.receipt.reporting_receipt_id && buried.semantic_fingerprint === digest(entry.receipt)) {
2060
- data = { ...entry.receipt, received_at: buried.recorded_at };
2061
- }
2062
- }
2063
- if (!data) {
2064
- replay.push(failed(entry.receipt.reporting_receipt_id));
2065
- continue;
2066
- }
2067
- replay.push(
2068
- result.kind === "recorded" ? recorded(result.entry, structuredClone(data)) : unchanged(result.entry, structuredClone(data))
2069
- );
2070
- }
2071
- return replay;
2072
- }
2073
- async recordReceipt(client, batch, entry, receivedAt, remainingReceipts) {
2074
- const fingerprint = digest(entry.receipt);
2075
- const existing = await client.query(
2076
- `SELECT semantic_fingerprint, data FROM adcp_reporting_receipts
2077
- WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = $3`,
2078
- [batch.account_id, batch.consumer_id, entry.receipt.reporting_receipt_id]
2079
- );
2080
- if (existing.rows[0]) {
2081
- if (existing.rows[0].semantic_fingerprint !== fingerprint) return failed(entry.receipt.reporting_receipt_id);
2082
- return unchanged(entry.kind, existing.rows[0].data);
2083
- }
2084
- const tombstones = await client.query(
2085
- `SELECT reporting_receipt_id, subject_id, status, was_current, semantic_fingerprint,
2086
- ${rfc3339Micro("COALESCE(subject_recorded_at, pruned_at)")} AS recorded_at
2087
- FROM adcp_reporting_receipt_tombstones
2088
- WHERE account_id = $1 AND consumer_id = $2
2089
- AND (reporting_receipt_id = $3 OR (receipt_kind = $4 AND subject_id = $5))
2090
- -- Newest leaf first. The demotion above keeps at most one current
2091
- -- tombstone per subject, but rows written before it existed, and any
2092
- -- future shape that tombstones two leaves at once, must still resolve
2093
- -- to one deterministic answer rather than to whatever the planner
2094
- -- emitted first.
2095
- ORDER BY was_current DESC, pruned_at DESC, reporting_receipt_id DESC`,
2096
- [batch.account_id, batch.consumer_id, entry.receipt.reporting_receipt_id, entry.kind, subjectIdFor(entry)]
2097
- );
2098
- const ownTombstone = tombstones.rows.find(
2099
- (tombstone) => tombstone.reporting_receipt_id === entry.receipt.reporting_receipt_id
2100
- );
2101
- if (ownTombstone) {
2102
- if (ownTombstone.semantic_fingerprint !== fingerprint) return failed(entry.receipt.reporting_receipt_id);
2103
- return unchanged(entry.kind, { ...entry.receipt, received_at: ownTombstone.recorded_at });
2104
- }
2105
- for (const tombstone of tombstones.rows) {
2106
- if (tombstone.status === "accepted" && tombstone.was_current) {
2107
- return failed(entry.receipt.reporting_receipt_id);
2108
- }
2109
- }
2110
- const tombstonedLeafId = tombstones.rows.find(
2111
- (tombstone) => tombstone.was_current && tombstone.subject_id === subjectIdFor(entry) && tombstone.reporting_receipt_id !== entry.receipt.reporting_receipt_id
2112
- )?.reporting_receipt_id;
2113
- const evidence = await this.loadReceiptEvidence(client, batch.account_id, entry);
2114
- if (!evidence) return failed(entry.receipt.reporting_receipt_id);
2115
- const matches = entry.kind === "revision" ? (0, import_managed.receiptEvidenceMatches)(entry.receipt, evidence.materialization) : (0, import_managed.adjustmentReceiptEvidenceMatches)(entry.receipt, evidence.adjustment);
2116
- const wellFormed = entry.receipt.status === "accepted" ? matches : Boolean(entry.receipt.rejection_codes?.length);
2117
- if (!wellFormed) {
2118
- return failed(entry.receipt.reporting_receipt_id);
2119
- }
2120
- const current = await client.query(
2121
- `SELECT reporting_receipt_id, data FROM adcp_reporting_receipts
2122
- WHERE account_id = $1 AND consumer_id = $2 AND receipt_kind = $3 AND subject_id = $4 AND is_current`,
2123
- [batch.account_id, batch.consumer_id, entry.kind, evidence.subjectId]
2124
- );
2125
- const leaf = current.rows[0];
2126
- const supersedes = entry.receipt.supersedes_reporting_receipt_id;
2127
- if (leaf && (leaf.data.status === "accepted" || supersedes !== leaf.reporting_receipt_id) || // The pruned leaf answers for the chain exactly as a live one would:
2128
- // only its own successor may extend the subject. Re-presenting the
2129
- // tombstoned leaf itself is not a succession and is handled above, so
2130
- // it never reaches here.
2131
- !leaf && tombstonedLeafId !== void 0 && supersedes !== tombstonedLeafId || !leaf && tombstonedLeafId === void 0 && supersedes !== void 0) {
2132
- return failed(entry.receipt.reporting_receipt_id);
2133
- }
2134
- if (remainingReceipts <= 0) return failed(entry.receipt.reporting_receipt_id);
2135
- if (leaf) {
2136
- await client.query(
2137
- `UPDATE adcp_reporting_receipts SET is_current = false
2138
- WHERE account_id = $1 AND consumer_id = $2 AND reporting_receipt_id = $3 AND is_current`,
2139
- [batch.account_id, batch.consumer_id, leaf.reporting_receipt_id]
2140
- );
2141
- }
2142
- const stored = { ...entry.receipt, received_at: receivedAt };
2143
- await client.query(
2144
- `INSERT INTO adcp_reporting_receipts
2145
- (account_id, consumer_id, reporting_receipt_id, receipt_kind, subject_id,
2146
- supersedes_receipt_id, is_current, semantic_fingerprint, data, received_at, recorded_at)
2147
- VALUES ($1,$2,$3,$4,$5,$6,true,$7,$8::jsonb,$9::timestamptz,$9::timestamptz)`,
2148
- [
2149
- batch.account_id,
2150
- batch.consumer_id,
2151
- entry.receipt.reporting_receipt_id,
2152
- entry.kind,
2153
- evidence.subjectId,
2154
- supersedes ?? null,
2155
- fingerprint,
2156
- JSON.stringify(stored),
2157
- receivedAt
2158
- ]
2159
- );
2160
- return recorded(entry.kind, stored);
2161
- }
2162
- async loadReceiptEvidence(client, accountId, entry) {
2163
- if (entry.kind === "revision") {
2164
- const receipt2 = entry.receipt;
2165
- const result2 = await client.query(
2166
- `SELECT materialization.data AS materialization
2167
- FROM adcp_reporting_materializations materialization
2168
- JOIN adcp_reporting_managed_bindings binding ON binding.configuration_id = materialization.configuration_id
2169
- JOIN adcp_reporting_destination_authorizations authz
2170
- ON authz.account_id = materialization.account_id
2171
- AND authz.destination_ref = materialization.destination_ref
2172
- AND authz.generation = materialization.authorization_generation
2173
- AND authz.revoked_at IS NULL
2174
- JOIN adcp_reporting_revisions revision ON revision.revision_id = materialization.revision_id
2175
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = materialization.obligation_id
2176
- WHERE materialization.account_id = $1 AND materialization.materialization_id = $2
2177
- AND materialization.revision_id = $3 AND materialization.obligation_id = $4
2178
- AND materialization.status IN ('available','delivered')
2179
- -- RC3 ties the receiptable revision to the obligation's own
2180
- -- required_finality, exactly as the read projection does when it
2181
- -- picks the required revision. Hard-coding 'official' made every
2182
- -- snapshot-finality consumer_receipt configuration unreconcilable:
2183
- -- the projection asked for a receipt the store always refused.
2184
- AND (obligation.data->>'requiredFinality' <> 'official' OR revision.finality = 'official')
2185
- AND binding.data->>'reconciliation_mode' = 'consumer_receipt'`,
2186
- [
2187
- accountId,
2188
- receipt2.reporting_materialization_id,
2189
- receipt2.reporting_revision_id,
2190
- receipt2.reporting_obligation_id
2191
- ]
2192
- );
2193
- return result2.rows[0] ? { subjectId: receipt2.reporting_revision_id, materialization: result2.rows[0].materialization } : null;
2194
- }
2195
- const receipt = entry.receipt;
2196
- const result = await client.query(
2197
- `SELECT adjustment.data AS adjustment
2198
- FROM adcp_reporting_adjustments adjustment
2199
- JOIN adcp_reporting_revisions revision ON revision.revision_id = adjustment.adjusts_revision_id
2200
- JOIN adcp_reporting_obligations obligation ON obligation.obligation_id = adjustment.obligation_id
2201
- JOIN adcp_reporting_managed_bindings binding ON binding.configuration_id = obligation.configuration_id
2202
- JOIN adcp_reporting_destination_authorizations authz
2203
- ON authz.account_id = binding.account_id AND authz.destination_ref = binding.destination_ref
2204
- AND authz.generation = binding.authorization_generation AND authz.revoked_at IS NULL
2205
- WHERE binding.account_id = $1 AND adjustment.adjustment_id = $2
2206
- AND adjustment.adjusts_revision_id = $3
2207
- -- Unlike a revision receipt this arm stays pinned to 'official': an
2208
- -- adjustment only ever corrects an already-official revision, which
2209
- -- commitAdjustment enforces at write time.
2210
- AND revision.finality = 'official'
2211
- AND binding.data->>'reconciliation_mode' = 'consumer_receipt'`,
2212
- [accountId, receipt.reporting_adjustment_id, receipt.adjusts_reporting_revision_id]
2213
- );
2214
- return result.rows[0] ? { subjectId: receipt.reporting_adjustment_id, adjustment: result.rows[0].adjustment } : null;
2215
- }
2216
- async transaction(body, signal) {
2217
- signal?.throwIfAborted();
2218
- let client;
2219
- try {
2220
- client = await this.pool.connect();
2221
- } catch (cause) {
2222
- throw new Error("PostgresReportingManagedDeliveryStore database connection failed", { cause });
2223
- }
2224
- let releaseError;
2225
- let transactionStarted = false;
2226
- try {
2227
- signal?.throwIfAborted();
2228
- await client.query("BEGIN");
2229
- transactionStarted = true;
2230
- if (signal) await client.query("SET LOCAL lock_timeout = '5s'; SET LOCAL statement_timeout = '10s'");
2231
- const guarded = signal ? {
2232
- query: async (sql, values) => {
2233
- signal.throwIfAborted();
2234
- const result = await client.query(sql, values);
2235
- signal.throwIfAborted();
2236
- return result;
2237
- },
2238
- release: () => {
2239
- throw new Error("Transaction body cannot release connection");
2240
- }
2241
- } : client;
2242
- const value = await body(guarded);
2243
- signal?.throwIfAborted();
2244
- await client.query("COMMIT");
2245
- return value;
2246
- } catch (cause) {
2247
- if (transactionStarted) {
2248
- try {
2249
- await client.query("ROLLBACK");
2250
- } catch (rollbackCause) {
2251
- releaseError = rollbackCause instanceof Error ? rollbackCause : new Error("Managed reporting rollback failed");
2252
- }
2253
- }
2254
- throw new Error("PostgresReportingManagedDeliveryStore transaction failed", { cause });
2255
- } finally {
2256
- client.release(releaseError);
2257
- }
2258
- }
2259
- async query(sql, values) {
2260
- try {
2261
- return await this.pool.query(sql, values);
2262
- } catch (cause) {
2263
- throw new Error("PostgresReportingManagedDeliveryStore database operation failed", { cause });
2264
- }
2265
- }
2266
- }
2267
- function recorded(kind, data) {
2268
- return kind === "revision" ? { result: "recorded", receipt: data } : { result: "recorded", adjustment_receipt: data };
2269
- }
2270
- function unchanged(kind, data) {
2271
- return kind === "revision" ? { result: "unchanged", receipt: data } : { result: "unchanged", adjustment_receipt: data };
2272
- }
2273
- function failed(reporting_receipt_id) {
2274
- return {
2275
- result: "failed",
2276
- reporting_receipt_id,
2277
- errors: [{ code: "INVALID_REQUEST", message: GENERIC_RECEIPT_MESSAGE, recovery: "correctable" }]
2278
- };
2279
- }
2280
- function idempotencyConflict(reporting_receipt_id) {
2281
- return {
2282
- result: "failed",
2283
- reporting_receipt_id,
2284
- errors: [
2285
- {
2286
- code: "IDEMPOTENCY_CONFLICT",
2287
- message: "Idempotency key was reused with different receipt content",
2288
- recovery: "correctable"
2289
- }
2290
- ]
2291
- };
2292
- }
2293
- function receiptResultId(result, entry) {
2294
- if (result.result === "failed") return result.reporting_receipt_id;
2295
- const body = "receipt" in result ? result.receipt : result.adjustment_receipt;
2296
- return body?.reporting_receipt_id ?? entry.receipt.reporting_receipt_id;
2297
- }
2298
- function legacyReceiptBatchResult(value) {
2299
- if (!value || typeof value !== "object" || !("result" in value)) return void 0;
2300
- return value;
2301
- }
2302
- function subjectIdFor(entry) {
2303
- return entry.kind === "revision" ? entry.receipt.reporting_revision_id : entry.receipt.reporting_adjustment_id;
2304
- }
2305
- function subjectKey(entry) {
2306
- return entry.kind === "revision" ? `revision:${entry.receipt.reporting_revision_id}` : `adjustment:${entry.receipt.reporting_adjustment_id}`;
2307
- }
2308
- function duplicates(values) {
2309
- const seen = /* @__PURE__ */ new Set();
2310
- const duplicate = /* @__PURE__ */ new Set();
2311
- for (const value of values) (seen.has(value) ? duplicate : seen).add(value);
2312
- return duplicate;
2313
- }
2314
- function digest(value) {
2315
- return (0, import_node_crypto.createHash)("sha256").update((0, import_jcs.canonicalize)(value)).digest("hex");
2316
- }
2317
- function managedBindingFingerprint(binding) {
2318
- const { created_at: _createdAt, semantic_fingerprint: _fingerprint, ...semantic } = binding;
2319
- return digest(semantic);
2320
- }
2321
- function nonnegativeSafeInteger(value, name) {
2322
- if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${name} must be a non-negative safe integer`);
2323
- }
2324
- function positiveInteger(value, name) {
2325
- if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
2326
- }
2327
- function policyNumber(value) {
2328
- return value === null || value === void 0 ? null : Number(value);
2329
- }
2330
- async function advisoryLock(client, key) {
2331
- await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key]);
2332
- }
2333
- function authLock(accountId, destinationRef) {
2334
- return `adcp-reporting-auth:${accountId}:${destinationRef}`;
2335
- }
2336
- function rfc3339Micro(expression) {
2337
- return `to_char(${expression} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
2338
- }
2339
- function accountLock(accountId) {
2340
- return `adcp-reporting-account:${accountId}`;
2341
- }
2342
- // Annotate the CommonJS export names for ESM import in node:
2343
- 0 && (module.exports = {
2344
- PostgresReportingManagedDeliveryStore,
2345
- REPORTING_MANAGED_DELIVERY_MIGRATION
2346
- });