@greatwait/contracts 0.1.0-alpha.3 → 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/README.md +11 -9
  2. package/dist/authority.d.ts +33 -27
  3. package/dist/authority.js +12 -2
  4. package/dist/billing.d.ts +1001 -0
  5. package/dist/billing.js +684 -0
  6. package/dist/callback-safety.d.ts +2 -2
  7. package/dist/callback-safety.js +2 -2
  8. package/dist/compatibility.d.ts +3 -3
  9. package/dist/compatibility.js +1 -1
  10. package/dist/data-export.d.ts +67 -0
  11. package/dist/data-export.js +182 -0
  12. package/dist/endpoints.d.ts +181 -240
  13. package/dist/endpoints.js +77 -81
  14. package/dist/events.d.ts +17 -17
  15. package/dist/events.js +1 -1
  16. package/dist/first-value-journey.d.ts +49 -55
  17. package/dist/first-value-journey.js +6 -13
  18. package/dist/forecast.d.ts +71 -71
  19. package/dist/forecast.js +1 -1
  20. package/dist/identifiers.d.ts +20 -20
  21. package/dist/identifiers.js +1 -1
  22. package/dist/identity.d.ts +41 -37
  23. package/dist/identity.js +5 -4
  24. package/dist/index.d.ts +10 -5
  25. package/dist/index.js +10 -5
  26. package/dist/mcp.d.ts +21 -19
  27. package/dist/mcp.js +1 -1
  28. package/dist/naming.d.ts +45 -0
  29. package/dist/naming.js +159 -0
  30. package/dist/oauth.d.ts +186 -172
  31. package/dist/oauth.js +1 -1
  32. package/dist/operation-definitions.d.ts +9 -0
  33. package/dist/operation-definitions.js +7 -0
  34. package/dist/operations.d.ts +4162 -2171
  35. package/dist/operations.js +2679 -908
  36. package/dist/organization-lifecycle.d.ts +34 -0
  37. package/dist/organization-lifecycle.js +122 -0
  38. package/dist/partition.d.ts +26 -0
  39. package/dist/policy.d.ts +4 -4
  40. package/dist/policy.js +9 -5
  41. package/dist/problems.d.ts +32 -26
  42. package/dist/problems.js +6 -2
  43. package/dist/protocol.d.ts +204 -75
  44. package/dist/protocol.js +108 -36
  45. package/dist/scheduling-records.d.ts +343 -285
  46. package/dist/scheduling-records.js +1 -1
  47. package/dist/scheduling.d.ts +670 -360
  48. package/dist/scheduling.js +382 -56
  49. package/dist/secrets.d.ts +119 -105
  50. package/dist/secrets.js +1 -1
  51. package/dist/signatures.d.ts +74 -74
  52. package/dist/signatures.js +1 -1
  53. package/dist/tenancy.d.ts +142 -68
  54. package/dist/tenancy.js +70 -17
  55. package/dist/test-sink.d.ts +127 -127
  56. package/dist/test-sink.js +1 -1
  57. package/dist/time.d.ts +4 -4
  58. package/dist/time.js +1 -1
  59. package/dist/timing.d.ts +65 -0
  60. package/dist/timing.js +82 -0
  61. package/dist/usage.d.ts +103 -47
  62. package/dist/usage.js +197 -31
  63. package/dist/workspace.d.ts +34 -34
  64. package/dist/workspace.js +1 -1
  65. package/dist/zod-values.d.ts +3 -0
  66. package/dist/zod-values.js +2 -0
  67. package/dist/zod.d.ts +2 -0
  68. package/dist/zod.js +2 -0
  69. package/generated/v1/examples/attempts.list.response.json +4 -0
  70. package/generated/v1/examples/attempts.read.response.json +4 -0
  71. package/generated/v1/examples/billing.autotopup.request.json +5 -0
  72. package/generated/v1/examples/billing.autotopup.response.json +120 -0
  73. package/generated/v1/examples/billing.autotopuppreview.request.json +19 -0
  74. package/generated/v1/examples/billing.autotopuppreview.response.json +45 -0
  75. package/generated/v1/examples/billing.budgets.request.json +13 -0
  76. package/generated/v1/examples/billing.budgets.response.json +22 -0
  77. package/generated/v1/examples/billing.ceiling.request.json +8 -0
  78. package/generated/v1/examples/billing.ceiling.response.json +120 -0
  79. package/generated/v1/examples/billing.change.request.json +5 -0
  80. package/generated/v1/examples/billing.change.response.json +12 -0
  81. package/generated/v1/examples/billing.funding.response.json +120 -0
  82. package/generated/v1/examples/billing.fundingquote.request.json +7 -0
  83. package/generated/v1/examples/billing.fundingquote.response.json +13 -0
  84. package/generated/v1/examples/billing.ledger.response.json +23 -0
  85. package/generated/v1/examples/billing.notices.response.json +41 -0
  86. package/generated/v1/examples/billing.notificationsettings.response.json +17 -0
  87. package/generated/v1/examples/billing.notificationsettingsupdate.request.json +9 -0
  88. package/generated/v1/examples/billing.notificationsettingsupdate.response.json +17 -0
  89. package/generated/v1/examples/billing.portal.response.json +3 -0
  90. package/generated/v1/examples/billing.projects.response.json +12 -0
  91. package/generated/v1/examples/billing.purchase.request.json +5 -0
  92. package/generated/v1/examples/billing.purchase.response.json +120 -0
  93. package/generated/v1/examples/billing.quote.request.json +4 -0
  94. package/generated/v1/examples/billing.quote.response.json +19 -0
  95. package/generated/v1/examples/billing.reconcile.response.json +73 -0
  96. package/generated/v1/examples/billing.refundrequest.request.json +10 -0
  97. package/generated/v1/examples/billing.refundrequest.response.json +11 -0
  98. package/generated/v1/examples/billing.refundresolution.request.json +4 -0
  99. package/generated/v1/examples/billing.refundresolution.response.json +120 -0
  100. package/generated/v1/examples/billing.summary.response.json +73 -0
  101. package/generated/v1/examples/endpoints.acknowledgeSigningKeyRotation.response.json +18 -0
  102. package/generated/v1/examples/endpoints.create.response.json +1 -1
  103. package/generated/v1/examples/endpoints.disable.response.json +0 -1
  104. package/generated/v1/examples/{endpoints.verify.response.json → endpoints.enable.response.json} +4 -5
  105. package/generated/v1/examples/endpoints.list.response.json +1 -2
  106. package/generated/v1/examples/endpoints.read.response.json +2 -2
  107. package/generated/v1/examples/endpoints.revealSigningSecret.response.json +7 -0
  108. package/generated/v1/examples/endpoints.rotateSigningKey.response.json +1 -2
  109. package/generated/v1/examples/endpoints.update.response.json +1 -1
  110. package/generated/v1/examples/executions.list.response.json +20 -0
  111. package/generated/v1/examples/exports.cancel.response.json +11 -0
  112. package/generated/v1/examples/exports.create.request.json +1 -0
  113. package/generated/v1/examples/exports.create.response.json +8 -0
  114. package/generated/v1/examples/exports.part.response.json +5 -0
  115. package/generated/v1/examples/exports.read.response.json +8 -0
  116. package/generated/v1/examples/occurrences.read.response.json +4 -0
  117. package/generated/v1/examples/organizations.close.request.json +5 -0
  118. package/generated/v1/examples/organizations.close.response.json +10 -0
  119. package/generated/v1/examples/organizations.lifecycle.response.json +10 -0
  120. package/generated/v1/examples/organizations.restore.request.json +3 -0
  121. package/generated/v1/examples/organizations.restore.response.json +10 -0
  122. package/generated/v1/examples/projects.archive.request.json +3 -0
  123. package/generated/v1/examples/projects.archive.response.json +14 -0
  124. package/generated/v1/examples/projects.create.response.json +10 -1
  125. package/generated/v1/examples/projects.delete.response.json +9 -0
  126. package/generated/v1/examples/projects.list.response.json +12 -2
  127. package/generated/v1/examples/projects.rename.request.json +3 -0
  128. package/generated/v1/examples/projects.rename.response.json +14 -0
  129. package/generated/v1/examples/schedules.archive.response.json +3 -0
  130. package/generated/v1/examples/schedules.cancel.response.json +3 -0
  131. package/generated/v1/examples/schedules.create.request.json +4 -0
  132. package/generated/v1/examples/schedules.create.response.json +3 -0
  133. package/generated/v1/examples/schedules.list.response.json +3 -0
  134. package/generated/v1/examples/schedules.pause.response.json +3 -0
  135. package/generated/v1/examples/schedules.read.response.json +6 -0
  136. package/generated/v1/examples/schedules.replace.request.json +4 -0
  137. package/generated/v1/examples/schedules.replace.response.json +3 -0
  138. package/generated/v1/examples/schedules.resume.response.json +3 -0
  139. package/generated/v1/examples/timing.summary.response.json +35 -0
  140. package/generated/v1/examples/usage.read.response.json +1 -0
  141. package/generated/v1/first-value-journey.json +19 -10
  142. package/generated/v1/manifest.json +936 -23
  143. package/generated/v1/openapi.json +29983 -5784
  144. package/generated/v1/policy-matrix.json +278 -47
  145. package/generated/v1/schemas/attempts.list.response.json +24 -1
  146. package/generated/v1/schemas/attempts.read.response.json +24 -1
  147. package/generated/v1/schemas/billing.autotopup.request-headers.json +13 -0
  148. package/generated/v1/schemas/billing.autotopup.request.json +22 -0
  149. package/generated/v1/schemas/billing.autotopup.response-headers.json +10 -0
  150. package/generated/v1/schemas/billing.autotopup.response.json +552 -0
  151. package/generated/v1/schemas/billing.autotopuppreview.request.json +77 -0
  152. package/generated/v1/schemas/billing.autotopuppreview.response.json +277 -0
  153. package/generated/v1/schemas/billing.budgets.request-headers.json +13 -0
  154. package/generated/v1/schemas/billing.budgets.request.json +66 -0
  155. package/generated/v1/schemas/billing.budgets.response-headers.json +10 -0
  156. package/generated/v1/schemas/billing.budgets.response.json +92 -0
  157. package/generated/v1/schemas/billing.ceiling.request-headers.json +13 -0
  158. package/generated/v1/schemas/billing.ceiling.request.json +40 -0
  159. package/generated/v1/schemas/billing.ceiling.response-headers.json +10 -0
  160. package/generated/v1/schemas/billing.ceiling.response.json +552 -0
  161. package/generated/v1/schemas/billing.change.request-headers.json +13 -0
  162. package/generated/v1/schemas/billing.change.request.json +22 -0
  163. package/generated/v1/schemas/billing.change.response.json +119 -0
  164. package/generated/v1/schemas/billing.funding.response.json +552 -0
  165. package/generated/v1/schemas/billing.fundingquote.request.json +28 -0
  166. package/generated/v1/schemas/billing.fundingquote.response.json +61 -0
  167. package/generated/v1/schemas/billing.ledger.query.json +16 -0
  168. package/generated/v1/schemas/billing.ledger.response.json +125 -0
  169. package/generated/v1/schemas/billing.notices.query.json +16 -0
  170. package/generated/v1/schemas/billing.notices.response.json +278 -0
  171. package/generated/v1/schemas/billing.notificationsettings.response.json +70 -0
  172. package/generated/v1/schemas/billing.notificationsettingsupdate.request-headers.json +13 -0
  173. package/generated/v1/schemas/billing.notificationsettingsupdate.request.json +36 -0
  174. package/generated/v1/schemas/billing.notificationsettingsupdate.response-headers.json +10 -0
  175. package/generated/v1/schemas/billing.notificationsettingsupdate.response.json +70 -0
  176. package/generated/v1/schemas/billing.portal.response.json +11 -0
  177. package/generated/v1/schemas/billing.projects.response.json +119 -0
  178. package/generated/v1/schemas/billing.purchase.request-headers.json +13 -0
  179. package/generated/v1/schemas/billing.purchase.request.json +22 -0
  180. package/generated/v1/schemas/billing.purchase.response-headers.json +10 -0
  181. package/generated/v1/schemas/billing.purchase.response.json +552 -0
  182. package/generated/v1/schemas/billing.quote.request.json +20 -0
  183. package/generated/v1/schemas/billing.quote.response.json +86 -0
  184. package/generated/v1/schemas/billing.reconcile.response.json +468 -0
  185. package/generated/v1/schemas/billing.refundrequest.request-headers.json +13 -0
  186. package/generated/v1/schemas/billing.refundrequest.request.json +42 -0
  187. package/generated/v1/schemas/billing.refundrequest.response-headers.json +10 -0
  188. package/generated/v1/schemas/billing.refundrequest.response.json +45 -0
  189. package/generated/v1/schemas/billing.refundresolution.request-headers.json +13 -0
  190. package/generated/v1/schemas/billing.refundresolution.request.json +61 -0
  191. package/generated/v1/schemas/billing.refundresolution.response-headers.json +10 -0
  192. package/generated/v1/schemas/billing.refundresolution.response.json +552 -0
  193. package/generated/v1/schemas/billing.summary.response.json +468 -0
  194. package/generated/v1/schemas/context.read.response.json +2 -0
  195. package/generated/v1/schemas/credentials.connection.response.json +2 -0
  196. package/generated/v1/schemas/credentials.create.request.json +2 -0
  197. package/generated/v1/schemas/credentials.create.response.json +2 -0
  198. package/generated/v1/schemas/credentials.list.response.json +2 -0
  199. package/generated/v1/schemas/credentials.revoke.response.json +2 -0
  200. package/generated/v1/schemas/credentials.rotate.response.json +4 -0
  201. package/generated/v1/schemas/{endpoints.verify.request-headers.json → endpoints.acknowledgeSigningKeyRotation.request-headers.json} +2 -1
  202. package/generated/v1/schemas/{endpoints.verify.response.json → endpoints.acknowledgeSigningKeyRotation.response.json} +8 -21
  203. package/generated/v1/schemas/endpoints.create.request.json +5 -0
  204. package/generated/v1/schemas/endpoints.create.response.json +8 -21
  205. package/generated/v1/schemas/endpoints.disable.response.json +8 -21
  206. package/generated/v1/schemas/endpoints.enable.response-headers.json +11 -0
  207. package/generated/v1/schemas/endpoints.enable.response.json +87 -0
  208. package/generated/v1/schemas/endpoints.list.response.json +8 -21
  209. package/generated/v1/schemas/endpoints.read.response.json +8 -21
  210. package/generated/v1/schemas/endpoints.revealSigningSecret.response.json +43 -0
  211. package/generated/v1/schemas/endpoints.rotateSigningKey.response.json +8 -21
  212. package/generated/v1/schemas/endpoints.update.request.json +5 -0
  213. package/generated/v1/schemas/endpoints.update.response.json +8 -21
  214. package/generated/v1/schemas/executions.list.query.json +46 -0
  215. package/generated/v1/schemas/executions.list.response.json +116 -0
  216. package/generated/v1/schemas/exports.cancel.response.json +48 -0
  217. package/generated/v1/schemas/exports.create.request.json +6 -0
  218. package/generated/v1/schemas/exports.create.response.json +38 -0
  219. package/generated/v1/schemas/exports.part.query.json +11 -0
  220. package/generated/v1/schemas/exports.part.response.json +22 -0
  221. package/generated/v1/schemas/exports.read.response.json +38 -0
  222. package/generated/v1/schemas/grants.list.response.json +2 -0
  223. package/generated/v1/schemas/grants.revoke.response.json +2 -0
  224. package/generated/v1/schemas/occurrences.list.query.json +5 -0
  225. package/generated/v1/schemas/occurrences.list.response.json +23 -0
  226. package/generated/v1/schemas/occurrences.read.response.json +23 -0
  227. package/generated/v1/schemas/occurrences.redeliver.response.json +24 -1
  228. package/generated/v1/schemas/organizations.close.request.json +20 -0
  229. package/generated/v1/schemas/organizations.close.response.json +68 -0
  230. package/generated/v1/schemas/organizations.lifecycle.response.json +68 -0
  231. package/generated/v1/schemas/organizations.restore.request.json +13 -0
  232. package/generated/v1/schemas/organizations.restore.response.json +68 -0
  233. package/generated/v1/schemas/projects.archive.request-headers.json +11 -0
  234. package/generated/v1/schemas/projects.archive.request.json +10 -0
  235. package/generated/v1/schemas/projects.archive.response-headers.json +11 -0
  236. package/generated/v1/schemas/projects.archive.response.json +79 -0
  237. package/generated/v1/schemas/projects.create.response.json +55 -1
  238. package/generated/v1/schemas/projects.delete.request-headers.json +11 -0
  239. package/generated/v1/schemas/projects.delete.response.json +37 -0
  240. package/generated/v1/schemas/projects.list.response.json +61 -2
  241. package/generated/v1/schemas/projects.rename.request-headers.json +11 -0
  242. package/generated/v1/schemas/projects.rename.request.json +12 -0
  243. package/generated/v1/schemas/projects.rename.response-headers.json +11 -0
  244. package/generated/v1/schemas/projects.rename.response.json +79 -0
  245. package/generated/v1/schemas/schedules.archive.response.json +90 -2
  246. package/generated/v1/schemas/schedules.cancel.response.json +90 -2
  247. package/generated/v1/schemas/schedules.create.request.json +24 -3
  248. package/generated/v1/schemas/schedules.create.response.json +90 -2
  249. package/generated/v1/schemas/schedules.list.query.json +13 -0
  250. package/generated/v1/schemas/schedules.list.response.json +67 -2
  251. package/generated/v1/schemas/schedules.pause.response.json +90 -2
  252. package/generated/v1/schemas/schedules.read.response.json +90 -2
  253. package/generated/v1/schemas/schedules.replace.request.json +24 -3
  254. package/generated/v1/schemas/schedules.replace.response.json +90 -2
  255. package/generated/v1/schemas/schedules.resume.response.json +90 -2
  256. package/generated/v1/schemas/timing.summary.response.json +191 -0
  257. package/generated/v1/schemas/usage.read.query.json +7 -0
  258. package/generated/v1/schemas/usage.read.response.json +36 -1
  259. package/generated/v1/snippets/billing.autotopup.json +6 -0
  260. package/generated/v1/snippets/billing.autotopuppreview.json +6 -0
  261. package/generated/v1/snippets/billing.budgets.json +6 -0
  262. package/generated/v1/snippets/billing.ceiling.json +6 -0
  263. package/generated/v1/snippets/billing.change.json +6 -0
  264. package/generated/v1/snippets/billing.funding.json +6 -0
  265. package/generated/v1/snippets/billing.fundingquote.json +6 -0
  266. package/generated/v1/snippets/billing.ledger.json +6 -0
  267. package/generated/v1/snippets/billing.notices.json +6 -0
  268. package/generated/v1/snippets/billing.notificationsettings.json +6 -0
  269. package/generated/v1/snippets/billing.notificationsettingsupdate.json +6 -0
  270. package/generated/v1/snippets/billing.portal.json +6 -0
  271. package/generated/v1/snippets/billing.projects.json +6 -0
  272. package/generated/v1/snippets/billing.purchase.json +6 -0
  273. package/generated/v1/snippets/billing.quote.json +6 -0
  274. package/generated/v1/snippets/billing.reconcile.json +6 -0
  275. package/generated/v1/snippets/billing.refundrequest.json +6 -0
  276. package/generated/v1/snippets/billing.refundresolution.json +6 -0
  277. package/generated/v1/snippets/billing.summary.json +6 -0
  278. package/generated/v1/snippets/endpoints.acknowledgeSigningKeyRotation.json +6 -0
  279. package/generated/v1/snippets/{endpoints.verify.json → endpoints.enable.json} +3 -3
  280. package/generated/v1/snippets/endpoints.revealSigningSecret.json +6 -0
  281. package/generated/v1/snippets/executions.list.json +6 -0
  282. package/generated/v1/snippets/exports.cancel.json +6 -0
  283. package/generated/v1/snippets/exports.create.json +6 -0
  284. package/generated/v1/snippets/exports.part.json +6 -0
  285. package/generated/v1/snippets/exports.read.json +6 -0
  286. package/generated/v1/snippets/organizations.close.json +6 -0
  287. package/generated/v1/snippets/organizations.lifecycle.json +6 -0
  288. package/generated/v1/snippets/organizations.restore.json +6 -0
  289. package/generated/v1/snippets/projects.archive.json +6 -0
  290. package/generated/v1/snippets/projects.delete.json +6 -0
  291. package/generated/v1/snippets/projects.rename.json +6 -0
  292. package/generated/v1/snippets/schedules.create.json +3 -3
  293. package/generated/v1/snippets/schedules.replace.json +3 -3
  294. package/generated/v1/snippets/timing.summary.json +6 -0
  295. package/package.json +1 -6
  296. package/dist/callback-ownership.d.ts +0 -58
  297. package/dist/callback-ownership.js +0 -71
  298. package/generated/v1/callback-endpoint-verification.json +0 -59
  299. /package/generated/v1/schemas/{endpoints.verify.response-headers.json → endpoints.acknowledgeSigningKeyRotation.response-headers.json} +0 -0
