@greatwait/contracts 0.1.0-alpha.1

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 (241) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/dist/authority.d.ts +217 -0
  4. package/dist/authority.js +261 -0
  5. package/dist/callback-ownership.d.ts +58 -0
  6. package/dist/callback-ownership.js +71 -0
  7. package/dist/callback-safety.d.ts +38 -0
  8. package/dist/callback-safety.js +403 -0
  9. package/dist/compatibility.d.ts +82 -0
  10. package/dist/compatibility.js +115 -0
  11. package/dist/delivery.d.ts +401 -0
  12. package/dist/delivery.js +311 -0
  13. package/dist/endpoints.d.ts +305 -0
  14. package/dist/endpoints.js +179 -0
  15. package/dist/events.d.ts +62 -0
  16. package/dist/events.js +38 -0
  17. package/dist/first-value-journey.d.ts +305 -0
  18. package/dist/first-value-journey.js +466 -0
  19. package/dist/forecast.d.ts +443 -0
  20. package/dist/forecast.js +729 -0
  21. package/dist/identifiers.d.ts +109 -0
  22. package/dist/identifiers.js +131 -0
  23. package/dist/identity.d.ts +196 -0
  24. package/dist/identity.js +182 -0
  25. package/dist/index.d.ts +27 -0
  26. package/dist/index.js +27 -0
  27. package/dist/mcp.d.ts +51 -0
  28. package/dist/mcp.js +56 -0
  29. package/dist/oauth.d.ts +796 -0
  30. package/dist/oauth.js +708 -0
  31. package/dist/operations.d.ts +3985 -0
  32. package/dist/operations.js +1837 -0
  33. package/dist/partition.d.ts +771 -0
  34. package/dist/partition.js +206 -0
  35. package/dist/policy.d.ts +93 -0
  36. package/dist/policy.js +180 -0
  37. package/dist/problems.d.ts +158 -0
  38. package/dist/problems.js +104 -0
  39. package/dist/protocol.d.ts +308 -0
  40. package/dist/protocol.js +515 -0
  41. package/dist/scheduling-records.d.ts +395 -0
  42. package/dist/scheduling-records.js +284 -0
  43. package/dist/scheduling.d.ts +1098 -0
  44. package/dist/scheduling.js +1116 -0
  45. package/dist/secrets.d.ts +383 -0
  46. package/dist/secrets.js +272 -0
  47. package/dist/signatures.d.ts +704 -0
  48. package/dist/signatures.js +867 -0
  49. package/dist/tenancy.d.ts +178 -0
  50. package/dist/tenancy.js +145 -0
  51. package/dist/test-sink.d.ts +986 -0
  52. package/dist/test-sink.js +755 -0
  53. package/dist/time.d.ts +45 -0
  54. package/dist/time.js +128 -0
  55. package/dist/usage.d.ts +121 -0
  56. package/dist/usage.js +200 -0
  57. package/dist/webhooks.d.ts +116 -0
  58. package/dist/webhooks.js +161 -0
  59. package/dist/workspace.d.ts +92 -0
  60. package/dist/workspace.js +59 -0
  61. package/generated/v1/callback-delivery-signature.json +467 -0
  62. package/generated/v1/callback-endpoint-verification.json +59 -0
  63. package/generated/v1/callback-receiver.json +241 -0
  64. package/generated/v1/examples/attempts.list.response.json +24 -0
  65. package/generated/v1/examples/attempts.read.response.json +20 -0
  66. package/generated/v1/examples/credentials.create.request.json +5 -0
  67. package/generated/v1/examples/credentials.create.response.json +10 -0
  68. package/generated/v1/examples/credentials.list.response.json +17 -0
  69. package/generated/v1/examples/credentials.revoke.response.json +11 -0
  70. package/generated/v1/examples/credentials.rotate.request.json +3 -0
  71. package/generated/v1/examples/credentials.rotate.response.json +22 -0
  72. package/generated/v1/examples/endpoints.create.request.json +3 -0
  73. package/generated/v1/examples/endpoints.create.response.json +16 -0
  74. package/generated/v1/examples/endpoints.disable.response.json +15 -0
  75. package/generated/v1/examples/endpoints.list.response.json +21 -0
  76. package/generated/v1/examples/endpoints.read.response.json +14 -0
  77. package/generated/v1/examples/endpoints.rotateSigningKey.request.json +3 -0
  78. package/generated/v1/examples/endpoints.rotateSigningKey.response.json +23 -0
  79. package/generated/v1/examples/endpoints.update.request.json +3 -0
  80. package/generated/v1/examples/endpoints.update.response.json +13 -0
  81. package/generated/v1/examples/endpoints.verify.response.json +15 -0
  82. package/generated/v1/examples/forecast.read.response.json +28 -0
  83. package/generated/v1/examples/forecast.windows.response.json +21 -0
  84. package/generated/v1/examples/grants.list.response.json +17 -0
  85. package/generated/v1/examples/grants.revoke.response.json +16 -0
  86. package/generated/v1/examples/meta.scaffold.response.json +5 -0
  87. package/generated/v1/examples/meta.version.response.json +5 -0
  88. package/generated/v1/examples/occurrences.list.response.json +17 -0
  89. package/generated/v1/examples/occurrences.read.response.json +15 -0
  90. package/generated/v1/examples/occurrences.redeliver.response.json +16 -0
  91. package/generated/v1/examples/projects.create.request.json +3 -0
  92. package/generated/v1/examples/projects.create.response.json +5 -0
  93. package/generated/v1/examples/projects.list.response.json +9 -0
  94. package/generated/v1/examples/schedules.archive.response.json +25 -0
  95. package/generated/v1/examples/schedules.cancel.response.json +24 -0
  96. package/generated/v1/examples/schedules.create.request.json +9 -0
  97. package/generated/v1/examples/schedules.create.response.json +25 -0
  98. package/generated/v1/examples/schedules.list.response.json +23 -0
  99. package/generated/v1/examples/schedules.pause.response.json +23 -0
  100. package/generated/v1/examples/schedules.read.response.json +25 -0
  101. package/generated/v1/examples/schedules.replace.request.json +8 -0
  102. package/generated/v1/examples/schedules.replace.response.json +25 -0
  103. package/generated/v1/examples/schedules.resume.response.json +23 -0
  104. package/generated/v1/examples/sinkdeliveries.list.response.json +19 -0
  105. package/generated/v1/examples/sinkdeliveries.read.response.json +16 -0
  106. package/generated/v1/examples/testsinks.configure.request.json +3 -0
  107. package/generated/v1/examples/testsinks.configure.response.json +13 -0
  108. package/generated/v1/examples/testsinks.create.request.json +8 -0
  109. package/generated/v1/examples/testsinks.create.response.json +13 -0
  110. package/generated/v1/examples/testsinks.delete.response.json +13 -0
  111. package/generated/v1/examples/testsinks.list.response.json +20 -0
  112. package/generated/v1/examples/testsinks.read.response.json +14 -0
  113. package/generated/v1/examples/usage.read.response.json +20 -0
  114. package/generated/v1/first-value-journey.json +436 -0
  115. package/generated/v1/manifest.json +1037 -0
  116. package/generated/v1/openapi.json +23665 -0
  117. package/generated/v1/policy-matrix.json +305 -0
  118. package/generated/v1/schemas/attempts.list.query.json +16 -0
  119. package/generated/v1/schemas/attempts.list.response.json +175 -0
  120. package/generated/v1/schemas/attempts.read.response.json +168 -0
  121. package/generated/v1/schemas/credentials.create.request.json +39 -0
  122. package/generated/v1/schemas/credentials.create.response.json +88 -0
  123. package/generated/v1/schemas/credentials.list.query.json +16 -0
  124. package/generated/v1/schemas/credentials.list.response.json +124 -0
  125. package/generated/v1/schemas/credentials.revoke.response.json +84 -0
  126. package/generated/v1/schemas/credentials.rotate.request.json +11 -0
  127. package/generated/v1/schemas/credentials.rotate.response.json +171 -0
  128. package/generated/v1/schemas/endpoints.create.request.json +12 -0
  129. package/generated/v1/schemas/endpoints.create.response.json +115 -0
  130. package/generated/v1/schemas/endpoints.disable.response-headers.json +11 -0
  131. package/generated/v1/schemas/endpoints.disable.response.json +100 -0
  132. package/generated/v1/schemas/endpoints.list.query.json +16 -0
  133. package/generated/v1/schemas/endpoints.list.response.json +146 -0
  134. package/generated/v1/schemas/endpoints.read.response-headers.json +11 -0
  135. package/generated/v1/schemas/endpoints.read.response.json +100 -0
  136. package/generated/v1/schemas/endpoints.rotateSigningKey.request-headers.json +10 -0
  137. package/generated/v1/schemas/endpoints.rotateSigningKey.request.json +11 -0
  138. package/generated/v1/schemas/endpoints.rotateSigningKey.response-headers.json +11 -0
  139. package/generated/v1/schemas/endpoints.rotateSigningKey.response.json +118 -0
  140. package/generated/v1/schemas/endpoints.update.request-headers.json +10 -0
  141. package/generated/v1/schemas/endpoints.update.request.json +12 -0
  142. package/generated/v1/schemas/endpoints.update.response-headers.json +11 -0
  143. package/generated/v1/schemas/endpoints.update.response.json +100 -0
  144. package/generated/v1/schemas/endpoints.verify.request-headers.json +10 -0
  145. package/generated/v1/schemas/endpoints.verify.response-headers.json +11 -0
  146. package/generated/v1/schemas/endpoints.verify.response.json +100 -0
  147. package/generated/v1/schemas/forecast.read.query.json +26 -0
  148. package/generated/v1/schemas/forecast.read.response.json +100 -0
  149. package/generated/v1/schemas/forecast.windows.query.json +26 -0
  150. package/generated/v1/schemas/forecast.windows.response.json +94 -0
  151. package/generated/v1/schemas/grants.list.response.json +98 -0
  152. package/generated/v1/schemas/grants.revoke.response.json +95 -0
  153. package/generated/v1/schemas/meta.scaffold.response.json +36 -0
  154. package/generated/v1/schemas/meta.version.response.json +19 -0
  155. package/generated/v1/schemas/occurrences.list.query.json +16 -0
  156. package/generated/v1/schemas/occurrences.list.response.json +83 -0
  157. package/generated/v1/schemas/occurrences.read.response.json +76 -0
  158. package/generated/v1/schemas/occurrences.redeliver.request-headers.json +13 -0
  159. package/generated/v1/schemas/occurrences.redeliver.response-headers.json +10 -0
  160. package/generated/v1/schemas/occurrences.redeliver.response.json +168 -0
  161. package/generated/v1/schemas/projects.create.request.json +12 -0
  162. package/generated/v1/schemas/projects.create.response.json +25 -0
  163. package/generated/v1/schemas/projects.list.response.json +35 -0
  164. package/generated/v1/schemas/schedules.archive.request-headers.json +10 -0
  165. package/generated/v1/schemas/schedules.archive.response-headers.json +11 -0
  166. package/generated/v1/schemas/schedules.archive.response.json +189 -0
  167. package/generated/v1/schemas/schedules.cancel.request-headers.json +10 -0
  168. package/generated/v1/schemas/schedules.cancel.response-headers.json +11 -0
  169. package/generated/v1/schemas/schedules.cancel.response.json +189 -0
  170. package/generated/v1/schemas/schedules.create.request-headers.json +12 -0
  171. package/generated/v1/schemas/schedules.create.request.json +99 -0
  172. package/generated/v1/schemas/schedules.create.response-headers.json +15 -0
  173. package/generated/v1/schemas/schedules.create.response.json +189 -0
  174. package/generated/v1/schemas/schedules.list.query.json +16 -0
  175. package/generated/v1/schemas/schedules.list.response.json +170 -0
  176. package/generated/v1/schemas/schedules.pause.request-headers.json +10 -0
  177. package/generated/v1/schemas/schedules.pause.response-headers.json +11 -0
  178. package/generated/v1/schemas/schedules.pause.response.json +189 -0
  179. package/generated/v1/schemas/schedules.read.response-headers.json +11 -0
  180. package/generated/v1/schemas/schedules.read.response.json +189 -0
  181. package/generated/v1/schemas/schedules.replace.request-headers.json +10 -0
  182. package/generated/v1/schemas/schedules.replace.request.json +99 -0
  183. package/generated/v1/schemas/schedules.replace.response-headers.json +11 -0
  184. package/generated/v1/schemas/schedules.replace.response.json +189 -0
  185. package/generated/v1/schemas/schedules.resume.request-headers.json +10 -0
  186. package/generated/v1/schemas/schedules.resume.response-headers.json +11 -0
  187. package/generated/v1/schemas/schedules.resume.response.json +189 -0
  188. package/generated/v1/schemas/sinkdeliveries.list.query.json +16 -0
  189. package/generated/v1/schemas/sinkdeliveries.list.response.json +126 -0
  190. package/generated/v1/schemas/sinkdeliveries.read.response.json +98 -0
  191. package/generated/v1/schemas/testsinks.configure.request.json +66 -0
  192. package/generated/v1/schemas/testsinks.configure.response.json +115 -0
  193. package/generated/v1/schemas/testsinks.create.request.json +76 -0
  194. package/generated/v1/schemas/testsinks.create.response.json +115 -0
  195. package/generated/v1/schemas/testsinks.delete.response.json +115 -0
  196. package/generated/v1/schemas/testsinks.list.query.json +16 -0
  197. package/generated/v1/schemas/testsinks.list.response.json +161 -0
  198. package/generated/v1/schemas/testsinks.read.response.json +115 -0
  199. package/generated/v1/schemas/usage.read.query.json +15 -0
  200. package/generated/v1/schemas/usage.read.response.json +71 -0
  201. package/generated/v1/snippets/attempts.list.json +5 -0
  202. package/generated/v1/snippets/attempts.read.json +5 -0
  203. package/generated/v1/snippets/credentials.create.json +5 -0
  204. package/generated/v1/snippets/credentials.list.json +5 -0
  205. package/generated/v1/snippets/credentials.revoke.json +5 -0
  206. package/generated/v1/snippets/credentials.rotate.json +5 -0
  207. package/generated/v1/snippets/endpoints.create.json +5 -0
  208. package/generated/v1/snippets/endpoints.disable.json +5 -0
  209. package/generated/v1/snippets/endpoints.list.json +5 -0
  210. package/generated/v1/snippets/endpoints.read.json +5 -0
  211. package/generated/v1/snippets/endpoints.rotateSigningKey.json +5 -0
  212. package/generated/v1/snippets/endpoints.update.json +5 -0
  213. package/generated/v1/snippets/endpoints.verify.json +5 -0
  214. package/generated/v1/snippets/forecast.read.json +5 -0
  215. package/generated/v1/snippets/forecast.windows.json +5 -0
  216. package/generated/v1/snippets/grants.list.json +5 -0
  217. package/generated/v1/snippets/grants.revoke.json +5 -0
  218. package/generated/v1/snippets/meta.scaffold.json +5 -0
  219. package/generated/v1/snippets/meta.version.json +5 -0
  220. package/generated/v1/snippets/occurrences.list.json +5 -0
  221. package/generated/v1/snippets/occurrences.read.json +5 -0
  222. package/generated/v1/snippets/occurrences.redeliver.json +5 -0
  223. package/generated/v1/snippets/projects.create.json +5 -0
  224. package/generated/v1/snippets/projects.list.json +5 -0
  225. package/generated/v1/snippets/schedules.archive.json +5 -0
  226. package/generated/v1/snippets/schedules.cancel.json +5 -0
  227. package/generated/v1/snippets/schedules.create.json +5 -0
  228. package/generated/v1/snippets/schedules.list.json +5 -0
  229. package/generated/v1/snippets/schedules.pause.json +5 -0
  230. package/generated/v1/snippets/schedules.read.json +5 -0
  231. package/generated/v1/snippets/schedules.replace.json +5 -0
  232. package/generated/v1/snippets/schedules.resume.json +5 -0
  233. package/generated/v1/snippets/sinkdeliveries.list.json +5 -0
  234. package/generated/v1/snippets/sinkdeliveries.read.json +5 -0
  235. package/generated/v1/snippets/testsinks.configure.json +5 -0
  236. package/generated/v1/snippets/testsinks.create.json +5 -0
  237. package/generated/v1/snippets/testsinks.delete.json +5 -0
  238. package/generated/v1/snippets/testsinks.list.json +5 -0
  239. package/generated/v1/snippets/testsinks.read.json +5 -0
  240. package/generated/v1/snippets/usage.read.json +5 -0
  241. package/package.json +153 -0
