@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,1116 @@
1
+ import { z } from "zod";
2
+ import { attemptIdSchema, deliveryIdSchema, endpointIdSchema, occurrenceIdSchema, scheduleIdSchema, } from "./identifiers.js";
3
+ import { CALLBACK_ATTEMPT_DIAGNOSTICS } from "./delivery.js";
4
+ import { pageSchema, versionSchema } from "./protocol.js";
5
+ import { callbackTriggerSchema, } from "./signatures.js";
6
+ import { durationSecondsSchema, timeZoneSchema, timestampSchema } from "./time.js";
7
+ /**
8
+ * The scheduling resource family: schedules, the occurrences they produce, and
9
+ * the attempts made to deliver each occurrence.
10
+ *
11
+ * Three ideas make the difference between this and the legacy scheduler, where
12
+ * one row was the schedule, the occurrence and the delivery all at once.
13
+ *
14
+ * 1. **A schedule is a definition; an occurrence is a concrete instant.** A
15
+ * recurring series never advances by rewriting its row, because that loses
16
+ * the outcome of the occurrence it just overwrote. Each occurrence carries
17
+ * its own state, retries and terminal outcome.
18
+ * 2. **A generation makes a stale event harmless.** Replacing a definition
19
+ * increments it, so work already in flight against the old definition can be
20
+ * recorded but can never fire, retire, or advance the replacement.
21
+ * 3. **An attempt is immutable once classified.** A redelivery is a new
22
+ * attempt against the same occurrence; it never rewrites history.
23
+ *
24
+ * Nothing here carries an authority tuple. A scheduling resource belongs to the
25
+ * partition that stores it, and that partition is bound to exactly one
26
+ * `(organization, project, environment)` for its whole life — see
27
+ * `docs/architecture/system-architecture.md` section 3. Repeating the tuple on
28
+ * every row would invite two answers to the same question.
29
+ */
30
+ /* -------------------------------------------------------------------------- */
31
+ /* Schedule state */
32
+ /* -------------------------------------------------------------------------- */
33
+ /**
34
+ * Every state a schedule can be in, in lifecycle order.
35
+ *
36
+ * `paused` is reversible and `cancelled` is terminal. `completed` is what a
37
+ * finite schedule reaches on its own — a one-shot that fired, or a series that
38
+ * ran out of occurrences — and is distinct from `cancelled` because "it
39
+ * finished" and "someone stopped it" are different answers to a support
40
+ * question.
41
+ */
42
+ export const SCHEDULE_STATUSES = [
43
+ "active",
44
+ "paused",
45
+ "cancelled",
46
+ "completed",
47
+ ];
48
+ export const scheduleStatusSchema = z.enum(SCHEDULE_STATUSES);
49
+ /**
50
+ * How promptly an occurrence expects to be dispatched.
51
+ *
52
+ * Two classes, not a numeric priority: a number invites callers to bid against
53
+ * each other, and a scheduler that honours arbitrary priorities cannot promise
54
+ * anything to anybody. `realtime` gets preference without starving `bulk`.
55
+ */
56
+ export const LATENCY_CLASSES = ["realtime", "bulk"];
57
+ export const latencyClassSchema = z.enum(LATENCY_CLASSES);
58
+ /* -------------------------------------------------------------------------- */
59
+ /* Delivery duration */
60
+ /* -------------------------------------------------------------------------- */
61
+ /**
62
+ * How long a receiver may take to answer, as a ladder rather than a number.
63
+ *
64
+ * **A ladder because every rung is a different product.** The first two are the
65
+ * ordinary path: a delivery is carried inside the partition's own dispatch and
66
+ * holds one of six in-flight slots for its whole duration, so the cost of a slow
67
+ * receiver is paid by every other schedule in that
68
+ * `(organization, project, environment)`. The rungs above are carried elsewhere
69
+ * and priced, because that is the only honest way to sell them.
70
+ *
71
+ * **Deliberately not a free-form integer.** A caller that could ask for 47
72
+ * seconds would be asking a question this product has no answer to: 47 is past
73
+ * the point where a delivery may occupy a partition slot and short of the point
74
+ * where it is worth carrying separately. The ladder is what makes "which side of
75
+ * the line is this" a decision somebody took rather than a comparison somebody
76
+ * wrote.
77
+ */
78
+ export const DELIVERY_DURATION_TIERS = [
79
+ 5, 30, 300, 900, 1_800, 3_600, 43_200, 86_400,
80
+ ];
81
+ /**
82
+ * What a schedule gets without asking, in seconds.
83
+ *
84
+ * Five, and it is a **six-fold tightening of the 30 seconds that shipped**. The
85
+ * reason is `DEFAULT_MAX_OUTSTANDING`: a partition dispatches six deliveries at
86
+ * once because Cloudflare permits six connections per invocation to be waiting
87
+ * for response headers, so a partition's throughput is six divided by how long a
88
+ * delivery takes. At 30 seconds that is twelve deliveries a minute for
89
+ * everything a customer has scheduled in one project and environment; at five it
90
+ * is seventy-two. The everyday reminder webhook this product is designed around
91
+ * answers in well under a second, and the schedules that do not now have
92
+ * somewhere to say so.
93
+ */
94
+ export const DEFAULT_DELIVERY_DURATION_SECONDS = 5;
95
+ /**
96
+ * The longest duration that is not a long-running schedule, in seconds.
97
+ *
98
+ * Thirty. A receiver that writes a row and calls one API can exceed five seconds
99
+ * without being a long-running job, and making that customer buy a plan to fix
100
+ * an occasional timeout would price the most ordinary failure there is. Above
101
+ * this the delivery is carried outside the partition and is a plan feature.
102
+ *
103
+ * EasyCron, which sells this ladder, allows **five seconds** on its free tier
104
+ * and nothing between that and twelve hours. The thirty-second rung is a
105
+ * deliberate difference from them rather than an oversight.
106
+ */
107
+ export const FREE_DELIVERY_DURATION_SECONDS = 30;
108
+ /**
109
+ * The longest a receiver may hold the request itself open, in seconds.
110
+ *
111
+ * Thirty minutes, and it is a platform fact rather than a commercial one. A
112
+ * `response` delivery is carried by a Workflow step holding a live socket, and
113
+ * Cloudflare restarts the Workers runtime a few times a week, giving in-flight
114
+ * requests a thirty-second grace period and terminating whatever is left. Their
115
+ * own limits page calls that "very unlikely" — which is true of a five-second
116
+ * delivery and inverts as the hold grows: at roughly three restarts a week a
117
+ * twelve-hour hold overlaps one about one time in five, and a twenty-four-hour
118
+ * hold about two in five.
119
+ *
120
+ * A terminated hold is not a lost occurrence — the watchdog settles it
121
+ * `network_ambiguous` and the ladder retries it — but `network_ambiguous` on a
122
+ * twelve-hour job means "your receiver may have run this, and we are about to
123
+ * ask it to run again". Selling that as a duration would be selling a coin flip.
124
+ *
125
+ * So the rungs above this are `callback` only. A `callback` receiver answers
126
+ * immediately and reports afterwards, so there is no hold for a restart to
127
+ * terminate — the attempt waits in one indexed row, which is not running when
128
+ * the runtime restarts. Cloudflare's own "limit timeouts to 30 minutes or less"
129
+ * advice lands on the same number from the same direction, which is the reason
130
+ * this constant is 1,800 rather than a round guess.
131
+ *
132
+ * **This is a ceiling on the mechanism, not on the ladder.** Every rung is still
133
+ * for sale and the duration is still what is priced; what a customer buying more
134
+ * than thirty minutes has to do is answer immediately and call us back.
135
+ */
136
+ export const RESPONSE_DELIVERY_MAX_DURATION_SECONDS = 1_800;
137
+ export const deliveryDurationSecondsSchema = z
138
+ .literal(DELIVERY_DURATION_TIERS)
139
+ .describe("How long the receiver may take to answer, in seconds");
140
+ /** Whether a duration is past the point where it needs a plan that allows it. */
141
+ export function isLongRunningDuration(seconds) {
142
+ return seconds > FREE_DELIVERY_DURATION_SECONDS;
143
+ }
144
+ /**
145
+ * How the receiver tells us the work finished.
146
+ *
147
+ * - `response` — it answers the POST when it is done, and we hold the request
148
+ * open for up to `maxDurationSeconds`. Nothing to build on the receiver's
149
+ * side; its own stack has to tolerate a request open that long, which is the
150
+ * customer's constraint rather than ours and is the one they will meet first.
151
+ * Available up to {@link RESPONSE_DELIVERY_MAX_DURATION_SECONDS}.
152
+ * - `callback` — it answers immediately to say it has the work, does the work,
153
+ * and calls the completion URL in the envelope when it is done. More to build
154
+ * on the receiver's side and no upper bound worth naming.
155
+ *
156
+ * **What is sold is the duration rather than the mechanism.** The operator
157
+ * settled that on 2026-08-12: `callback` costs this product almost nothing to
158
+ * run — an instance waiting for an event hibernates, one holding a socket does
159
+ * not — so pricing it separately would charge for the option we would rather
160
+ * every customer took.
161
+ *
162
+ * **Both were available at every rung until 2026-08-13**, and what changed is
163
+ * not the pricing but a measured platform limit: see
164
+ * {@link RESPONSE_DELIVERY_MAX_DURATION_SECONDS} for why a held-open request
165
+ * stops being a promise somebody can keep at the top three rungs. The ladder is
166
+ * untouched, and so is what a rung costs.
167
+ */
168
+ export const DELIVERY_COMPLETION_MODES = ["response", "callback"];
169
+ export const deliveryCompletionModeSchema = z.enum(DELIVERY_COMPLETION_MODES);
170
+ /**
171
+ * What to do when a recurring schedule comes due and its previous run has not
172
+ * finished.
173
+ *
174
+ * **There was no answer to this before long-running deliveries**, and the
175
+ * absence was deliberate rather than an oversight: `PartitionKernel.advanceSeries`
176
+ * never reads the head occurrence's state, because a series whose advance
177
+ * depended on delivery succeeding would stop a customer's daily schedule the
178
+ * first time their receiver had a bad afternoon. That reasoning is untouched for
179
+ * every schedule on the default duration, where a run lasts five seconds and an
180
+ * overlap is not a thing anyone can construct.
181
+ *
182
+ * It stops being true at twelve hours. An hourly schedule whose runs take twelve
183
+ * hours has twelve of them alive at once under the old rule, and no customer
184
+ * means that. So this setting exists **only on a long-running schedule**, which
185
+ * is the narrowest place it can live and still answer the question.
186
+ *
187
+ * - `skip` — do not run; record the occurrence as skipped. The default.
188
+ * - `queue` — run it when the previous one finishes.
189
+ */
190
+ export const DELIVERY_OVERLAP_POLICIES = ["skip", "queue"];
191
+ export const deliveryOverlapPolicySchema = z.enum(DELIVERY_OVERLAP_POLICIES);
192
+ export const DEFAULT_DELIVERY_OVERLAP_POLICY = "skip";
193
+ /**
194
+ * A definition generation. Starts at 1 and increases on every replacement.
195
+ *
196
+ * Deliberately separate from `version`. `version` moves for any write at all,
197
+ * including a pause, and drives ETags; `generation` moves only when the *work*
198
+ * changes, and is what a due event is checked against. Folding them together
199
+ * would make pausing and resuming a schedule invalidate occurrences that are
200
+ * still perfectly valid.
201
+ */
202
+ export const generationSchema = z
203
+ .number()
204
+ .int("must be a whole number")
205
+ .positive("must be at least 1");
206
+ /**
207
+ * A customer-supplied fairness key.
208
+ *
209
+ * Bounded in length and charset because it is used as a telemetry label and as
210
+ * a pacing lane: an unbounded key would let one caller mint unbounded
211
+ * cardinality in our metrics, and a key carrying arbitrary bytes would end up
212
+ * in a log line that nothing downstream can parse. It selects a lane within the
213
+ * caller's own partition and can never change the authority tuple or reach
214
+ * another Durable Object namespace.
215
+ */
216
+ export const PARTITION_KEY_MAX_LENGTH = 64;
217
+ export const partitionKeySchema = z
218
+ .string()
219
+ .min(1, "must not be empty")
220
+ .max(PARTITION_KEY_MAX_LENGTH, `must be at most ${String(PARTITION_KEY_MAX_LENGTH)} characters`)
221
+ .regex(/^[A-Za-z0-9._:-]+$/u, "must use only letters, digits, dot, underscore, colon, or hyphen");
222
+ /* -------------------------------------------------------------------------- */
223
+ /* Occurrence state */
224
+ /* -------------------------------------------------------------------------- */
225
+ /**
226
+ * Every state one occurrence can be in.
227
+ *
228
+ * `ambiguous` is a first-class state rather than a kind of failure. When a
229
+ * request times out the receiver may or may not have acted, and calling that
230
+ * either "succeeded" or "failed" is how an occurrence is silently dropped or
231
+ * silently duplicated.
232
+ *
233
+ * `skipped_stale` records an occurrence that became due too late to be worth
234
+ * firing, or whose definition was replaced underneath it. It is deliberately
235
+ * not `cancelled`: a customer who cancelled something and a customer whose
236
+ * occurrence went stale need different explanations.
237
+ */
238
+ export const OCCURRENCE_STATES = [
239
+ "planned",
240
+ "due",
241
+ "dispatching",
242
+ "retry_wait",
243
+ "ambiguous",
244
+ "succeeded",
245
+ "terminal_failed",
246
+ "cancelled",
247
+ "skipped_stale",
248
+ ];
249
+ export const occurrenceStateSchema = z.enum(OCCURRENCE_STATES);
250
+ /**
251
+ * The states an occurrence never leaves.
252
+ *
253
+ * Exported so a caller enumerates them rather than writing the list out and
254
+ * quietly missing one if another is ever added.
255
+ */
256
+ export const TERMINAL_OCCURRENCE_STATES = [
257
+ "succeeded",
258
+ "terminal_failed",
259
+ "cancelled",
260
+ "skipped_stale",
261
+ ];
262
+ export function isTerminalOccurrenceState(state) {
263
+ return TERMINAL_OCCURRENCE_STATES.includes(state);
264
+ }
265
+ /* -------------------------------------------------------------------------- */
266
+ /* Attempt state */
267
+ /* -------------------------------------------------------------------------- */
268
+ /**
269
+ * How one delivery attempt was classified. Immutable once set.
270
+ *
271
+ * These four are the attempt-level counterpart of `ResponseClass` in
272
+ * `signatures.ts`, which classifies a *response*. They are kept as separate
273
+ * vocabularies on purpose: a response class is a fact about what came back, and
274
+ * an attempt state is a fact about the attempt as a whole, including an attempt
275
+ * that never received a response at all. `attemptStateForResponse` is the only
276
+ * sanctioned mapping between them.
277
+ */
278
+ export const ATTEMPT_STATES = [
279
+ "succeeded",
280
+ "retryable_failure",
281
+ "terminal_failure",
282
+ "ambiguous",
283
+ ];
284
+ export const attemptStateSchema = z.enum(ATTEMPT_STATES);
285
+ /** Safe diagnostic codes produced by the W3-03 delivery classifier. */
286
+ export const attemptDiagnosticSchema = z.enum(CALLBACK_ATTEMPT_DIAGNOSTICS);
287
+ /**
288
+ * Bounded next-action hints for an attempt timeline.
289
+ *
290
+ * These are codes rather than prose so a client can render its own guidance
291
+ * without ever receiving an executor exception, destination, response body,
292
+ * payload, or secret. `review_retry_policy` names the historical category
293
+ * without promising that the occurrence still has automatic work to do.
294
+ */
295
+ export const ATTEMPT_REMEDIATIONS = [
296
+ "none",
297
+ "review_retry_policy",
298
+ "inspect_receiver",
299
+ "verify_endpoint",
300
+ "replace_payload",
301
+ "contact_support",
302
+ "review_before_redelivery",
303
+ ];
304
+ /**
305
+ * How much of one retained response header is published, in characters.
306
+ *
307
+ * The sender caps it at 256 **bytes**, and this is the schema's own bound on
308
+ * what may be presented — deliberately the looser of the two, because a schema
309
+ * that refused what the sender legitimately stored would make a delivery
310
+ * unreadable rather than a header untidy.
311
+ */
312
+ export const ATTEMPT_RESPONSE_HEADER_MAX_LENGTH = 256;
313
+ /** How much of a failed receiver's body is published, in characters. */
314
+ export const ATTEMPT_FAILURE_SNIPPET_MAX_LENGTH = 4_096;
315
+ export const attemptRemediationSchema = z.enum(ATTEMPT_REMEDIATIONS);
316
+ /** Derive the one safe next-action code a public attempt may expose. */
317
+ export function remediationForAttempt(diagnostic) {
318
+ switch (diagnostic) {
319
+ case "delivered":
320
+ return "none";
321
+ case "endpoint_unavailable":
322
+ case "endpoint_unsafe":
323
+ return "verify_endpoint";
324
+ case "http_refused":
325
+ case "http_terminal":
326
+ // The same advice for two different futures, deliberately. Both mean the
327
+ // receiver answered and declined, so the receiver is what to look at;
328
+ // what differs is whether the occurrence still has attempts left, which
329
+ // this immutable row must not claim to know — see `http_retryable` below.
330
+ return "inspect_receiver";
331
+ case "identity_conflict":
332
+ return "contact_support";
333
+ case "payload_unavailable":
334
+ return "replace_payload";
335
+ case "completion_timed_out":
336
+ case "duplicate_inflight":
337
+ case "network_ambiguous":
338
+ // A receiver that acknowledged the work and then said nothing is exactly
339
+ // the ambiguous case, and the advice is the same one: it may have run.
340
+ // Anything more decisive would be this row claiming to know something no
341
+ // part of this system observed.
342
+ return "review_before_redelivery";
343
+ case "http_retryable":
344
+ case "rate_limited":
345
+ // This describes what the immutable diagnostic asks an operator to
346
+ // review, not what the occurrence will do next. The latter can change
347
+ // later through retry, give-up, cancellation, or redelivery; deriving
348
+ // from it would make this earlier attempt's representation mutable.
349
+ return "review_retry_policy";
350
+ }
351
+ }
352
+ /**
353
+ * Why this immutable attempt exists.
354
+ *
355
+ * Automatic work has no causal attempt. A manual redelivery names the
356
+ * retained attempt the caller chose to follow, which preserves both identities
357
+ * and makes the new row an honest link instead of a rewrite of the old one.
358
+ *
359
+ * **Deliberately the same schema the signed callback envelope carries**, rather
360
+ * than a second declaration of the same shape. From W3-08 the attempt's trigger
361
+ * is what reaches the receiver, so a reader of the timeline and a receiver
362
+ * branching on the wire are looking at one fact. Two definitions would drift,
363
+ * and the failure mode of that drift is a receiver that re-runs work for a
364
+ * cause the history says never happened.
365
+ */
366
+ export const attemptTriggerSchema = callbackTriggerSchema;
367
+ /**
368
+ * The largest elapsed time JSON can carry without losing integer precision.
369
+ *
370
+ * Runtime timeout policies are ordinarily much smaller, but their configured
371
+ * ceiling is not a public invariant. Bounding the wire at the safe-integer
372
+ * edge keeps corrupt or non-finite timing out without clamping honest durable
373
+ * evidence into a value the executor did not report.
374
+ */
375
+ export const ATTEMPT_DURATION_MAX_MS = Number.MAX_SAFE_INTEGER;
376
+ /** The attempt state a classified response produces. */
377
+ export function attemptStateForResponse(responseClass) {
378
+ switch (responseClass) {
379
+ case "success":
380
+ return "succeeded";
381
+ case "retryable":
382
+ return "retryable_failure";
383
+ case "terminal":
384
+ return "terminal_failure";
385
+ case "ambiguous":
386
+ return "ambiguous";
387
+ }
388
+ }
389
+ /* -------------------------------------------------------------------------- */
390
+ /* Bounds */
391
+ /* -------------------------------------------------------------------------- */
392
+ /**
393
+ * The runtime's default bounds, in one place.
394
+ *
395
+ * **These are engineering defaults, not plan limits.** They exist because a
396
+ * partition has to bound its own growth to stay affordable and predictable, and
397
+ * every one of them is enforced by the storage layer today. What they are *not*
398
+ * is a commercial decision: `docs/product/vision-and-strategy.md` section 11
399
+ * names retained history, payload size, active schedules, realtime rate,
400
+ * endpoints and forecast horizon as **plan limits**, and puts limits and
401
+ * retention behind the W7 commercial gate. W7 sets the per-plan numbers; these
402
+ * are what a partition enforces until it does, and the partition store takes
403
+ * them as an argument precisely so a plan can supply its own.
404
+ *
405
+ * Durations are whole seconds, per `docs/contracts/v1-conventions.md`.
406
+ */
407
+ export const SCHEDULING_BOUNDS = {
408
+ /**
409
+ * Attempts made against one occurrence before it is terminal, including the
410
+ * first. Bounded so a permanently broken receiver cannot make one occurrence
411
+ * cost an unbounded amount of work.
412
+ */
413
+ attemptsPerOccurrence: 8,
414
+ /**
415
+ * The longest fixed interval a recurring schedule may ask for, in seconds.
416
+ *
417
+ * Thirty days, which is the copied scheduler's `MAX_INTERVAL_SECONDS` kept
418
+ * rather than a new number invented beside it, and its reason is the
419
+ * platform's rather than the product's: a Durable Object alarm is not
420
+ * arbitrarily far in the future.
421
+ */
422
+ everySecondsMax: 30 * 24 * 60 * 60,
423
+ /**
424
+ * The shortest fixed interval a recurring schedule may ask for, in seconds.
425
+ *
426
+ * One minute, and this one is **not** inherited: the copy admits a one-second
427
+ * interval, which is 86,400 alarm wakes a day for a single schedule. A minute
428
+ * is also the finest instant a five-field cron expression can name, so both
429
+ * ways of expressing a recurrence promise the same granularity rather than
430
+ * one of them quietly promising more. Widening this later is additive by
431
+ * `classifyChange` and narrowing it is breaking, which is the direction to be
432
+ * conservative in.
433
+ */
434
+ everySecondsMin: 60,
435
+ /**
436
+ * How long an idempotency receipt is honoured. A client retrying a create for
437
+ * longer than a day is not retrying, it is making a new request; keeping
438
+ * receipts beyond that turns the partition into an unbounded key/value store.
439
+ */
440
+ idempotencyReceiptSeconds: 24 * 60 * 60,
441
+ /**
442
+ * Concrete occurrences materialized **ahead of time** for one schedule.
443
+ *
444
+ * One, which is architecture section 15's default: a series is written one
445
+ * occurrence at a time rather than as a horizon, because a horizon multiplies
446
+ * row count by its own length and makes a replace rewrite all of it.
447
+ *
448
+ * **What it bounds is lead time, not live rows, and W2-04 settled that.**
449
+ * Read as "one non-terminal occurrence" it would forbid architecture section
450
+ * 6's own fire flow, which marks the current occurrence `due` and plans its
451
+ * successor in one transaction and therefore holds two. Read as this wording
452
+ * already says — materialized *ahead of time* — it means a schedule holds at
453
+ * most one `planned` occurrence whose instant has not yet arrived, which
454
+ * permits that transaction, permits an advance and a serve to land in either
455
+ * order within one wake, and still forbids the horizon it exists to forbid.
456
+ *
457
+ * Enforced by `PartitionStore.insertOccurrence`, where occurrences are
458
+ * planned, rather than by a unique index — an index cannot reference the
459
+ * present, and the present is what "ahead of time" is measured against.
460
+ */
461
+ materializedOccurrencesPerSchedule: 1,
462
+ /**
463
+ * How long occurrence and attempt history is retained before it is swept.
464
+ * Long enough to explain last month's invoice, short enough that a busy
465
+ * partition's history does not outgrow its schedules.
466
+ */
467
+ occurrenceHistorySeconds: 30 * 24 * 60 * 60,
468
+ /**
469
+ * How far ahead a one-shot may be asked for as a *delay*, in seconds — about
470
+ * five years, which is the copied scheduler's `MAX_ONESHOT_SECONDS` kept
471
+ * rather than a new number invented beside it.
472
+ *
473
+ * It bounds `delaySeconds` and deliberately does not bound `at`. Narrowing a
474
+ * request field that is already published is breaking by `classifyChange`,
475
+ * and `at` shipped with W1-08; a horizon on both belongs with the plan limits
476
+ * W7 owns, which is where a customer-visible ceiling can be set once for
477
+ * every way of expressing the same instant.
478
+ */
479
+ oneShotDelaySeconds: 158_000_000,
480
+ /**
481
+ * The customer payload carried to the callback, in bytes, measured on the
482
+ * UTF-8 encoding of the JSON the caller sent. 64 KiB comfortably fits the
483
+ * command-shaped payloads this product is for, and refuses the ones that are
484
+ * really a file transfer.
485
+ */
486
+ payloadBytes: 65_536,
487
+ /**
488
+ * How soon a one-shot has to be for `realtime` to be the class it gets by
489
+ * default, in seconds.
490
+ *
491
+ * Two minutes, which is the copied `validate.ts`'s own threshold kept rather
492
+ * than a number invented beside it: upstream classifies an `at` or a `delay`
493
+ * inside 120 seconds as realtime and everything else as bulk, on the
494
+ * reasoning that a wait that short is a human waiting on a screen. A caller
495
+ * who disagrees says so — `latencyClass` on the request is what makes this a
496
+ * default rather than a rule.
497
+ */
498
+ realtimeOneShotSeconds: 120,
499
+ };
500
+ /* -------------------------------------------------------------------------- */
501
+ /* Recurrence */
502
+ /* -------------------------------------------------------------------------- */
503
+ /**
504
+ * The longest cron expression this surface accepts, in characters.
505
+ *
506
+ * A five-field expression naming every value it could name is well under a
507
+ * hundred characters, so this refuses a field list built to make a parser work
508
+ * hard without refusing anything a human would write.
509
+ */
510
+ export const CRON_EXPRESSION_MAX_LENGTH = 120;
511
+ /**
512
+ * A cron expression, checked for shape and deliberately not for meaning.
513
+ *
514
+ * **What a schema can honestly promise here is the interesting part.** Whether
515
+ * an expression parses, whether its fields are in range, and whether it names
516
+ * an instant that ever arrives are all decisions belonging to the engine that
517
+ * resolves it — the partition, which owns timer authority and is the one place
518
+ * `cron-schedule` is a dependency. Encoding them here would mean either a
519
+ * second parser in this package or a JSON Schema pretending to a precision it
520
+ * does not have, and a published schema that lies about what it validates is
521
+ * worse than one that says less. So this bounds the length and the field count
522
+ * and stops; a request that passes it and cannot be resolved is refused by the
523
+ * partition as `invalid_request`, naming which part it could not resolve.
524
+ *
525
+ * Five fields, or the `@`-prefixed nicknames every cron understands. Six-field
526
+ * forms are not refused here — a six-field expression whose seconds field is
527
+ * zero says the same thing as its five-field spelling — but one that asks to
528
+ * fire on a second boundary is refused by the partition, because a minute is
529
+ * the granularity both arms of recurrence promise.
530
+ */
531
+ export const cronExpressionSchema = z
532
+ .string()
533
+ .min(1, "must not be empty")
534
+ .max(CRON_EXPRESSION_MAX_LENGTH, `must be at most ${String(CRON_EXPRESSION_MAX_LENGTH)} characters`)
535
+ .refine((expression) => expression.startsWith("@") ||
536
+ [5, 6].includes(expression.trim().split(/\s+/u).length), { message: "must be five cron fields, six fields, or an @-nickname" });
537
+ /**
538
+ * How a schedule recurs, as a caller reads it back.
539
+ *
540
+ * A discriminated union rather than three optional fields, because "which of
541
+ * these is set" is a question a client should not have to answer by
542
+ * elimination — and because a one-shot is a recurrence of one rather than an
543
+ * absence of recurrence, which is what lets every consumer `switch`
544
+ * exhaustively and be told by its compiler when a fourth kind arrives.
545
+ *
546
+ * `timeZone` is present on the `cron` arm and nowhere else, which is the
547
+ * conventions' rule made structural: a timezone means something alongside a
548
+ * wall-clock recurrence and nothing alongside an instant, where it would only
549
+ * invite a caller to believe an instant had been reinterpreted. It is required
550
+ * on that arm rather than optional, so a read always says which wall clock a
551
+ * cron schedule is on instead of leaving the caller to assume UTC.
552
+ */
553
+ export const recurrenceSchema = z.discriminatedUnion("kind", [
554
+ /**
555
+ * A one-shot, which carries no instant of its own.
556
+ *
557
+ * The instant is the occurrence's `plannedFor` and is stated once, there —
558
+ * repeating it here would invite the two to disagree after a replace, and the
559
+ * question this field answers is "does this fire again", not "when". A
560
+ * listing, which carries no occurrence, is a summary by design; detail is one
561
+ * call away and is where the occurrence is authoritative.
562
+ */
563
+ z.object({ kind: z.literal("at") }),
564
+ z.object({
565
+ everySeconds: durationSecondsSchema
566
+ .min(SCHEDULING_BOUNDS.everySecondsMin, `must be at least ${String(SCHEDULING_BOUNDS.everySecondsMin)} seconds`)
567
+ .max(SCHEDULING_BOUNDS.everySecondsMax, `must be at most ${String(SCHEDULING_BOUNDS.everySecondsMax)} seconds`),
568
+ kind: z.literal("every"),
569
+ }),
570
+ z.object({
571
+ cron: cronExpressionSchema,
572
+ kind: z.literal("cron"),
573
+ timeZone: timeZoneSchema,
574
+ }),
575
+ ]);
576
+ /* -------------------------------------------------------------------------- */
577
+ /* Public resources */
578
+ /* -------------------------------------------------------------------------- */
579
+ /**
580
+ * A schedule, as a client sees it.
581
+ *
582
+ * A callback schedule exposes the reusable endpoint it references, but never
583
+ * the payload stored for that endpoint and never its signing secret. The
584
+ * endpoint id is useful definition metadata; the encrypted payload is content
585
+ * and stays only in the authoritative partition.
586
+ */
587
+ export const scheduleSchema = z.object({
588
+ /**
589
+ * When the schedule was filed away, and absent until it is.
590
+ *
591
+ * A timestamp rather than a `status` member, for two independent reasons.
592
+ * Adding an enum member a client must *receive* is breaking by
593
+ * `classifyChange`, and archiving is orthogonal to the lifecycle anyway: a
594
+ * schedule is archived *because* it is `cancelled` or `completed`, and
595
+ * folding the two would lose which of those it was. It is set only from a
596
+ * terminal status — archiving is filing, not stopping.
597
+ */
598
+ archivedAt: timestampSchema.optional(),
599
+ createdAt: timestampSchema,
600
+ /**
601
+ * The delivery settings in force, with the defaults filled in.
602
+ *
603
+ * **Present on every callback schedule rather than only on one that asked**,
604
+ * which is a deliberate departure from how `partitionKey` and `endpoint` read
605
+ * back. Those are absent when unset because absent is the truth about them.
606
+ * These three always have a value — five seconds, answer the request, skip an
607
+ * overlapping run — and a schedule that reported nothing would leave a caller
608
+ * to hardcode the defaults in order to display them, which is the shape of
609
+ * duplication that goes stale the first time a default moves.
610
+ *
611
+ * Absent on a timer-only schedule, which delivers to nothing.
612
+ *
613
+ * **`maxDurationSeconds` reads back as a plain duration, not as the ladder the
614
+ * request accepts**, and the asymmetry is the point. Narrowing a request field
615
+ * is breaking and widening it is additive, so the request takes the closed
616
+ * ladder; a *response* field is the other way round — adding a value a client
617
+ * must receive is what breaks it — so the response promises only "a number of
618
+ * seconds" and a rung added later costs no contract break.
619
+ */
620
+ delivery: z
621
+ .object({
622
+ completion: deliveryCompletionModeSchema,
623
+ maxDurationSeconds: durationSecondsSchema,
624
+ onOverlap: deliveryOverlapPolicySchema,
625
+ })
626
+ .optional(),
627
+ /** The verified reusable callback endpoint this schedule delivers to. */
628
+ endpoint: endpointIdSchema.optional(),
629
+ generation: generationSchema,
630
+ id: scheduleIdSchema,
631
+ latencyClass: latencyClassSchema,
632
+ partitionKey: partitionKeySchema.optional(),
633
+ /**
634
+ * How this schedule recurs, in the vocabulary it will keep being read in.
635
+ *
636
+ * Required rather than optional, which is additive by `classifyChange`
637
+ * (`required` on a *response* field is what a client may now rely on
638
+ * receiving, not a new obligation on it). A schedule that could omit it would
639
+ * make "does this fire again?" unanswerable from a read, and that is the
640
+ * question a caller has immediately after creating one.
641
+ *
642
+ * A schedule created as `delaySeconds` reports `{ kind: "at" }`: the delay
643
+ * was converted to an instant once, at acceptance, and reporting the delay
644
+ * back would describe a schedule whose next fire moves every time it is read.
645
+ */
646
+ recurrence: recurrenceSchema,
647
+ status: scheduleStatusSchema,
648
+ updatedAt: timestampSchema,
649
+ version: versionSchema,
650
+ });
651
+ /**
652
+ * A page of schedules.
653
+ *
654
+ * Items are schedules rather than schedule *views*. A cross-partition listing is
655
+ * served from the D1 projection W2-06 built, and that projection holds a route
656
+ * and a summary rather than every partition's live occurrence — so a page that
657
+ * promised an occurrence would either lie about its freshness or force the
658
+ * listing back through every partition it spans. Detail is where the occurrence
659
+ * is authoritative, and detail is one call away.
660
+ */
661
+ export const schedulePageSchema = pageSchema(scheduleSchema);
662
+ /**
663
+ * One concrete occurrence of a schedule.
664
+ *
665
+ * `sequence` is the occurrence's position in its series, starting at 1, and is
666
+ * scoped to a generation: replacing a definition starts a new generation whose
667
+ * numbering starts again. Together `(schedule, generation, sequence)` is what
668
+ * makes an occurrence identifiable without depending on when it was written.
669
+ */
670
+ export const occurrenceSchema = z
671
+ .object({
672
+ attemptCount: z.number().int().nonnegative(),
673
+ generation: generationSchema,
674
+ id: occurrenceIdSchema,
675
+ /** When the occurrence was planned to fire. Never earlier than this. */
676
+ plannedFor: timestampSchema,
677
+ schedule: scheduleIdSchema,
678
+ sequence: z.number().int().positive(),
679
+ /** Present exactly when the occurrence is in a terminal state. */
680
+ settledAt: timestampSchema.optional(),
681
+ state: occurrenceStateSchema,
682
+ })
683
+ // Settlement and terminality are one fact, so the wire must not be able to
684
+ // express them separately. The partition enforces the same pairing, and a
685
+ // document the storage layer could never hold is a document no client should
686
+ // ever be handed: "succeeded, settled at — unknown" is not a state anything
687
+ // can explain.
688
+ .refine((occurrence) => isTerminalOccurrenceState(occurrence.state) ===
689
+ (occurrence.settledAt !== undefined), {
690
+ message: "settledAt must be present for a terminal state and absent otherwise",
691
+ path: ["settledAt"],
692
+ });
693
+ /**
694
+ * One delivery attempt against one occurrence.
695
+ *
696
+ * `delivery` is the identifier the callback signature covers and the receiver
697
+ * dedupes on, so it is per-attempt rather than per-occurrence: a retry is a new
698
+ * delivery, and a receiver that saw the first one must be able to tell.
699
+ *
700
+ * `state` is absent while the attempt is in flight. That is the ambiguity the
701
+ * watchdog resolves, and modelling it as absent rather than defaulting it to a
702
+ * failure is what stops an in-flight attempt from being counted as one that
703
+ * came back.
704
+ */
705
+ /**
706
+ * Bounded, non-secret evidence about the response a receiver sent.
707
+ *
708
+ * **A closed allowlist rather than "the headers".** A response header is
709
+ * receiver-controlled text that lands in durable history and on a support
710
+ * screen, so the question is not "is this one dangerous" but "does this one
711
+ * earn its place": the media type explains what the snippet is, `Retry-After`
712
+ * explains a pacing decision the customer can see, and one receiver-side
713
+ * identifier turns "your receiver refused this" into something an integrator
714
+ * can look up in their own logs. Cookies, `Authorization` and
715
+ * `WWW-Authenticate` have no representation here at all.
716
+ */
717
+ export const attemptResponseHeaderSchema = z.object({
718
+ /** Lowercase, from the published allowlist. */
719
+ name: z.string().min(1).max(64),
720
+ value: z.string().max(ATTEMPT_RESPONSE_HEADER_MAX_LENGTH),
721
+ });
722
+ /**
723
+ * What a failing receiver said, bounded and access-controlled.
724
+ *
725
+ * Present only on a **failed** attempt whose response carried a text or JSON
726
+ * media type, and only where the plan retains one. A successful response body
727
+ * is never retained, and neither is a pending acknowledgement's: the customer
728
+ * content this product keeps is the content that helps somebody fix a broken
729
+ * receiver, and nothing else.
730
+ *
731
+ * `truncated` is carried rather than inferred from the length, because a body
732
+ * that is exactly the bound and a body that was cut at the bound are different
733
+ * facts and only one of them should show a customer an ellipsis.
734
+ */
735
+ export const attemptFailureSnippetSchema = z.object({
736
+ /** The media type it was captured from. Not customer content. */
737
+ contentType: z.string().min(1).max(128),
738
+ text: z.string().max(ATTEMPT_FAILURE_SNIPPET_MAX_LENGTH),
739
+ truncated: z.boolean(),
740
+ });
741
+ export const attemptSchema = z
742
+ .object({
743
+ attemptNumber: z.number().int().positive(),
744
+ /** Present exactly when the attempt has been classified. */
745
+ classifiedAt: timestampSchema.optional(),
746
+ delivery: deliveryIdSchema,
747
+ /** Safe classifier code; absent when durable evidence predates diagnostics. */
748
+ diagnostic: attemptDiagnosticSchema.optional(),
749
+ /** Executor-observed elapsed time; absent when no durable timing survived. */
750
+ durationMs: z
751
+ .number()
752
+ .int()
753
+ .nonnegative()
754
+ .max(ATTEMPT_DURATION_MAX_MS)
755
+ .optional(),
756
+ id: attemptIdSchema,
757
+ occurrence: occurrenceIdSchema,
758
+ /** Stable next-action code paired with a diagnostic when one is known. */
759
+ remediation: attemptRemediationSchema.optional(),
760
+ /**
761
+ * What a failing receiver said, where the plan retains it.
762
+ *
763
+ * Absent on a success, on an in-flight attempt, on a response whose media
764
+ * type is not text or JSON, and on a plan whose retention does not include
765
+ * one. Its absence is never evidence about the delivery.
766
+ */
767
+ failureSnippet: attemptFailureSnippetSchema.optional(),
768
+ /** The fixed diagnostic-header allowlist, when a response carried any. */
769
+ responseHeaders: z.array(attemptResponseHeaderSchema).max(8).optional(),
770
+ /** Absent when no response was received at all. */
771
+ responseStatus: z.number().int().min(100).max(599).optional(),
772
+ startedAt: timestampSchema,
773
+ state: attemptStateSchema.optional(),
774
+ trigger: attemptTriggerSchema,
775
+ })
776
+ // Classification and its timestamp arrive together or not at all — the same
777
+ // pairing the partition's CHECK constraint enforces. Independently optional
778
+ // fields would let an adapter publish a classified attempt with no
779
+ // classification time, which validates here and is unstorable there.
780
+ .refine((attempt) => (attempt.state === undefined) === (attempt.classifiedAt === undefined), {
781
+ message: "state and classifiedAt must both be present or both absent",
782
+ path: ["classifiedAt"],
783
+ })
784
+ // A diagnostic and the safe action derived from it are one public fact. Old
785
+ // retained attempts may have neither; publishing only one would make the
786
+ // other look accidentally lost and tempt a client to infer it.
787
+ .refine((attempt) => (attempt.diagnostic === undefined) ===
788
+ (attempt.remediation === undefined), {
789
+ message: "diagnostic and remediation must both be present or both absent",
790
+ path: ["remediation"],
791
+ })
792
+ // An in-flight attempt has no classified delivery evidence. In particular,
793
+ // a response status may not be presented early and then contradicted by the
794
+ // immutable outcome that lands later — and neither may the response headers
795
+ // or a failure snippet, which is the same rule one field wider since W8-11.
796
+ .refine((attempt) => attempt.state !== undefined ||
797
+ (attempt.diagnostic === undefined &&
798
+ attempt.durationMs === undefined &&
799
+ attempt.failureSnippet === undefined &&
800
+ attempt.remediation === undefined &&
801
+ attempt.responseHeaders === undefined &&
802
+ attempt.responseStatus === undefined), {
803
+ message: "classification metadata must be absent while an attempt is in flight",
804
+ path: ["state"],
805
+ });
806
+ /** An authoritative page over one schedule's retained occurrences. */
807
+ export const occurrencePageSchema = pageSchema(occurrenceSchema).extend({
808
+ freshness: z.object({ kind: z.literal("authoritative") }),
809
+ });
810
+ /** An authoritative occurrence detail response. */
811
+ export const occurrenceViewSchema = z.object({
812
+ freshness: z.object({ kind: z.literal("authoritative") }),
813
+ occurrence: occurrenceSchema,
814
+ });
815
+ /** An authoritative page over one retained occurrence's immutable attempts. */
816
+ export const attemptPageSchema = pageSchema(attemptSchema).extend({
817
+ freshness: z.object({ kind: z.literal("authoritative") }),
818
+ });
819
+ /** An authoritative attempt detail or redelivery response. */
820
+ export const attemptViewSchema = z.object({
821
+ attempt: attemptSchema,
822
+ freshness: z.object({ kind: z.literal("authoritative") }),
823
+ });
824
+ /**
825
+ * A schedule together with the occurrence that is next to happen.
826
+ *
827
+ * One document rather than two calls, because "when does this next fire" is the
828
+ * question every caller has immediately after creating a schedule, and a client
829
+ * that has to make a second request to answer it will cache the first answer and
830
+ * be wrong.
831
+ */
832
+ export const scheduleViewSchema = z.object({
833
+ occurrence: occurrenceSchema,
834
+ schedule: scheduleSchema,
835
+ });
836
+ /**
837
+ * The four ways a caller may say when a schedule happens.
838
+ *
839
+ * Exported so a caller enumerates them rather than writing the list out and
840
+ * quietly missing one when a fifth arrives.
841
+ */
842
+ export const RECURRENCE_REQUEST_FIELDS = [
843
+ "at",
844
+ "cron",
845
+ "delaySeconds",
846
+ "everySeconds",
847
+ ];
848
+ /**
849
+ * What a caller sends to create or replace a schedule.
850
+ *
851
+ * Exactly one of `at`, `delaySeconds`, `everySeconds` and `cron`. A callback
852
+ * schedule references a reusable endpoint and may carry any JSON value as its
853
+ * data. Nothing here names an organization, project or environment: those come
854
+ * from the verified principal, and the endpoint lookup is bound to that same
855
+ * tuple. The idempotency key is not here either — it is a header, because a
856
+ * retry of the *same* request must be byte-identical, and a key inside the body
857
+ * would make every retry a different request from the one it is retrying.
858
+ *
859
+ * **All fields optional, with refinements, rather than a union.** A union of
860
+ * object schemas accepts a body carrying two arms' fields, since each arm
861
+ * strips what it does not know; the copied validator refuses that as
862
+ * `ambiguous_schedule`, and a scheduler that silently picks one of two instants
863
+ * a customer named is the worst kind of helpful.
864
+ */
865
+ export const scheduleRequestSchema = z
866
+ .object({
867
+ /** The instant the first occurrence is planned for. */
868
+ at: timestampSchema.optional(),
869
+ /**
870
+ * A cron expression the schedule recurs on, read against `timeZone`.
871
+ *
872
+ * The first occurrence is the first instant the expression names after the
873
+ * request is accepted, so a caller does not give both an expression and a
874
+ * starting point — the expression already is one.
875
+ */
876
+ cron: cronExpressionSchema.optional(),
877
+ /**
878
+ * Customer data placed in the signed callback envelope.
879
+ *
880
+ * Any JSON value is accepted, including `null`, arrays and scalars. It is
881
+ * never returned by schedule reads or listings. The accepting adapter
882
+ * serializes it once, enforces `SCHEDULING_BOUNDS.payloadBytes` against
883
+ * those UTF-8 bytes, then encrypts those same bytes before persistence.
884
+ */
885
+ data: z.json().optional(),
886
+ /**
887
+ * How long from acceptance the first occurrence is planned for. Converted
888
+ * to an absolute instant once, **in the adapter that accepts the request**,
889
+ * against the clock that request arrived on — never in the partition, which
890
+ * is reached again by every retry and every replay and would convert a
891
+ * second time. A schedule created as a delay and one created as an instant
892
+ * are the same schedule afterwards, and neither drifts when it is read
893
+ * again.
894
+ */
895
+ delaySeconds: durationSecondsSchema
896
+ .positive("must be at least 1 second")
897
+ .max(SCHEDULING_BOUNDS.oneShotDelaySeconds, `must be at most ${String(SCHEDULING_BOUNDS.oneShotDelaySeconds)} seconds`)
898
+ .optional(),
899
+ /**
900
+ * How long the receiver may take, and what happens while it is taking it.
901
+ *
902
+ * Omitted is the ordinary case and the one this product is designed around:
903
+ * a reminder webhook answers in well under a second and needs none of this.
904
+ *
905
+ * **Grouped rather than three sibling fields**, because two of the three are
906
+ * meaningless without the first. `onOverlap` only answers a question a long
907
+ * run can ask, and a caller who set it beside a five-second delivery would
908
+ * be configuring something that cannot happen. Nesting makes the dependency
909
+ * visible in the shape instead of only in a refusal message.
910
+ */
911
+ delivery: z
912
+ .object({
913
+ /**
914
+ * What to do when this schedule comes due and its previous occurrence
915
+ * has not finished. Only meaningful on a recurring, long-running
916
+ * schedule; refused elsewhere rather than ignored.
917
+ */
918
+ onOverlap: deliveryOverlapPolicySchema.optional(),
919
+ /**
920
+ * How the receiver signals it is finished — by answering the request,
921
+ * or by calling the completion URL in the envelope.
922
+ */
923
+ completion: deliveryCompletionModeSchema.optional(),
924
+ /**
925
+ * How long the receiver may take to answer, in seconds. One of
926
+ * `DELIVERY_DURATION_TIERS`.
927
+ */
928
+ maxDurationSeconds: deliveryDurationSecondsSchema.optional(),
929
+ })
930
+ .strict()
931
+ .optional(),
932
+ /**
933
+ * A fixed interval the schedule recurs on, in seconds. The first occurrence
934
+ * falls one interval after acceptance.
935
+ *
936
+ * A duration and not a wall clock: `86400` is twenty-four hours and stays
937
+ * twenty-four hours across a daylight-saving transition, which is why it
938
+ * takes no `timeZone`. A caller who means "every day at nine" is naming a
939
+ * wall clock and wants `cron`.
940
+ */
941
+ everySeconds: durationSecondsSchema
942
+ .min(SCHEDULING_BOUNDS.everySecondsMin, `must be at least ${String(SCHEDULING_BOUNDS.everySecondsMin)} seconds`)
943
+ .max(SCHEDULING_BOUNDS.everySecondsMax, `must be at most ${String(SCHEDULING_BOUNDS.everySecondsMax)} seconds`)
944
+ .optional(),
945
+ /**
946
+ * A verified reusable callback endpoint owned by this request's authority.
947
+ *
948
+ * Optional so timer-only schedules remain valid. On replace, omission
949
+ * preserves the existing endpoint; sending `data` without this field is
950
+ * valid only when the stored schedule already has an endpoint.
951
+ */
952
+ endpoint: endpointIdSchema.optional(),
953
+ /**
954
+ * How promptly this schedule's occurrences expect to be served.
955
+ *
956
+ * Optional, and omitting it is the ordinary case: the default is derived
957
+ * from how the schedule recurs (`deriveLatencyClass`). Stating it is for
958
+ * the caller whose nightly cron really is a human waiting, or whose
959
+ * thirty-second one-shot really is a campaign — the derivation is a good
960
+ * guess about intent and cannot be a correct one.
961
+ *
962
+ * It buys preference, not a promise. `realtime` is served ahead of `bulk`
963
+ * within the same partition; it does not raise the partition's rate, so a
964
+ * caller who marks everything `realtime` has re-labelled their work rather
965
+ * than obtained more of the service.
966
+ */
967
+ latencyClass: latencyClassSchema.optional(),
968
+ /**
969
+ * A fairness lane within the caller's own project and environment.
970
+ *
971
+ * Two schedules with different keys are paced independently, so one key's
972
+ * herd does not delay another's work. It is a label and a lane and nothing
973
+ * more: it cannot widen the authority tuple, cannot select a different
974
+ * database shard or timer actor, and cannot raise anybody's rate — the
975
+ * lanes share the project's ceiling, so minting more keys divides the same
976
+ * budget rather than multiplying it.
977
+ *
978
+ * **Fixed once the schedule exists.** A replace may carry the key it read
979
+ * back or omit it to preserve the stored value. Adding, changing, or
980
+ * clearing it is refused as `invalid_request` with `/partitionKey` named in
981
+ * the field errors. Widening this later is additive and narrowing it later
982
+ * is breaking, so a lane that cannot move is the direction to ship in until
983
+ * somebody asks otherwise.
984
+ */
985
+ partitionKey: partitionKeySchema.optional(),
986
+ /**
987
+ * The IANA timezone `cron` is read against. `UTC` when omitted.
988
+ *
989
+ * Accepted only alongside `cron`. Sent with an instant or an interval it
990
+ * would be a caller believing something had been reinterpreted that had
991
+ * not, so it is refused rather than ignored — an ignored field is a
992
+ * misunderstanding a customer cannot see.
993
+ */
994
+ timeZone: timeZoneSchema.optional(),
995
+ })
996
+ // Ordinary creation and replacement mint every authority and record
997
+ // identity on the service side. Stripping an unknown `id` or `authority`
998
+ // would make a caller believe it selected something it did not, so the body
999
+ // is closed rather than permissive.
1000
+ .strict()
1001
+ // `superRefine` rather than two `refine`s, because a pointer is the whole
1002
+ // value of this refusal to a caller and a fixed one stopped being honest when
1003
+ // a third and fourth way of saying "when" arrived. A body naming two of them
1004
+ // now points at **both**, so a dashboard highlights the pair that conflict
1005
+ // rather than one field chosen alphabetically; a body naming none points at
1006
+ // `at`, because a pointer has to name something a document could contain and
1007
+ // the message names the rest. The published `pointer` must start with `/`, so
1008
+ // "the document as a whole" is not a value this surface can express.
1009
+ .superRefine((request, context) => {
1010
+ const supplied = RECURRENCE_REQUEST_FIELDS.filter((field) => request[field] !== undefined);
1011
+ if (supplied.length !== 1) {
1012
+ for (const path of supplied.length === 0 ? ["at"] : supplied) {
1013
+ context.addIssue({
1014
+ code: "custom",
1015
+ message: "give exactly one of `at`, `delaySeconds`, `everySeconds` and `cron`",
1016
+ path: [path],
1017
+ });
1018
+ }
1019
+ }
1020
+ if (request.timeZone !== undefined && request.cron === undefined) {
1021
+ context.addIssue({
1022
+ code: "custom",
1023
+ message: "`timeZone` is only meaningful with `cron`",
1024
+ path: ["timeZone"],
1025
+ });
1026
+ }
1027
+ if (request.delivery?.onOverlap !== undefined) {
1028
+ // A one-shot has nothing to overlap with. Refused rather than ignored, for
1029
+ // the reason `timeZone` is: an ignored field is a misunderstanding the
1030
+ // customer cannot see, and this one would look like a guarantee.
1031
+ if (request.cron === undefined && request.everySeconds === undefined) {
1032
+ context.addIssue({
1033
+ code: "custom",
1034
+ message: "`onOverlap` is only meaningful with `cron` or `everySeconds`",
1035
+ path: ["delivery", "onOverlap"],
1036
+ });
1037
+ }
1038
+ // Only checked when the duration is in *this* request. A replace may carry
1039
+ // `onOverlap` while the stored schedule holds the duration, exactly as it
1040
+ // may carry `data` while the stored schedule holds the endpoint, and the
1041
+ // partition is where a request is resolved against what is stored.
1042
+ const duration = request.delivery.maxDurationSeconds;
1043
+ if (duration !== undefined && !isLongRunningDuration(duration)) {
1044
+ context.addIssue({
1045
+ code: "custom",
1046
+ message: `\`onOverlap\` is only meaningful above ${String(FREE_DELIVERY_DURATION_SECONDS)} seconds, where a run can still be going when the next one is due`,
1047
+ path: ["delivery", "onOverlap"],
1048
+ });
1049
+ }
1050
+ }
1051
+ // Held open past the point a held-open request survives. Checked against the
1052
+ // *derived* pair rather than against what the body names, because
1053
+ // `completion` defaults to `response`: a caller who asks for twelve hours and
1054
+ // says nothing about the mechanism has asked for a twelve-hour held socket
1055
+ // whether or not the word appears in their document. Pointing at
1056
+ // `delivery.completion` therefore names a field the body may not contain,
1057
+ // which is the same choice the recurrence refusal above makes and for the
1058
+ // same reason — a pointer has to name what the caller must change.
1059
+ if (request.delivery?.maxDurationSeconds !== undefined &&
1060
+ request.delivery.maxDurationSeconds >
1061
+ RESPONSE_DELIVERY_MAX_DURATION_SECONDS &&
1062
+ (request.delivery.completion ?? "response") === "response") {
1063
+ context.addIssue({
1064
+ code: "custom",
1065
+ message: `a receiver may hold the request open for at most ${String(RESPONSE_DELIVERY_MAX_DURATION_SECONDS)} seconds; above that set \`completion\` to \`callback\` and call the completion URL in the envelope when the work is done`,
1066
+ path: ["delivery", "completion"],
1067
+ });
1068
+ }
1069
+ });
1070
+ /**
1071
+ * The delivery settings a request runs under: the caller's, or the defaults.
1072
+ *
1073
+ * Here rather than in either adapter for the same reason `deriveLatencyClass`
1074
+ * is: REST and MCP have to reach the same answer for the same body, and a
1075
+ * default two surfaces derive separately is a default they will eventually
1076
+ * disagree about.
1077
+ *
1078
+ * **`onOverlap` resolves to its default even for a schedule that cannot
1079
+ * overlap**, and that is deliberate rather than sloppy. The alternative is an
1080
+ * optional field the storage layer has to keep re-deciding about, and a stored
1081
+ * `skip` on a five-second one-shot describes something true — the previous run
1082
+ * is never still going — rather than something wrong.
1083
+ */
1084
+ export function deriveDeliverySettings(request) {
1085
+ return {
1086
+ completion: request.delivery?.completion ?? "response",
1087
+ maxDurationSeconds: request.delivery?.maxDurationSeconds ?? DEFAULT_DELIVERY_DURATION_SECONDS,
1088
+ onOverlap: request.delivery?.onOverlap ?? DEFAULT_DELIVERY_OVERLAP_POLICY,
1089
+ };
1090
+ }
1091
+ export function deriveLatencyClass(request, nowMs) {
1092
+ if (request.latencyClass !== undefined) {
1093
+ return request.latencyClass;
1094
+ }
1095
+ if (request.cron !== undefined || request.everySeconds !== undefined) {
1096
+ return "bulk";
1097
+ }
1098
+ const threshold = SCHEDULING_BOUNDS.realtimeOneShotSeconds * 1000;
1099
+ if (request.delaySeconds !== undefined) {
1100
+ return request.delaySeconds * 1000 <= threshold ? "realtime" : "bulk";
1101
+ }
1102
+ if (request.at === undefined) {
1103
+ // No recurrence at all, which the schema refuses before this is reached.
1104
+ // Answered rather than thrown so a caller of this function on an unvalidated
1105
+ // body gets the conservative class instead of an exception.
1106
+ return "bulk";
1107
+ }
1108
+ const atMs = Date.parse(request.at);
1109
+ if (Number.isNaN(atMs)) {
1110
+ return "bulk";
1111
+ }
1112
+ // An instant already in the past is as immediate as an instant gets, so the
1113
+ // comparison is against the delay from now and not against its absolute
1114
+ // value: `at` one second ago is realtime, and `at` a year out is not.
1115
+ return atMs - nowMs <= threshold ? "realtime" : "bulk";
1116
+ }