package/dist/usage.d.ts CHANGED
@@ -1,28 +1,65 @@
1
- import { z } from "zod";
1
+ import { z } from "./zod.js";
2
2
  import type { FieldError } from "./problems.js";
3
3
  /**
4
- * What a customer is charged for, and nothing about what it cost to deliver.
4
+ * A legacy operational aggregate of terminal occurrences.
5
5
  *
6
- * **One terminal occurrence is one billable unit, however many attempts it
7
- * took.** That is the commercial rule the whole runtime is built around — a
8
- * retry is our cost, not the customer's — and this surface exists so it is
9
- * something a customer can see rather than something they are told. A schedule
10
- * that failed four times and succeeded on the fifth appears here once; the
11
- * five attempts are on the occurrence's own attempt history, which is the page
12
- * that answers "what did it cost us".
6
+ * The published field names predate PAYG and remain for client compatibility.
7
+ * They are no longer the invoice meter: PAYG charges each logical delivery
8
+ * attempt, including retries and redeliveries, through the exact billing ledger.
9
+ * A schedule that failed four times and succeeded on the fifth appears here as
10
+ * one terminal occurrence and in `billing.ledger` as five distinct attempts.
13
11
  *
14
12
  * Days are UTC and half-open, aligned to midnight at both ends. A tenant-local
15
13
  * boundary would make every bucket a different width for a different customer
16
14
  * and could not be one index range; the capacity decision states the budget in
17
15
  * UTC days for the same reason.
18
16
  *
19
- * A day with no billable occurrence is **absent** rather than zero. The window
17
+ * A day with no terminal occurrence is **absent** rather than zero. The window
20
18
  * says which days the answer covers, so an absent day is unambiguous, and a
21
19
  * quiet month is a short document rather than thirty-one zeroes.
22
20
  */