@@ -0,0 +1,755 @@
1
+ import { z } from "zod";
2
+ import { CALLBACK_DEFERRAL_STATUS, CALLBACK_PENDING_DISCRIMINATOR, } from "./delivery.js";
3
+ import { callbackSigningKeyIdSchema, deliveryIdSchema, endpointIdSchema, occurrenceIdSchema, projectIdSchema, scheduleIdSchema, testSinkDeliveryIdSchema, testSinkIdSchema, } from "./identifiers.js";
4
+ import { pageSchema } from "./protocol.js";
5
+ import { CALLBACK_HEADERS, signatureHeaderSchema } from "./signatures.js";
6
+ import { timestampSchema } from "./time.js";
7
+ /**
8
+ * The deterministic test sink and the receiver contract it and the shipped
9
+ * middleware both implement.
10
+ *
11
+ * An integrator cannot rehearse a delivery failure against their own receiver
12
+ * without first building one that fails on demand, and the failures that matter
13
+ * — a dropped connection, a receiver that answers three minutes late, a 429
14
+ * carrying a delay hint — are exactly the ones hardest to produce by hand. The
15
+ * sink produces them on request, in test environments only.
16
+ *
17
+ * Two properties are load-bearing and are the reason this vocabulary lives in
18
+ * the contract package rather than inside the API Worker.
19
+ *
20
+ * **The sink and the SDK middleware must agree.** The sink is a reference
21
+ * receiver built on the middleware this story ships, so an integrator who
22
+ * compares the two is comparing one implementation against itself. That is only
23
+ * worth anything if the status a receiver returns for each outcome is published
24
+ * rather than chosen twice — hence `CALLBACK_RECEIVER_STATUS` below.
25
+ *
26
+ * **A sink is a receiver, and the only thing it ever sends is a report about a
27
+ * delivery it has already received.** That sentence used to read "never a
28
+ * sender", and the deferring behaviors added on 2026-08-23 are why it does not:
29
+ * a receiver that defers is one that answers `202` now and reports later, and a
30
+ * sink that could not report later could rehearse the first half of that
31
+ * exchange and none of the half where it goes wrong.
32
+ *
33
+ * What has not changed is that **there is deliberately no field anywhere in
34
+ * this file that names a place to send to.** A completion report goes to the
35
+ * credential inside the signed envelope of the delivery in hand, sent to the
36
+ * one address the stage is configured with, and
37
+ * nowhere else: no program, no control operation and no request header names a
38
+ * destination, so a sink still cannot be asked to forward, redirect or fetch,
39
+ * and still cannot be configured into an egress proxy. A delivery whose
40
+ * envelope carries no completion material cannot defer at all.
41
+ */
42
+ /* -------------------------------------------------------------------------- */
43
+ /* Failure simulation */
44
+ /* -------------------------------------------------------------------------- */
45
+ /**
46
+ * What a sink does with a delivery. Exhaustive, and each entry corresponds to a
47
+ * distinct sender-visible outcome class rather than to a distinct status code.
48
+ *
49
+ * `timeout` and `disconnect` are both ambiguous to the sender, and that is the
50
+ * point rather than a defect in the simulation: a sender genuinely cannot tell
51
+ * them apart, which is why the architecture records ambiguity honestly instead
52
+ * of guessing. They differ in what the *receiver* did — see
53
+ * `TEST_SINK_BEHAVIOR_FACTS`.
54
+ *
55
+ * **The three `defer_*` behaviors are three entries rather than one entry with
56
+ * an option**, for exactly the reason stated above: they produce three
57
+ * different sender-visible outcomes. All three answer the same `202` and the
58
+ * same pending marker, so a list that collapsed them would be a list keyed on
59
+ * the status a receiver returns — which is the thing this list is deliberately
60
+ * not keyed on. What separates them happens *after* the acknowledgement, and it
61
+ * is the whole of what an integrator rehearsing deferral needs to see.
62
+ */
63
+ export const TEST_SINK_BEHAVIORS = [
64
+ "client_error",
65
+ "defer_abandon",
66
+ "defer_fail",
67
+ "defer_succeed",
68
+ "delay",
69
+ "disconnect",
70
+ "duplicate",
71
+ "invalid_signature",
72
+ "rate_limited",
73
+ "server_error",
74
+ "success",
75
+ "timeout",
76
+ ];
77
+ export const testSinkBehaviorSchema = z.enum(TEST_SINK_BEHAVIORS);
78
+ /**
79
+ * Bounds on what a sink may be asked to do.
80
+ *
81
+ * Every one of these exists because the sink runs inside the API Worker's
82
+ * request budget on the product's own account. A test facility that can be
83
+ * asked to hold a request open indefinitely, or to accumulate history without
84
+ * limit, is a denial-of-service surface wearing a test badge.
85
+ */
86
+ export const TEST_SINK_BOUNDS = {
87
+ /**
88
+ * Longest a sink will wait between acknowledging a deferral and reporting
89
+ * completion.
90
+ *
91
+ * Bounded for the same reason `maxDelayMs` is, and the reason survives the
92
+ * response having already been sent: the report is performed on the same
93
+ * invocation, so a sink asked to report an hour later would be asking the API
94
+ * Worker to stay alive for an hour on the product's own account. Smaller than
95
+ * `maxDelayMs` because nothing is waiting on this wait — a rehearsal of
96
+ * deferral is a rehearsal of the *shape* of the exchange, and any integrator
97
+ * who needs to watch a real deadline expire has `defer_abandon`, which costs
98
+ * nothing at all.
99
+ */
100
+ maxCompletionDelayMs: 5_000,
101
+ /** Longest `delay` a sink will honour before answering. */
102
+ maxDelayMs: 10_000,
103
+ /** Longest a `timeout` or `disconnect` holds a request open. */
104
+ maxHoldMs: 45_000,
105
+ /** Retained delivery receipts per sink; the oldest are evicted. */
106
+ maxRetainedDeliveries: 50,
107
+ /** Sinks per project/environment. */
108
+ maxSinksPerEnvironment: 10,
109
+ /** Largest request body a sink will read. Larger is refused unread. */
110
+ maxRequestBodyBytes: 65_536,
111
+ /** Captured body bytes retained on a receipt; beyond this it is truncated. */
112
+ maxRetainedBodyBytes: 4_096,
113
+ };
114
+ /**
115
+ * Selected 4xx a sink may answer with.
116
+ *
117
+ * **They are no longer all terminal to the sender, and which are is not this
118
+ * file's decision.** FXO-1596 moved the refusals — a receiver declining *this
119
+ * delivery now* rather than objecting to the message — onto the receiver's
120
+ * ordinary five-failure budget. `TEST_SINK_REFUSAL_STATUSES` below is the
121
+ * subset of this list that the sender will retry, and `test-sink.test.ts`
122
+ * holds it equal to what `classifyStatus` actually says, so a status moved in
123
+ * `CALLBACK_REFUSAL_STATUSES` and not here fails a test rather than teaching an
124
+ * integrator the wrong lesson.
125
+ *
126
+ * Nothing is removed from what a caller may request: every status here was
127
+ * accepted before and still is.
128
+ */
129
+ export const TEST_SINK_CLIENT_ERROR_STATUSES = [
130
+ 400, 401, 403, 404, 409, 410, 422,
131
+ ];
132
+ /**
133
+ * The statuses above that the sender retries rather than settling on.
134
+ *
135
+ * Exists so an integrator simulating a rotated credential (401) or a WAF rule
136
+ * (403) can see five attempts arrive, and one simulating a malformed body (400)
137
+ * or a duplicate (409) can see exactly one.
138
+ */
139
+ export const TEST_SINK_REFUSAL_STATUSES = [401, 403, 404];
140
+ /** Selected 5xx a sink may answer with. Each is retryable to the sender. */
141
+ export const TEST_SINK_SERVER_ERROR_STATUSES = [500, 502, 503, 504];
142
+ /**
143
+ * A deterministic behavior program.
144
+ *
145
+ * `behavior` applies to the first `forDeliveries` deliveries and `then` to
146
+ * every one after that, so "fail twice, then succeed" is one declaration rather
147
+ * than a sequence of control calls racing the schedule that is firing. Leaving
148
+ * `forDeliveries` out makes `behavior` permanent, which is what a single
149
+ * failure mode under test wants.
150
+ *
151
+ * The count is over deliveries this sink has *received*, not over a particular
152
+ * occurrence's attempts: a sink is a destination and does not get to know which
153
+ * occurrence the sender considers current.
154
+ */
155
+ export const testSinkProgramSchema = z
156
+ .object({
157
+ behavior: testSinkBehaviorSchema,
158
+ /**
159
+ * Milliseconds between acknowledging a deferral and reporting completion.
160
+ * Only meaningful for a behavior that reports one.
161
+ */
162
+ completionDelayMs: z
163
+ .number()
164
+ .int()
165
+ .positive()
166
+ .max(TEST_SINK_BOUNDS.maxCompletionDelayMs)
167
+ .optional(),
168
+ /** Milliseconds to wait before answering. Only meaningful for `delay`. */
169
+ delayMs: z
170
+ .number()
171
+ .int()
172
+ .positive()
173
+ .max(TEST_SINK_BOUNDS.maxDelayMs)
174
+ .optional(),
175
+ /**
176
+ * How many deliveries `behavior` applies to before `then` takes over.
177
+ * Absent means "every delivery".
178
+ */
179
+ forDeliveries: z.number().int().positive().max(100).optional(),
180
+ /** `Retry-After` seconds sent with `rate_limited`. */
181
+ retryAfterSeconds: z.number().int().positive().max(300).optional(),
182
+ /** Which 4xx or 5xx to answer with. Defaults per behavior. */
183
+ status: z.number().int().min(400).max(599).optional(),
184
+ /** Behavior after `forDeliveries` is exhausted. Defaults to `success`. */
185
+ then: testSinkBehaviorSchema.optional(),
186
+ })
187
+ .superRefine((program, context) => {
188
+ if (program.status !== undefined) {
189
+ const allowed = program.behavior === "client_error"
190
+ ? TEST_SINK_CLIENT_ERROR_STATUSES
191
+ : program.behavior === "server_error"
192
+ ? TEST_SINK_SERVER_ERROR_STATUSES
193
+ : [];
194
+ if (!allowed.includes(program.status)) {
195
+ context.addIssue({
196
+ code: "custom",
197
+ message: "status is only accepted with client_error or server_error, and only from the published list",
198
+ path: ["status"],
199
+ });
200
+ }
201
+ }
202
+ if (program.delayMs !== undefined && program.behavior !== "delay") {
203
+ context.addIssue({
204
+ code: "custom",
205
+ message: "delayMs is only accepted with the delay behavior",
206
+ path: ["delayMs"],
207
+ });
208
+ }
209
+ // The gap before a *report*, so it is only meaningful for a behavior that
210
+ // sends one. `defer_abandon` deliberately sends none, and accepting a delay
211
+ // there would read as configured while nothing would ever honour it —
212
+ // the same reason `then` without `forDeliveries` is refused below.
213
+ //
214
+ // Read off the facts rather than off a list of behavior names: the facts
215
+ // are what the runtime resolves against, so a behavior that stopped
216
+ // reporting would stop accepting the modifier in the same edit.
217
+ if (program.completionDelayMs !== undefined &&
218
+ !reportsCompletion(program.behavior)) {
219
+ context.addIssue({
220
+ code: "custom",
221
+ message: "completionDelayMs is only accepted with a behavior that reports completion",
222
+ path: ["completionDelayMs"],
223
+ });
224
+ }
225
+ if (program.retryAfterSeconds !== undefined &&
226
+ program.behavior !== "rate_limited") {
227
+ context.addIssue({
228
+ code: "custom",
229
+ message: "retryAfterSeconds is only accepted with the rate_limited behavior",
230
+ path: ["retryAfterSeconds"],
231
+ });
232
+ }
233
+ // `then` exists to end a bounded run of failures. Without `forDeliveries`
234
+ // it is unreachable, and a program with an unreachable half reads as
235
+ // configured when it is not.
236
+ if (program.then !== undefined && program.forDeliveries === undefined) {
237
+ context.addIssue({
238
+ code: "custom",
239
+ message: "then requires forDeliveries",
240
+ path: ["then"],
241
+ });
242
+ }
243
+ });
244
+ /**
245
+ * What each behavior does, as data rather than prose, so the published artifact
246
+ * and the implementation cannot drift and a receiver author can read the whole
247
+ * simulation without running it.
248
+ *
249
+ * `senderOutcome` is stated in the vocabulary of `classifyOutcome`, which is
250
+ * what the delivery path actually applies. `receiverActed` is the honest answer
251
+ * to "may the receiver have processed this?" — the question a sender cannot
252
+ * answer for itself and must therefore record as ambiguity.
253
+ *
254
+ * `deferredReport` is present on the behaviors that acknowledge now and report
255
+ * later, and it is the reason `senderOutcome` cannot be derived from
256
+ * `defaultStatus` for those three: all three answer the same `202`, which
257
+ * `classifyStatus` reads as an ordinary success, and what the sender finally
258
+ * records comes from the report — or, for `never`, from the deadline passing
259
+ * with no report at all.
260
+ */
261
+ export const TEST_SINK_BEHAVIOR_FACTS = {
262
+ client_error: {
263
+ defaultStatus: 422,
264
+ deferredReport: undefined,
265
+ receiverActed: false,
266
+ senderOutcome: "terminal",
267
+ summary: "Answers a selected 4xx. The sender records a customer refusal, and retries it only when the status is one the receiver may recover from — 401, 403 and 404, listed as `clientErrorStatusesRetried`. The default, 422, is not one of those, so it settles on the first attempt.",
268
+ },
269
+ defer_abandon: {
270
+ defaultStatus: CALLBACK_DEFERRAL_STATUS,
271
+ deferredReport: "never",
272
+ // It answered 202, which is the receiver taking the work. That is what
273
+ // makes this the ambiguity the deadline exists for rather than a failure:
274
+ // the work may well have run, and nothing anyone can observe says whether
275
+ // it did.
276
+ receiverActed: true,
277
+ senderOutcome: "ambiguous",
278
+ summary: "Acknowledges the delivery and then never reports at all. The sender waits until the deadline in the envelope and records ambiguity, because a receiver that took the work and went quiet may well have done it. This is the case the completion deadline exists to bound.",
279
+ },
280
+ defer_fail: {
281
+ defaultStatus: CALLBACK_DEFERRAL_STATUS,
282
+ deferredReport: "failed",
283
+ receiverActed: true,
284
+ senderOutcome: "retryable",
285
+ summary: "Acknowledges the delivery, then reports `failed` with the credential in its signed envelope. The occurrence goes back on the ordinary retry ladder — the same place a 500 puts it — which is how a receiver says its job died rather than that it vanished.",
286
+ },
287
+ defer_succeed: {
288
+ defaultStatus: CALLBACK_DEFERRAL_STATUS,
289
+ deferredReport: "succeeded",
290
+ receiverActed: true,
291
+ senderOutcome: "success",
292
+ summary: "Acknowledges the delivery with 202 and the pending marker, keeps the work, then reports `succeeded` with the credential in its signed envelope. The whole deferred exchange, without writing a receiver.",
293
+ },
294
+ delay: {
295
+ defaultStatus: 200,
296
+ deferredReport: undefined,
297
+ receiverActed: true,
298
+ senderOutcome: "success",
299
+ summary: "Waits a bounded interval, then accepts. Exercises a slow receiver that still succeeds.",
300
+ },
301
+ disconnect: {
302
+ defaultStatus: undefined,
303
+ deferredReport: undefined,
304
+ receiverActed: false,
305
+ senderOutcome: "ambiguous",
306
+ summary: "Authenticates the delivery and then drops the connection without processing it, holding until the sender gives up. Nothing was claimed and nothing ran, but the sender cannot know that.",
307
+ },
308
+ duplicate: {
309
+ defaultStatus: 200,
310
+ deferredReport: undefined,
311
+ receiverActed: false,
312
+ senderOutcome: "success",
313
+ summary: "Accepts and reports the occurrence as already handled, without processing it again. What occurrence idempotency looks like from the sender's side.",
314
+ },
315
+ invalid_signature: {
316
+ defaultStatus: 401,
317
+ deferredReport: undefined,
318
+ receiverActed: false,
319
+ // Retryable since FXO-1596, and the reversal is deliberate. The reasoning
320
+ // it replaces was that a key mismatch cannot self-heal the way a stale
321
+ // timestamp does — true of a mismatch caused by our own signing, and wrong
322
+ // about the population that actually occurs, which is a receiver holding a
323
+ // credential it has rotated or not yet deployed. That fixes itself on their
324
+ // next release, and the delivery that follows carries a fresh signature.
325
+ senderOutcome: "retryable",
326
+ summary: "Rejects the delivery as unauthenticated regardless of whether the signature verified. Exercises the key-mismatch path without forging a signature. The sender retries on the receiver's ordinary budget, because the usual cause is a credential the receiver is about to redeploy.",
327
+ },
328
+ rate_limited: {
329
+ defaultStatus: 429,
330
+ deferredReport: undefined,
331
+ receiverActed: false,
332
+ senderOutcome: "retryable",
333
+ summary: "Answers 429 with a bounded Retry-After. The sender honours the hint and does not spend its give-up budget.",
334
+ },
335
+ server_error: {
336
+ defaultStatus: 503,
337
+ deferredReport: undefined,
338
+ receiverActed: false,
339
+ senderOutcome: "retryable",
340
+ summary: "Answers a selected 5xx. The sender retries with backoff.",
341
+ },
342
+ success: {
343
+ defaultStatus: 200,
344
+ deferredReport: undefined,
345
+ receiverActed: true,
346
+ senderOutcome: "success",
347
+ summary: "Verifies, records, and accepts.",
348
+ },
349
+ timeout: {
350
+ defaultStatus: undefined,
351
+ deferredReport: undefined,
352
+ receiverActed: true,
353
+ senderOutcome: "ambiguous",
354
+ summary: "Verifies and records the delivery, then never answers. The receiver acted and the sender cannot know it.",
355
+ },
356
+ };
357
+ /**
358
+ * Whether this behavior acknowledges now and reports later.
359
+ *
360
+ * `defer_abandon` defers and does *not* report, so the two questions are
361
+ * separate ones and a caller asking "does it defer" must not be answered with
362
+ * "does it report".
363
+ */
364
+ function defersCompletion(behavior) {
365
+ return TEST_SINK_BEHAVIOR_FACTS[behavior].deferredReport !== undefined;
366
+ }
367
+ /** The outcome this behavior reports, or `undefined` if it reports nothing. */
368
+ function completionReportFor(behavior) {
369
+ const report = TEST_SINK_BEHAVIOR_FACTS[behavior].deferredReport;
370
+ return report === undefined || report === "never" ? undefined : report;
371
+ }
372
+ /** Whether this behavior sends a completion report at all. */
373
+ function reportsCompletion(behavior) {
374
+ return completionReportFor(behavior) !== undefined;
375
+ }
376
+ /**
377
+ * The exact body a deferring sink answers with.
378
+ *
379
+ * Built from the published discriminator rather than written out, so a sink
380
+ * cannot emit a marker the sender does not recognise. Deliberately without
381
+ * `completeBy`: the sink's report follows within `completionDelayMs`, and an
382
+ * acknowledgement that named an instant that close would be asking the sender
383
+ * to stop waiting at roughly the moment the report arrives — a race the
384
+ * rehearsal would lose intermittently and blame on the protocol. A receiver
385
+ * that wants to bring its deadline forward is exercising
386
+ * `effectiveCompletionDeadlineMs`, which is the delivery path's own test rather
387
+ * than this simulation's.
388
+ */
389
+ export const TEST_SINK_PENDING_ACKNOWLEDGEMENT = JSON.stringify({
390
+ [CALLBACK_PENDING_DISCRIMINATOR.key]: {
391
+ status: CALLBACK_PENDING_DISCRIMINATOR.status,
392
+ },
393
+ });
394
+ /**
395
+ * Resolve a program against how many deliveries this sink has already taken.
396
+ *
397
+ * Pure and total: the same `(program, deliveryIndex)` always produces the same
398
+ * action, which is what "deterministic" means here and what lets the same
399
+ * engine drive both the hosted sink and an offline conformance run.
400
+ *
401
+ * `deliveryIndex` is zero-based and counts every delivery the sink accepted for
402
+ * processing, including refused ones — a receiver that answers 401 has still
403
+ * received a request, and a program that said "fail the first two" must not be
404
+ * satisfiable by failing the same delivery twice.
405
+ */
406
+ export function resolveTestSinkAction(program, deliveryIndex) {
407
+ const inFirstPhase = program.forDeliveries === undefined ||
408
+ deliveryIndex < program.forDeliveries;
409
+ const behavior = inFirstPhase
410
+ ? program.behavior
411
+ : (program.then ?? "success");
412
+ const facts = TEST_SINK_BEHAVIOR_FACTS[behavior];
413
+ const status = inFirstPhase
414
+ ? (program.status ?? facts.defaultStatus)
415
+ : facts.defaultStatus;
416
+ const retryAfterSeconds = behavior === "rate_limited"
417
+ ? ((inFirstPhase ? program.retryAfterSeconds : undefined) ?? 5)
418
+ : undefined;
419
+ const defers = defersCompletion(behavior);
420
+ const completionOutcome = completionReportFor(behavior);
421
+ return {
422
+ ...(defers ? { acknowledgement: TEST_SINK_PENDING_ACKNOWLEDGEMENT } : {}),
423
+ behavior,
424
+ claims: behavior !== "disconnect",
425
+ // Defaulted the way `delayMs` is, and for the same reason: the wait is the
426
+ // only thing separating a report that arrives after the response from one
427
+ // that races it, so a default of zero would make the rehearsal prove less
428
+ // than it appears to.
429
+ completionDelayMs: completionOutcome === undefined
430
+ ? 0
431
+ : ((inFirstPhase ? program.completionDelayMs : undefined) ?? 250),
432
+ ...(completionOutcome === undefined ? {} : { completionOutcome }),
433
+ defers,
434
+ delayMs: behavior === "delay"
435
+ ? ((inFirstPhase ? program.delayMs : undefined) ?? 250)
436
+ : 0,
437
+ hold: behavior === "timeout" || behavior === "disconnect",
438
+ ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
439
+ ...(status === undefined ? {} : { status }),
440
+ };
441
+ }
442
+ /* -------------------------------------------------------------------------- */
443
+ /* The receiver contract */
444
+ /* -------------------------------------------------------------------------- */
445
+ /**
446
+ * Every way a receiver can dispose of one delivery.
447
+ *
448
+ * `duplicate` and `replay` are both "already handled" and both succeed, and
449
+ * separating them is what makes an integrator's logs legible: `replay` is the
450
+ * same delivery identity arriving twice — a duplicated request — while
451
+ * `duplicate` is a *different* delivery for an occurrence already processed,
452
+ * which is what a retry after an ambiguous attempt, and what a customer-ordered
453
+ * redelivery, both look like on the wire.
454
+ *
455
+ * `in_flight` is the concurrent case: another worker holds the occurrence and
456
+ * has not finished. Answering success there would claim an outcome nobody has
457
+ * yet produced.
458
+ */
459
+ export const CALLBACK_RECEIVER_DISPOSITIONS = [
460
+ "body_too_large",
461
+ "duplicate",
462
+ "handler_failed",
463
+ "in_flight",
464
+ "invalid_envelope",
465
+ "invalid_headers",
466
+ "invalid_signature",
467
+ "processed",
468
+ "replay",
469
+ "stale_timestamp",
470
+ ];
471
+ export const callbackReceiverDispositionSchema = z.enum(CALLBACK_RECEIVER_DISPOSITIONS);
472
+ /**
473
+ * What a sink recorded, which is one value wider than what a receiver can
474
+ * answer with.
475
+ *
476
+ * `connection_dropped` has no status because the point of it is that there was
477
+ * none: the sink modelled a connection that died before the receiver read
478
+ * anything, so nothing was authenticated and nothing ran. Widening
479
+ * `CALLBACK_RECEIVER_DISPOSITIONS` to hold it would break the property that
480
+ * makes that list useful — every member maps to exactly one status.
481
+ */
482
+ export const TEST_SINK_DISPOSITIONS = [
483
+ ...CALLBACK_RECEIVER_DISPOSITIONS,
484
+ "connection_dropped",
485
+ ];
486
+ export const testSinkDispositionSchema = z.enum(TEST_SINK_DISPOSITIONS);
487
+ /**
488
+ * The HTTP status a conforming receiver returns for each disposition.
489
+ *
490
+ * Published, and published *here*, because the sender classifies on the status
491
+ * and the classification decides whether an occurrence retries, settles, or is
492
+ * abandoned. A receiver that picks its own numbers can be perfectly correct
493
+ * about what it did and still tell the sender the opposite.
494
+ *
495
+ * Each choice is answerable against `classifyStatus`:
496
+ *
497
+ * - `processed`, `duplicate`, `replay` — 200. All three mean the occurrence is
498
+ * handled. A replayed or duplicated delivery answering 4xx would make the
499
+ * sender record a terminal failure for work that succeeded, which is the
500
+ * single most damaging thing a receiver can do.
501
+ * - `stale_timestamp` — 408, which is retryable. A clock skew wide enough to
502
+ * fail tolerance is an operator problem no retry fixes, but a request held on
503
+ * the network for six minutes is not, and every attempt re-signs with a fresh
504
+ * timestamp, so the transient case self-heals and the permanent case exhausts
505
+ * the attempt bound honestly.
506
+ * - `invalid_signature`, `invalid_headers` — 401, **retryable since
507
+ * FXO-1596**, and this entry used to say the opposite. The reasoning it
508
+ * replaces was that retrying with the same key cannot succeed and a retryable
509
+ * answer would spend the give-up budget hiding a misconfiguration. That is
510
+ * true of a mismatch in *our* signing and false of the population that
511
+ * actually reaches a 401: a receiver holding a credential it has rotated,
512
+ * expired, or not yet deployed. Those recover on the receiver's next release,
513
+ * and the delivery after it carries a fresh signature. The misconfiguration
514
+ * is not hidden either — five identical 401s settle the occurrence 82 seconds
515
+ * later with the same `inspect_receiver` advice, five rows instead of one.
516
+ * - `invalid_envelope` — 400, terminal. The bytes authenticated and did not
517
+ * parse, so the sender and receiver disagree about the contract.
518
+ * - `body_too_large` — 413, terminal, and matched to the `payload_too_large`
519
+ * problem code the API surface already publishes.
520
+ * - `in_flight` — 503, retryable. Honest: someone else holds the occurrence.
521
+ * - `handler_failed` — 500, retryable. The receiver's own fault and worth
522
+ * another attempt.
523
+ */
524
+ export const CALLBACK_RECEIVER_STATUS = {
525
+ body_too_large: 413,
526
+ duplicate: 200,
527
+ handler_failed: 500,
528
+ in_flight: 503,
529
+ invalid_envelope: 400,
530
+ invalid_headers: 401,
531
+ invalid_signature: 401,
532
+ processed: 200,
533
+ replay: 200,
534
+ stale_timestamp: 408,
535
+ };
536
+ /**
537
+ * The response body a conforming receiver returns.
538
+ *
539
+ * Deliberately tiny and deliberately free of prose: a receiver's response is
540
+ * read by a machine, and a body that carries an exception message is how a
541
+ * receiver leaks its internals to whoever can provoke an error.
542
+ */
543
+ export const callbackReceiverAcknowledgementSchema = z.object({
544
+ disposition: callbackReceiverDispositionSchema,
545
+ /** Present only when the delivery authenticated. */
546
+ occurrenceId: occurrenceIdSchema.optional(),
547
+ });
548
+ /* -------------------------------------------------------------------------- */
549
+ /* The sink resource */
550
+ /* -------------------------------------------------------------------------- */
551
+ export const testSinkSchema = z.object({
552
+ createdAt: timestampSchema,
553
+ /** Deliveries this sink has received, across its whole retained lifetime. */
554
+ deliveryCount: z.number().int().nonnegative(),
555
+ /** The callback endpoint whose signing keys this sink verifies against. */
556
+ endpoint: endpointIdSchema,
557
+ /** Always `test`. Published so a reader never has to infer it. */
558
+ environmentKind: z.literal("test"),
559
+ id: testSinkIdSchema,
560
+ program: testSinkProgramSchema,
561
+ project: projectIdSchema,
562
+ updatedAt: timestampSchema,
563
+ /**
564
+ * Where to point the endpoint. Absolute and server-assigned.
565
+ *
566
+ * The scheme is the stage's own public origin rather than a fixed `https://`,
567
+ * which is not a weakening: every hosted stage is HTTPS, and a callback
568
+ * endpoint's URL policy refuses anything else outright, so a sink served over
569
+ * plain HTTP is one no endpoint can be pointed at. Asserting `https` here
570
+ * would only make the local development origin — `http://127.0.0.1` — fail
571
+ * schema validation instead of failing endpoint validation, which moves the
572
+ * refusal somewhere less informative.
573
+ */
574
+ url: z.url(),
575
+ });
576
+ export const testSinkPageSchema = pageSchema(testSinkSchema);
577
+ export const testSinkCreateRequestSchema = z.object({
578
+ endpoint: endpointIdSchema,
579
+ /** Initial behavior. Defaults to plain acceptance. */
580
+ program: testSinkProgramSchema.optional(),
581
+ });
582
+ /* -------------------------------------------------------------------------- */
583
+ /* Delivery receipts */
584
+ /* -------------------------------------------------------------------------- */
585
+ /**
586
+ * One received delivery, as the sink recorded it.
587
+ *
588
+ * The identifiers, the key that signed it, and the disposition are the whole of
589
+ * what a *list* returns. The exact body is available only from the single-record
590
+ * read, capped, and only in a test environment — see `testSinkDeliveryBodySchema`.
591
+ */
592
+ export const testSinkDeliverySchema = z.object({
593
+ /** Which of the sink's behaviors produced the response. */
594
+ behavior: testSinkBehaviorSchema,
595
+ disposition: testSinkDispositionSchema,
596
+ /** The sender's delivery identity, from the signed header. */
597
+ delivery: deliveryIdSchema,
598
+ id: testSinkDeliveryIdSchema,
599
+ /** Absent when the delivery failed authentication. */
600
+ keyId: callbackSigningKeyIdSchema.optional(),
601
+ occurrenceId: occurrenceIdSchema.optional(),
602
+ receivedAt: timestampSchema,
603
+ /** Absent when the sink held the connection instead of answering. */
604
+ respondedWith: z.number().int().min(100).max(599).optional(),
605
+ scheduleId: scheduleIdSchema.optional(),
606
+ sink: testSinkIdSchema,
607
+ });
608
+ export const testSinkDeliveryPageSchema = pageSchema(testSinkDeliverySchema);
609
+ /**
610
+ * One received delivery including the bytes that arrived.
611
+ *
612
+ * **The one deliberate exception to "callback bodies never leave the delivery
613
+ * path".** The rule exists because a payload is customer content and a log,
614
+ * metric, or support index is the wrong place for it. A test sink is none of
615
+ * those: it is a destination the customer created, in their own test
616
+ * environment, to look at what their receiver would have been sent, and
617
+ * withholding it would leave the sink unable to answer the first question
618
+ * anybody asks it.
619
+ *
620
+ * The exception is kept narrow rather than assumed safe. The body is available
621
+ * only from the single-delivery read and never from a listing, only under the
622
+ * creating authority, only in test, capped at
623
+ * `TEST_SINK_BOUNDS.maxRetainedBodyBytes` with the truncation stated, and never
624
+ * written to a log. `signature` is included because a MAC is not secret and
625
+ * comparing it against a locally computed one is how a receiver author finds a
626
+ * body-mutation bug; no signing key has a field anywhere in this file.
627
+ */
628
+ export const testSinkDeliveryBodySchema = testSinkDeliverySchema.extend({
629
+ /** The exact bytes as received, UTF-8 decoded, possibly truncated. */
630
+ body: z.string(),
631
+ /** True when the body exceeded the retained cap and was cut. */
632
+ bodyTruncated: z.boolean(),
633
+ /** Exactly the bytes that arrived, before truncation. */
634
+ receivedBytes: z.number().int().nonnegative(),
635
+ /** The offered MAC. Public by construction; never a key. */
636
+ signature: signatureHeaderSchema.optional(),
637
+ });
638
+ /* -------------------------------------------------------------------------- */
639
+ /* The published artifact */
640
+ /* -------------------------------------------------------------------------- */
641
+ /**
642
+ * Dated, and moved on 2026-08-23 because the sink gained the three deferring
643
+ * behaviors and the bound on how long one may wait before reporting. A reader
644
+ * holding the older artifact has a complete and correct description of a sink
645
+ * that could not defer.
646
+ */
647
+ export const TEST_SINK_PROTOCOL_VERSION = "2026-08-23";
648
+ /**
649
+ * The language-neutral receiver contract.
650
+ *
651
+ * A receiver written in any stack can be held to this: verify these bytes,
652
+ * answer these statuses, dedupe on this identity. It is generated beside
653
+ * OpenAPI so an integrator does not have to read TypeScript to write a Go
654
+ * receiver.
655
+ */
656
+ export const CALLBACK_RECEIVER_PROTOCOL = {
657
+ idempotency: {
658
+ /**
659
+ * Stated plainly because it is the property most often got wrong, and the
660
+ * one the contract vocabulary already implies without saying so anywhere a
661
+ * receiver author would look.
662
+ */
663
+ occurrence: {
664
+ identity: CALLBACK_HEADERS.occurrenceId,
665
+ note: "Every automatic delivery of one occurrence — the first attempt and every retry the system chose — is at most one execution. A receiver claims the occurrence atomically and answers 200 for every later automatic delivery of it. This is what makes at-least-once delivery safe; a receiver that needs a second execution asks for a second occurrence, or a person orders a redelivery.",
666
+ requirement: "claim the occurrence atomically before running the handler; release the claim if the handler fails so a later attempt can retry it",
667
+ },
668
+ /**
669
+ * The one case where an occurrence runs twice, and the reason it is safe.
670
+ *
671
+ * Until the envelope carried `trigger` this could not be expressed: a retry
672
+ * nobody asked for and a redelivery a person ordered arrived identically, so
673
+ * the rule safe against the first was applied to both and pressing
674
+ * "redeliver" changed nothing. The unit of idempotency is therefore not the
675
+ * occurrence alone but the occurrence *and what asked for it*.
676
+ */
677
+ redelivery: {
678
+ identity: `${CALLBACK_HEADERS.occurrenceId} together with the signed attempt number, which is the redelivery's ordinal`,
679
+ note: "A redelivery is a deliberate, authorized instruction to do the work again. Suppressing it as a duplicate answers a person with silence, which is what a customer pressing redeliver after fixing their endpoint experiences as the attempt succeeding while nothing happens.",
680
+ optOut: "A receiver that genuinely requires strict once-only per occurrence chooses it explicitly and keeps answering duplicate. It is an opt-out, not the default, because a default of suppression makes the product lie to the person who pressed the button.",
681
+ ordering: "attempt only ever increases within an occurrence and every copy of one redelivery carries the same value, so a receiver keeps the highest redelivery it has run to completion and refuses anything at or below it. That is what recognises a copy delayed in a proxy, a queue or an abandoned connection, however late it arrives and however many redeliveries have completed since. Remembering only the most recent instruction cannot do this and will run a customer's work twice; remembering every instruction can, at the cost of a store that grows without bound. sourceAttemptId names the attempt the person chose to follow and is there to show them, not to decide whether the work runs.",
682
+ requirement: "run the handler again for a redelivery whose attempt is above the highest this receiver has run to completion for the occurrence, and suppress every redelivery at or below that high-water mark",
683
+ strictness: "A receiver that chose strict once-only suppresses every delivery for an occurrence once anything has finished, whatever caused it. Reading strictness as a rule about redeliveries alone leaves a hole: a strict receiver that processed a redelivery first would then admit a delayed automatic delivery and run a payment twice.",
684
+ warranty: "Each accepted redelivery is one logical run. Repeats are suppressed once the receiver has durably completed that run — not from the moment it starts one. A handler that performs its external effect and then dies before its claim is settled will be run again when the lease expires, which is the honest limit of what any at-least-once receiver can promise. A customer whose effect must happen exactly once commits the occurrence and ordinal alongside their own state, or passes them downstream as an idempotency key.",
685
+ },
686
+ replay: {
687
+ identity: CALLBACK_HEADERS.deliveryId,
688
+ requirement: "reject a previously accepted delivery identity before parsing, and answer 200 — a duplicated request is not a failure",
689
+ },
690
+ },
691
+ /**
692
+ * How a receiver's occurrence store answers one claim, in order.
693
+ *
694
+ * Published as data because a receiver author outside JavaScript had nowhere
695
+ * to read it: `order` covers authenticating a request, and the prose guide is
696
+ * TypeScript-shaped and not machine-readable. It drifted from the code three
697
+ * times during W3-08 — and the third time it published a rule that would have
698
+ * had a customer take a payment twice — so the ordered list now lives beside
699
+ * the rules it describes and the guide is checked against it.
700
+ *
701
+ * Rule 2 is deliberately about what has *finished* rather than what *arrived*.
702
+ * Splitting it — suppress an automatic delivery when the automatic run
703
+ * finished, apply strictness only to redeliveries — is the hole: a strict
704
+ * receiver that legitimately saw an ordered redelivery first would then admit
705
+ * a delayed automatic copy and run the work again.
706
+ */
707
+ claimRules: [
708
+ "another delivery holds an unexpired lease, whatever the run, so an ordered redelivery never runs alongside the attempt it follows -> in_flight",
709
+ "the delivery may not retake a settled occurrence and anything at all has finished for it -> settled. This covers every automatic delivery and every delivery at a receiver that chose strict once-only",
710
+ "the delivery is a redelivery whose ordinal is at or below the highest this receiver has run to completion -> settled",
711
+ "otherwise take it, recording the holder and the run -> claimed",
712
+ ],
713
+ order: [
714
+ "read the exact request bytes before any body parser sees them",
715
+ "reject a body over the receiver's byte bound without buffering it",
716
+ "validate the five Callback-* headers and the content type",
717
+ "reject a timestamp outside tolerance",
718
+ "select the signing key by Callback-Key-Id within the receiver's own endpoint, project, and environment",
719
+ "compare the HMAC in constant time over <timestamp>.<delivery-id>. plus the exact bytes",
720
+ "compare the canonical envelope identifiers with the occurrence header",
721
+ "claim the delivery identity atomically",
722
+ // Parsing moved ahead of the occurrence claim on 2026-08-08, and it is a
723
+ // correction as much as a change: the shipped verifier already parsed here,
724
+ // because nothing downstream can use an envelope it has not decoded. What
725
+ // is new is that the step now *matters* — the trigger deciding which run is
726
+ // being claimed lives in the body, so it must come from bytes the MAC has
727
+ // already authenticated rather than from a header anyone could set.
728
+ "only now parse the body, and read the trigger that says why this delivery exists",
729
+ "claim the occurrence identity atomically, for the run the signed body names — the occurrence for an automatic delivery, and the occurrence with the redelivery's attempt ordinal for one a person ordered",
730
+ "run the application handler",
731
+ ],
732
+ responses: CALLBACK_RECEIVER_STATUS,
733
+ sink: {
734
+ behaviors: TEST_SINK_BEHAVIOR_FACTS,
735
+ bounds: TEST_SINK_BOUNDS,
736
+ clientErrorStatuses: TEST_SINK_CLIENT_ERROR_STATUSES,
737
+ // Published as data rather than described in a summary, because a receiver
738
+ // author reading this artifact writes their integration test against it and
739
+ // has no way to read a TypeScript constant. Which of the 4xx above are
740
+ // retried is the one fact about the sink that changed at FXO-1596.
741
+ clientErrorStatusesRetried: TEST_SINK_REFUSAL_STATUSES,
742
+ // The `defer_*` behaviors made "never sends" false, and stating the
743
+ // narrower property is worth more than restating the old one: what keeps a
744
+ // sink from being an egress surface was never that it made no request, but
745
+ // that nothing anyone can set names a place for it to send to.
746
+ deferral: {
747
+ acknowledgement: TEST_SINK_PENDING_ACKNOWLEDGEMENT,
748
+ note: "A deferring behavior answers this exact body with this status, then reports with the bearer credential from the delivery's own signed envelope — to the one completion address the stage is configured with, which the envelope's own URL must match. A delivery whose envelope carries no completion material, or names a different address, cannot defer, and the sink answers it as an ordinary success instead.",
749
+ status: CALLBACK_DEFERRAL_STATUS,
750
+ },
751
+ egress: "A test sink has no destination field: no program, no control operation and no request header names anywhere for it to send. The one request it performs is a deferred-completion report for a delivery it has already received and authenticated, to the URL and credential carried inside that delivery's signed envelope. It cannot forward or redirect a delivery.",
752
+ visibility: "Test environments only. Organization, project, environment, and credential kind come from the verified principal; no request may widen them.",
753
+ },
754
+ version: TEST_SINK_PROTOCOL_VERSION,
755
+ };