23
21
  export declare const USAGE_BOUNDS: {
22
+ /**
23
+ * The bucket a caller gets without asking: one UTC day.
24
+ *
25
+ * Unchanged from the only width this surface had, so a caller that never
26
+ * heard of `bucketSeconds` sees exactly what it saw before.
27
+ */
28
+ readonly defaultBucketSeconds: 86400;
24
29
  /** Days a window covers when the caller names neither end. */
25
30
  readonly defaultWindowDays: 31;
31
+ /**
32
+ * Buckets one answer may carry.
33
+ *
34
+ * A year of hours is 8,784, which is not a chart and not a document anybody
35
+ * reads — it is a client asking for the widest window at the finest width and
36
+ * getting a megabyte back. The ceiling is a refusal rather than a truncation,
37
+ * for the reason the forecast gives: an answer that is quietly short is one a
38
+ * caller will act on.
39
+ *
40
+ * Matched to `FORECAST_BOUNDS.maxBuckets`, because past and future now answer
41
+ * the same question in the same shape and a client charting both should not
42
+ * discover that one of them stops earlier.
43
+ */
44
+ readonly maxBuckets: 1500;
45
+ /**
46
+ * The widest bucket, which is also the default: a day.
47
+ *
48
+ * Wider than a day is a question about months, and months are not a whole
49
+ * number of days — a "monthly" bucket would have to pick a calendar and this
50
+ * surface is deliberately in UTC days for every tenant.
51
+ */
52
+ readonly maxBucketSeconds: 86400;
53
+ /**
54
+ * The finest bucket, which is an hour.
55
+ *
56
+ * Finer than an hour is not refused for cost — the read is the same indexed
57
+ * range scan at any width, and the row count depends on the window rather
58
+ * than the bucket — but because nothing asks for it. The dashboard's finest
59
+ * chart is hourly, and a width nothing consumes is a width nobody has
60
+ * checked the arithmetic of.
61
+ */
62
+ readonly minBucketSeconds: 3600;
26
63
  /**
27
64
  * The widest window this contract will describe.
28
65
  *
@@ -32,19 +69,20 @@ export declare const USAGE_BOUNDS: {
32
69
  */
33
70
  readonly maxWindowDays: 366;
34
71
  };
35
- /** How a billable occurrence ended. The four terminal occurrence states. */
72
+ /** How a terminal occurrence ended. The four retained terminal states. */
36
73
  export declare const BILLABLE_TERMINAL_STATES: readonly ["cancelled", "skipped_stale", "succeeded", "terminal_failed"];
37
- export declare const billableTerminalStateSchema: z.ZodEnum<{
74
+ export declare const billableTerminalStateSchema: import("zod").ZodEnum<{
38
75
  succeeded: "succeeded";
39
76
  cancelled: "cancelled";
40
77
  terminal_failed: "terminal_failed";
41
78
  skipped_stale: "skipped_stale";
42
79
  }>;
43
80
  export type BillableTerminalStateName = z.infer<typeof billableTerminalStateSchema>;
44
- export declare const usageRequestSchema: z.ZodObject<{
45
- from: z.ZodOptional<z.ZodString>;
46
- to: z.ZodOptional<z.ZodString>;
47
- }, z.core.$strict>;
81
+ export declare const usageRequestSchema: import("zod").ZodObject<{
82
+ bucketSeconds: import("zod").ZodDefault<import("zod").ZodNumber>;
83
+ from: import("zod").ZodOptional<import("zod").ZodString>;
84
+ to: import("zod").ZodOptional<import("zod").ZodString>;
85
+ }, import("zod/v4/core").$strict>;
48
86
  export type UsageRequest = z.infer<typeof usageRequestSchema>;
49
87
  export type UsageRequestParse = {
50
88
  readonly errors: readonly FieldError[];
@@ -65,43 +103,51 @@ export type UsageRequestParse = {
65
103
  export declare function parseUsageRequest(query: URLSearchParams): UsageRequestParse;
66
104
  /** What a caller is told when a usage window is refused rather than served. */
67
105
  export declare const USAGE_WINDOW_REASONS: Readonly<Record<UsageWindowRefusal, string>>;
68
- export declare const usageDaySchema: z.ZodObject<{
69
- billableOccurrences: z.ZodNumber;
70
- byTerminalState: z.ZodRecord<z.ZodEnum<{
106
+ export declare const usageDaySchema: import("zod").ZodObject<{
107
+ billableOccurrences: import("zod").ZodNumber;
108
+ byTerminalState: import("zod").ZodRecord<import("zod").ZodEnum<{
71
109
  succeeded: "succeeded";
72
110
  cancelled: "cancelled";
73
111
  terminal_failed: "terminal_failed";
74
112
  skipped_stale: "skipped_stale";
75
- }> & z.core.$partial, z.ZodNumber>;
76
- start: z.ZodString;
77
- }, z.core.$strip>;
113
+ }> & import("zod/v4/core").$partial, import("zod").ZodNumber>;
114
+ start: import("zod").ZodString;
115
+ }, import("zod/v4/core").$strip>;
78
116
  export type UsageDayView = z.infer<typeof usageDaySchema>;
79
- export declare const usageSchema: z.ZodObject<{
80
- days: z.ZodArray<z.ZodObject<{
81
- billableOccurrences: z.ZodNumber;
82
- byTerminalState: z.ZodRecord<z.ZodEnum<{
117
+ export declare const usageSchema: import("zod").ZodObject<{
118
+ bucketSeconds: import("zod").ZodNumber;
119
+ days: import("zod").ZodArray<import("zod").ZodObject<{
120
+ billableOccurrences: import("zod").ZodNumber;
121
+ byTerminalState: import("zod").ZodRecord<import("zod").ZodEnum<{
83
122
  succeeded: "succeeded";
84
123
  cancelled: "cancelled";
85
124
  terminal_failed: "terminal_failed";
86
125
  skipped_stale: "skipped_stale";
87
- }> & z.core.$partial, z.ZodNumber>;
88
- start: z.ZodString;
89
- }, z.core.$strip>>;
90
- freshness: z.ZodObject<{
91
- kind: z.ZodLiteral<"authoritative">;
92
- }, z.core.$strip>;
93
- totalBillableOccurrences: z.ZodNumber;
94
- window: z.ZodObject<{
95
- from: z.ZodString;
96
- to: z.ZodString;
97
- }, z.core.$strip>;
98
- }, z.core.$strip>;
126
+ }> & import("zod/v4/core").$partial, import("zod").ZodNumber>;
127
+ start: import("zod").ZodString;
128
+ }, import("zod/v4/core").$strip>>;
129
+ freshness: import("zod").ZodObject<{
130
+ kind: import("zod").ZodLiteral<"authoritative">;
131
+ }, import("zod/v4/core").$strip>;
132
+ plan: import("zod").ZodOptional<import("zod").ZodObject<{
133
+ attemptsToday: import("zod").ZodNumber;
134
+ dailyAttemptCeiling: import("zod").ZodNumber;
135
+ resetsAt: import("zod").ZodString;
136
+ since: import("zod").ZodString;
137
+ }, import("zod/v4/core").$strip>>;
138
+ totalBillableOccurrences: import("zod").ZodNumber;
139
+ window: import("zod").ZodObject<{
140
+ from: import("zod").ZodString;
141
+ to: import("zod").ZodString;
142
+ }, import("zod/v4/core").$strip>;
143
+ }, import("zod/v4/core").$strip>;
99
144
  export type UsageView = z.infer<typeof usageSchema>;
100
- export type UsageWindowRefusal = "window-inverted" | "window-too-wide" | "window-unreadable";
145
+ export type UsageWindowRefusal = "too-many-buckets" | "window-inverted" | "window-too-wide" | "window-unreadable";
101
146
  export type ResolvedUsageWindow = {
102
147
  readonly ok: false;
103
148
  readonly refusal: UsageWindowRefusal;
104
149
  } | {
150
+ readonly bucketSeconds: number;
105
151
  readonly fromMs: number;
106
152
  readonly ok: true;
107
153
  readonly toMs: number;
@@ -109,13 +155,23 @@ export type ResolvedUsageWindow = {
109
155
  /** UTC midnight of the day an instant falls in. */
110
156
  export declare function utcMidnightMs(instantMs: number): number;
111
157
  /**
112
- * Resolve the window a usage request names, aligned to UTC days.
158
+ * Resolve the window a usage request names, aligned to whole buckets.
159
+ *
160
+ * **Both ends are snapped outward, and that is not tidiness.** A window
161
+ * beginning at nine in the morning would put a partial bucket at the front of
162
+ * the list with no way for a reader to tell it from a quiet one — a bar that is
163
+ * short because it is twenty-three minutes long rather than because nothing
164
+ * happened. Snapping outward means the answer always covers whole buckets and
165
+ * never covers less than what was asked for.
113
166
  *
114
- * **Both ends are snapped to midnight, and that is not tidiness.** The answer
115
- * is bucketed by UTC day; a window beginning at nine in the morning would put a
116
- * partial day at the front of the list with no way for a reader to tell it from
117
- * a quiet one. Snapping outward — the start of `from`'s day, the start of the
118
- * day after `to`'s — means the window always covers whole days and never covers
119
- * less than what was asked for.
167
+ * **To the bucket rather than to the day, since FXO-2281**, which is the same
168
+ * argument one width down. It is the same behaviour at the default width: a
169
+ * day-wide bucket aligned to the epoch starts at UTC midnight, so a caller who
170
+ * names no width gets exactly the window this always served. An hourly caller
171
+ * asking for "the last twenty-four hours" would otherwise have both ends pushed
172
+ * to midnight and be handed up to forty-eight buckets, half of them for a day
173
+ * they did not ask about.
120
174
  */
121
- export declare function resolveUsageWindow(request: UsageRequest, nowMs: number): ResolvedUsageWindow;
175
+ export declare function resolveUsageWindow(request: Omit<UsageRequest, "bucketSeconds"> & {
176
+ readonly bucketSeconds?: number;
177
+ }, nowMs: number): ResolvedUsageWindow;
package/dist/usage.js CHANGED
@@ -1,28 +1,65 @@
1
- import { z } from "zod";
1
+ import { z } from "./zod.js";
2
2
  import { timestampSchema } from "./time.js";
3
3
  /**
4
- * What a customer is charged for, and nothing about what it cost to deliver.
4
+ * A legacy operational aggregate of terminal occurrences.
5
5
  *
6
- * **One terminal occurrence is one billable unit, however many attempts it
7
- * took.** That is the commercial rule the whole runtime is built around — a
8
- * retry is our cost, not the customer's — and this surface exists so it is
9
- * something a customer can see rather than something they are told. A schedule
10
- * that failed four times and succeeded on the fifth appears here once; the
11
- * five attempts are on the occurrence's own attempt history, which is the page
12
- * that answers "what did it cost us".
6
+ * The published field names predate PAYG and remain for client compatibility.
7
+ * They are no longer the invoice meter: PAYG charges each logical delivery
8
+ * attempt, including retries and redeliveries, through the exact billing ledger.
9
+ * A schedule that failed four times and succeeded on the fifth appears here as
10
+ * one terminal occurrence and in `billing.ledger` as five distinct attempts.
13
11
  *
14
12
  * Days are UTC and half-open, aligned to midnight at both ends. A tenant-local
15
13
  * boundary would make every bucket a different width for a different customer
16
14
  * and could not be one index range; the capacity decision states the budget in
17
15
  * UTC days for the same reason.
18
16
  *
19
- * A day with no billable occurrence is **absent** rather than zero. The window
17
+ * A day with no terminal occurrence is **absent** rather than zero. The window
20
18
  * says which days the answer covers, so an absent day is unambiguous, and a
21
19
  * quiet month is a short document rather than thirty-one zeroes.
22
20
  */
23
21
  export const USAGE_BOUNDS = {
22
+ /**
23
+ * The bucket a caller gets without asking: one UTC day.
24
+ *
25
+ * Unchanged from the only width this surface had, so a caller that never
26
+ * heard of `bucketSeconds` sees exactly what it saw before.
27
+ */
28
+ defaultBucketSeconds: 86_400,
24
29
  /** Days a window covers when the caller names neither end. */
25
30
  defaultWindowDays: 31,
31
+ /**
32
+ * Buckets one answer may carry.
33
+ *
34
+ * A year of hours is 8,784, which is not a chart and not a document anybody
35
+ * reads — it is a client asking for the widest window at the finest width and
36
+ * getting a megabyte back. The ceiling is a refusal rather than a truncation,
37
+ * for the reason the forecast gives: an answer that is quietly short is one a
38
+ * caller will act on.
39
+ *
40
+ * Matched to `FORECAST_BOUNDS.maxBuckets`, because past and future now answer
41
+ * the same question in the same shape and a client charting both should not
42
+ * discover that one of them stops earlier.
43
+ */
44
+ maxBuckets: 1_500,
45
+ /**
46
+ * The widest bucket, which is also the default: a day.
47
+ *
48
+ * Wider than a day is a question about months, and months are not a whole
49
+ * number of days — a "monthly" bucket would have to pick a calendar and this
50
+ * surface is deliberately in UTC days for every tenant.
51
+ */
52
+ maxBucketSeconds: 86_400,
53
+ /**
54
+ * The finest bucket, which is an hour.
55
+ *
56
+ * Finer than an hour is not refused for cost — the read is the same indexed
57
+ * range scan at any width, and the row count depends on the window rather
58
+ * than the bucket — but because nothing asks for it. The dashboard's finest
59
+ * chart is hourly, and a width nothing consumes is a width nobody has
60
+ * checked the arithmetic of.
61
+ */
62
+ minBucketSeconds: 3_600,
26
63
  /**
27
64
  * The widest window this contract will describe.
28
65
  *
@@ -32,7 +69,7 @@ export const USAGE_BOUNDS = {
32
69
  */
33
70
  maxWindowDays: 366,
34
71
  };
35
- /** How a billable occurrence ended. The four terminal occurrence states. */
72
+ /** How a terminal occurrence ended. The four retained terminal states. */
36
73
  export const BILLABLE_TERMINAL_STATES = [
37
74
  "cancelled",
38
75
  "skipped_stale",
@@ -42,13 +79,42 @@ export const BILLABLE_TERMINAL_STATES = [
42
79
  export const billableTerminalStateSchema = z.enum(BILLABLE_TERMINAL_STATES);
43
80
  export const usageRequestSchema = z
44
81
  .object({
82
+ /**
83
+ * Bucket width in whole seconds, aligned to the epoch.
84
+ *
85
+ * **Mirrors `/v1/forecast`'s parameter, deliberately** — past and future
86
+ * answer the same question about the same book, and a client charting both
87
+ * should not have to learn two spellings of "by the hour". Seconds as a
88
+ * number rather than a duration string, which is this product's published
89
+ * convention.
90
+ *
91
+ * A whole number of the finest bucket, so re-aggregating is exact: an
92
+ * answer whose buckets straddled would be a number nobody can reconcile
93
+ * against a bill.
94
+ */
95
+ bucketSeconds: z
96
+ .number()
97
+ .int()
98
+ .min(USAGE_BOUNDS.minBucketSeconds)
99
+ .max(USAGE_BOUNDS.maxBucketSeconds)
100
+ .multipleOf(USAGE_BOUNDS.minBucketSeconds)
101
+ .default(USAGE_BOUNDS.defaultBucketSeconds),
45
102
  /** Inclusive start. Defaults to `defaultWindowDays` before the end. */
46
103
  from: timestampSchema.optional(),
47
104
  /** Exclusive end. Defaults to the start of tomorrow, UTC. */
48
105
  to: timestampSchema.optional(),
49
106
  })
50
107
  .strict();
51
- const USAGE_PARAMETERS = ["from", "to"];
108
+ /** `[name, numeric]` — the coercion each parameter needs off a URL. */
109
+ const USAGE_PARAMETERS = [
110
+ ["bucketSeconds", true],
111
+ ["from", false],
112
+ ["to", false],
113
+ ];
114
+ /** Query values arrive as strings; only a whole number may become one. */
115
+ function asNumberIfInteger(value) {
116
+ return /^-?\d+$/u.test(value) ? Number(value) : value;
117
+ }
52
118
  /**
53
119
  * Read a usage request off a URL.
54
120
  *
@@ -61,7 +127,7 @@ const USAGE_PARAMETERS = ["from", "to"];
61
127
  export function parseUsageRequest(query) {
62
128
  const errors = [];
63
129
  const raw = {};
64
- for (const field of USAGE_PARAMETERS) {
130
+ for (const [field, numeric] of USAGE_PARAMETERS) {
65
131
  const values = query.getAll(field);
66
132
  if (values.length > 1) {
67
133
  errors.push({
@@ -73,7 +139,7 @@ export function parseUsageRequest(query) {
73
139
  const value = values[0];
74
140
  if (value === undefined)
75
141
  continue;
76
- raw[field] = value;
142
+ raw[field] = numeric ? asNumberIfInteger(value) : value;
77
143
  }
78
144
  if (errors.length > 0)
79
145
  return { errors, ok: false };
@@ -90,6 +156,7 @@ export function parseUsageRequest(query) {
90
156
  }
91
157
  /** What a caller is told when a usage window is refused rather than served. */
92
158
  export const USAGE_WINDOW_REASONS = {
159
+ "too-many-buckets": `must not ask for more than ${String(USAGE_BOUNDS.maxBuckets)} buckets; widen bucketSeconds or narrow the window`,
93
160
  "window-inverted": "must end after it begins",
94
161
  "window-too-wide": `must cover at most ${String(USAGE_BOUNDS.maxWindowDays)} days`,
95
162
  "window-unreadable": "must be an RFC 3339 instant",
@@ -97,7 +164,7 @@ export const USAGE_WINDOW_REASONS = {
97
164
  export const usageDaySchema = z.object({
98
165
  billableOccurrences: z.number().int().positive(),
99
166
  /**
100
- * How the day's billable occurrences ended.
167
+ * How the day's terminal occurrences ended.
101
168
  *
102
169
  * Sums to `billableOccurrences`, and a state with no occurrences is absent
103
170
  * rather than zero — the same rule the day list follows, one level down.
@@ -108,7 +175,22 @@ export const usageDaySchema = z.object({
108
175
  });
109
176
  export const usageSchema = z
110
177
  .object({
111
- /** Ascending by `start`. Days with no billable occurrence are absent. */
178
+ /**
179
+ * The width each entry in `days` covers, in seconds.
180
+ *
181
+ * Echoed so a client knows what it got rather than what it asked for, the
182
+ * way the forecast echoes its own. It is `86400` for every caller that does
183
+ * not ask, which is what this surface always served.
184
+ */
185
+ bucketSeconds: z.number().int().positive(),
186
+ /**
187
+ * Ascending by `start`. Buckets with no terminal occurrence are absent.
188
+ *
189
+ * **Still named `days`, at a width that may not be one.** Renaming it would
190
+ * break every caller reading the field to serve callers who asked for an
191
+ * hourly width — which is nobody until they opt in. `bucketSeconds` above
192
+ * says what a row spans.
193
+ */
112
194
  days: z.array(usageDaySchema),
113
195
  /**
114
196
  * Authoritative, because this is the committed model the settlement wrote.
@@ -119,6 +201,44 @@ export const usageSchema = z
119
201
  * summary.
120
202
  */
121
203
  freshness: z.object({ kind: z.literal("authoritative") }),
204
+ /**
205
+ * How much of this deployment's daily attempt allowance has been spent.
206
+ *
207
+ * **Because nothing else could say it, and something had to.** The runtime
208
+ * refuses admission once an organization has made
209
+ * `dailyAttemptCeiling` attempts in a UTC day, and defers every schedule to
210
+ * the next day boundary rather than failing them. Nothing lost, nothing
211
+ * failed — and nothing running either, for up to a day, on a product whose
212
+ * whole job is to run things. The operator found his own account in that
213
+ * state during the W4-10 walk and no screen anywhere named it: the runs
214
+ * read "Delivering", the dashboard had no action waiting, and the only
215
+ * visible trace was an unremarkable number on the billing panel.
216
+ *
217
+ * The disposition, the ceiling and the count all lived inside
218
+ * `DialerWorker` and `EntitlementPolicy`; neither ever left
219
+ * `packages/application`, and `days` above counts settled *occurrences*
220
+ * rather than attempts, so it could not be derived either.
221
+ *
222
+ * Absent when this deployment sets no ceiling, which is the honest shape
223
+ * for "there is no allowance to be near the end of" — a surface must not
224
+ * render a limit nobody has.
225
+ */
226
+ plan: z
227
+ .object({
228
+ /** Attempts this organization has made since `since`, capped at one past the ceiling. */
229
+ attemptsToday: z.number().int().nonnegative(),
230
+ dailyAttemptCeiling: z.number().int().positive(),
231
+ /**
232
+ * When the allowance refills — the next UTC day boundary.
233
+ *
234
+ * The same instant the runtime defers to, so a page saying "runs
235
+ * resume at" and a row's own wake cannot disagree.
236
+ */
237
+ resetsAt: timestampSchema,
238
+ /** The start of the day being counted, so a reader can check the window. */
239
+ since: timestampSchema,
240
+ })
241
+ .optional(),
122
242
  totalBillableOccurrences: z.number().int().nonnegative(),
123
243
  window: z.object({ from: timestampSchema, to: timestampSchema }),
124
244
  })
@@ -138,7 +258,7 @@ export const usageSchema = z
138
258
  path: ["totalBillableOccurrences"],
139
259
  })
140
260
  .refine((usage) => usage.days.every((day) => Object.values(day.byTerminalState).reduce((sum, count) => sum + count, 0) === day.billableOccurrences), {
141
- message: "byTerminalState must account for every billable occurrence",
261
+ message: "byTerminalState must account for every terminal occurrence",
142
262
  path: ["days"],
143
263
  })
144
264
  .refine((usage) => usage.days.every((day) => {
@@ -166,35 +286,81 @@ export function utcMidnightMs(instantMs) {
166
286
  return Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate());
167
287
  }
168
288
  /**
169
- * Resolve the window a usage request names, aligned to UTC days.
289
+ * Resolve the window a usage request names, aligned to whole buckets.
290
+ *
291
+ * **Both ends are snapped outward, and that is not tidiness.** A window
292
+ * beginning at nine in the morning would put a partial bucket at the front of
293
+ * the list with no way for a reader to tell it from a quiet one — a bar that is
294
+ * short because it is twenty-three minutes long rather than because nothing
295
+ * happened. Snapping outward means the answer always covers whole buckets and
296
+ * never covers less than what was asked for.
170
297
  *
171
- * **Both ends are snapped to midnight, and that is not tidiness.** The answer
172
- * is bucketed by UTC day; a window beginning at nine in the morning would put a
173
- * partial day at the front of the list with no way for a reader to tell it from
174
- * a quiet one. Snapping outward — the start of `from`'s day, the start of the
175
- * day after `to`'s — means the window always covers whole days and never covers
176
- * less than what was asked for.
298
+ * **To the bucket rather than to the day, since FXO-2281**, which is the same
299
+ * argument one width down. It is the same behaviour at the default width: a
300
+ * day-wide bucket aligned to the epoch starts at UTC midnight, so a caller who
301
+ * names no width gets exactly the window this always served. An hourly caller
302
+ * asking for "the last twenty-four hours" would otherwise have both ends pushed
303
+ * to midnight and be handed up to forty-eight buckets, half of them for a day
304
+ * they did not ask about.
177
305
  */
178
- export function resolveUsageWindow(request, nowMs) {
179
- const defaultToMs = utcMidnightMs(nowMs) + DAY_MS;
306
+ export function resolveUsageWindow(
307
+ // **The request as a caller may hand it over, not only as the schema hands
308
+ // it back.** `bucketSeconds` carries a `.default()`, which makes it required
309
+ // on the parsed type; a caller resolving a window from a literal `{ from, to }`
310
+ // — which every case here and the contract mock do — is asking the same
311
+ // question and gets the same default from the `??` below.
312
+ request, nowMs) {
313
+ const bucketSeconds = request.bucketSeconds ?? USAGE_BOUNDS.defaultBucketSeconds;
314
+ const bucketMs = bucketSeconds * 1_000;
315
+ /** The start of the bucket an instant falls in, aligned to the epoch. */
316
+ const floorToBucket = (instantMs) => Math.floor(instantMs / bucketMs) * bucketMs;
317
+ /**
318
+ * The end of the bucket an instant falls in — and the instant itself when it
319
+ * is already a boundary.
320
+ *
321
+ * **The `+ bucketMs` here used to be unconditional**, which handed every
322
+ * caller asking a tidy question one bucket more than it asked for. A
323
+ * dashboard requesting seven whole days ending at midnight was served eight,
324
+ * and printed the served window under a heading that said seven. Windows
325
+ * here are half-open, so an aligned `to` is already exact; only an instant
326
+ * *inside* a bucket has anything to round up to.
327
+ *
328
+ * `defaultToMs` reads the same way and wants to: with `to` omitted the
329
+ * answer should reach the bucket in progress, and when `now` happens to land
330
+ * exactly on a boundary the bucket in progress is empty and belongs outside
331
+ * the window rather than on the end of it as a zero.
332
+ */
333
+ const ceilToBucket = (instantMs) => {
334
+ const floored = floorToBucket(instantMs);
335
+ return floored === instantMs ? instantMs : floored + bucketMs;
336
+ };
337
+ const defaultToMs = ceilToBucket(nowMs);
180
338
  const requestedToMs = request.to === undefined ? defaultToMs : Date.parse(request.to);
181
339
  if (!Number.isFinite(requestedToMs)) {
182
340
  return { ok: false, refusal: "window-unreadable" };
183
341
  }
184
- const toMs = request.to === undefined
185
- ? defaultToMs
186
- : utcMidnightMs(requestedToMs) + DAY_MS;
342
+ const toMs = request.to === undefined ? defaultToMs : ceilToBucket(requestedToMs);
187
343
  const requestedFromMs = request.from === undefined
188
344
  ? toMs - USAGE_BOUNDS.defaultWindowDays * DAY_MS
189
345
  : Date.parse(request.from);
190
346
  if (!Number.isFinite(requestedFromMs)) {
191
347
  return { ok: false, refusal: "window-unreadable" };
192
348
  }
193
- const fromMs = utcMidnightMs(requestedFromMs);
349
+ const fromMs = floorToBucket(requestedFromMs);
194
350
  if (fromMs >= toMs)
195
351
  return { ok: false, refusal: "window-inverted" };
196
352
  if (toMs - fromMs > USAGE_BOUNDS.maxWindowDays * DAY_MS) {
197
353
  return { ok: false, refusal: "window-too-wide" };
198
354
  }
199
- return { fromMs, ok: true, toMs };
355
+ // **The bucket count, not the read.** The read is one indexed range scan over
356
+ // `occurrence_usage` at any width — `terminal_state` is an INCLUDE column on
357
+ // the index it uses, so the whole answer comes from the index and the rows it
358
+ // touches depend on the window rather than on how they are grouped. What
359
+ // grows with a finer bucket is the *answer*, and a year of hours is 8,784
360
+ // rows nobody charts. Refused rather than truncated: a quietly short answer
361
+ // is one a caller acts on.
362
+ if ((toMs - fromMs) / bucketMs > USAGE_BOUNDS.maxBuckets) {
363
+ return { ok: false, refusal: "too-many-buckets" };
364
+ }
365
+ return { bucketSeconds, fromMs, ok: true, toMs };
200
366
  }