@pithy-sh/core 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +54 -0
  2. package/dist/audit/auditEvent.js +1 -1
  3. package/dist/audit/chain.d.ts +289 -0
  4. package/dist/audit/chain.d.ts.map +1 -0
  5. package/dist/audit/chain.js +400 -0
  6. package/dist/audit/citations.d.ts +52 -0
  7. package/dist/audit/citations.d.ts.map +1 -0
  8. package/dist/audit/citations.js +13 -0
  9. package/dist/capability/bindings.d.ts +22 -0
  10. package/dist/capability/bindings.d.ts.map +1 -1
  11. package/dist/capability/bindings.js +32 -2
  12. package/dist/capability/capability.d.ts +123 -16
  13. package/dist/capability/capability.d.ts.map +1 -1
  14. package/dist/capability/capability.js +17 -2
  15. package/dist/capability/erasure.d.ts +75 -0
  16. package/dist/capability/erasure.d.ts.map +1 -0
  17. package/dist/capability/erasure.js +2 -0
  18. package/dist/capability/manifest.d.ts +5 -0
  19. package/dist/capability/manifest.d.ts.map +1 -1
  20. package/dist/capability/personColumns.d.ts +28 -0
  21. package/dist/capability/personColumns.d.ts.map +1 -0
  22. package/dist/capability/personColumns.js +66 -0
  23. package/dist/capability/personRows.d.ts +62 -0
  24. package/dist/capability/personRows.d.ts.map +1 -0
  25. package/dist/capability/personRows.js +59 -0
  26. package/dist/capability/purge.d.ts +86 -19
  27. package/dist/capability/purge.d.ts.map +1 -1
  28. package/dist/capability/purge.js +83 -5
  29. package/dist/capability/settings.d.ts +10 -0
  30. package/dist/capability/settings.d.ts.map +1 -1
  31. package/dist/capability/vectorIndex.d.ts +36 -0
  32. package/dist/capability/vectorIndex.d.ts.map +1 -0
  33. package/dist/capability/vectorIndex.js +25 -0
  34. package/dist/cli/commandCatalog.generated.d.ts +438 -3
  35. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  36. package/dist/cli/commandCatalog.generated.js +454 -2
  37. package/dist/cli/commandFacts.d.ts +8 -5
  38. package/dist/cli/commandFacts.d.ts.map +1 -1
  39. package/dist/cli/commandFacts.js +12 -3
  40. package/dist/consent/purpose.d.ts +70 -0
  41. package/dist/consent/purpose.d.ts.map +1 -0
  42. package/dist/consent/purpose.js +97 -0
  43. package/dist/consent/reader.d.ts +99 -0
  44. package/dist/consent/reader.d.ts.map +1 -0
  45. package/dist/consent/reader.js +33 -0
  46. package/dist/controlPlane/audit/actions.d.ts +15 -4
  47. package/dist/controlPlane/audit/actions.d.ts.map +1 -1
  48. package/dist/controlPlane/audit/actions.js +16 -5
  49. package/dist/controlPlane/capability.d.ts +10 -0
  50. package/dist/controlPlane/capability.d.ts.map +1 -1
  51. package/dist/controlPlane/capability.js +56 -8
  52. package/dist/controlPlane/context.d.ts +4 -0
  53. package/dist/controlPlane/context.d.ts.map +1 -1
  54. package/dist/controlPlane/context.js +3 -2
  55. package/dist/controlPlane/data/connection.d.ts +21 -2
  56. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  57. package/dist/controlPlane/data/connection.js +13 -4
  58. package/dist/controlPlane/data/connections.d.ts +94 -0
  59. package/dist/controlPlane/data/connections.d.ts.map +1 -0
  60. package/dist/controlPlane/data/connections.js +151 -0
  61. package/dist/controlPlane/data/hold.d.ts +46 -0
  62. package/dist/controlPlane/data/hold.d.ts.map +1 -0
  63. package/dist/controlPlane/data/hold.js +36 -0
  64. package/dist/controlPlane/data/oidcTrust.d.ts +122 -0
  65. package/dist/controlPlane/data/oidcTrust.d.ts.map +1 -0
  66. package/dist/controlPlane/data/oidcTrust.js +173 -0
  67. package/dist/controlPlane/data/tables.d.ts +4 -0
  68. package/dist/controlPlane/data/tables.d.ts.map +1 -1
  69. package/dist/controlPlane/data/tables.js +9 -4
  70. package/dist/controlPlane/data/trustFirstCall.d.ts +16 -0
  71. package/dist/controlPlane/data/trustFirstCall.d.ts.map +1 -0
  72. package/dist/controlPlane/data/trustFirstCall.js +25 -0
  73. package/dist/controlPlane/discovery/adminRoute.d.ts +165 -2
  74. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  75. package/dist/controlPlane/discovery/adminRoute.js +32 -8
  76. package/dist/controlPlane/discovery/routeInput.d.ts +23 -0
  77. package/dist/controlPlane/discovery/routeInput.d.ts.map +1 -0
  78. package/dist/controlPlane/discovery/routeInput.js +80 -0
  79. package/dist/controlPlane/discovery/toolName.d.ts +21 -0
  80. package/dist/controlPlane/discovery/toolName.d.ts.map +1 -0
  81. package/dist/controlPlane/discovery/toolName.js +25 -0
  82. package/dist/controlPlane/erasure/attest.d.ts +78 -0
  83. package/dist/controlPlane/erasure/attest.d.ts.map +1 -0
  84. package/dist/controlPlane/erasure/attest.js +164 -0
  85. package/dist/controlPlane/erasure/read.d.ts +39 -0
  86. package/dist/controlPlane/erasure/read.d.ts.map +1 -0
  87. package/dist/controlPlane/erasure/read.js +140 -0
  88. package/dist/controlPlane/error/errors.d.ts +61 -2
  89. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  90. package/dist/controlPlane/error/errors.js +13 -0
  91. package/dist/controlPlane/evidence/derive.d.ts +124 -0
  92. package/dist/controlPlane/evidence/derive.d.ts.map +1 -0
  93. package/dist/controlPlane/evidence/derive.js +343 -0
  94. package/dist/controlPlane/evidence/mapping.d.ts +85 -0
  95. package/dist/controlPlane/evidence/mapping.d.ts.map +1 -0
  96. package/dist/controlPlane/evidence/mapping.js +114 -0
  97. package/dist/controlPlane/evidence/pack.d.ts +46 -0
  98. package/dist/controlPlane/evidence/pack.d.ts.map +1 -0
  99. package/dist/controlPlane/evidence/pack.js +124 -0
  100. package/dist/controlPlane/http/connections.d.ts +96 -0
  101. package/dist/controlPlane/http/connections.d.ts.map +1 -0
  102. package/dist/controlPlane/http/connections.js +109 -0
  103. package/dist/controlPlane/http/guard.d.ts +23 -2
  104. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  105. package/dist/controlPlane/http/guard.js +69 -12
  106. package/dist/controlPlane/http/handlers.d.ts +212 -10
  107. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  108. package/dist/controlPlane/http/handlers.js +117 -26
  109. package/dist/controlPlane/http/holds.d.ts +57 -0
  110. package/dist/controlPlane/http/holds.d.ts.map +1 -0
  111. package/dist/controlPlane/http/holds.js +98 -0
  112. package/dist/controlPlane/http/presence.d.ts +27 -0
  113. package/dist/controlPlane/http/presence.d.ts.map +1 -0
  114. package/dist/controlPlane/http/presence.js +67 -0
  115. package/dist/controlPlane/http/processing.d.ts +73 -0
  116. package/dist/controlPlane/http/processing.d.ts.map +1 -0
  117. package/dist/controlPlane/http/processing.js +17 -0
  118. package/dist/controlPlane/http/responses.d.ts +927 -3
  119. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  120. package/dist/controlPlane/http/responses.js +283 -7
  121. package/dist/controlPlane/http/routes.d.ts +12 -2
  122. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  123. package/dist/controlPlane/http/routes.js +182 -5
  124. package/dist/controlPlane/http/schemas.d.ts +63 -0
  125. package/dist/controlPlane/http/schemas.d.ts.map +1 -1
  126. package/dist/controlPlane/http/schemas.js +39 -1
  127. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  128. package/dist/controlPlane/http/verify.js +13 -1
  129. package/dist/controlPlane/http/verifyTrust.d.ts +76 -0
  130. package/dist/controlPlane/http/verifyTrust.d.ts.map +1 -0
  131. package/dist/controlPlane/http/verifyTrust.js +75 -0
  132. package/dist/controlPlane/migrations/0004_connection_trusts.d.ts +15 -0
  133. package/dist/controlPlane/migrations/0004_connection_trusts.d.ts.map +1 -0
  134. package/dist/controlPlane/migrations/0004_connection_trusts.js +23 -0
  135. package/dist/controlPlane/migrations/0005_holds.d.ts +12 -0
  136. package/dist/controlPlane/migrations/0005_holds.d.ts.map +1 -0
  137. package/dist/controlPlane/migrations/0005_holds.js +26 -0
  138. package/dist/controlPlane/processing/derive.d.ts +77 -0
  139. package/dist/controlPlane/processing/derive.d.ts.map +1 -0
  140. package/dist/controlPlane/processing/derive.js +206 -0
  141. package/dist/controlPlane/processing/read.d.ts +10 -0
  142. package/dist/controlPlane/processing/read.d.ts.map +1 -0
  143. package/dist/controlPlane/processing/read.js +89 -0
  144. package/dist/controlPlane/scope/kind.d.ts +46 -0
  145. package/dist/controlPlane/scope/kind.d.ts.map +1 -0
  146. package/dist/controlPlane/scope/kind.js +52 -0
  147. package/dist/controlPlane/scope/scope.d.ts +58 -3
  148. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  149. package/dist/controlPlane/scope/scope.js +59 -4
  150. package/dist/controlPlane/token/jws.js +1 -1
  151. package/dist/controlPlane/token/mint.js +1 -1
  152. package/dist/controlPlane/wire.d.ts +23 -0
  153. package/dist/controlPlane/wire.d.ts.map +1 -1
  154. package/dist/controlPlane/wire.js +24 -1
  155. package/dist/createBackend.d.ts +13 -0
  156. package/dist/createBackend.d.ts.map +1 -1
  157. package/dist/createBackend.js +11 -3
  158. package/dist/createEntrypoint.d.ts +7 -0
  159. package/dist/createEntrypoint.d.ts.map +1 -1
  160. package/dist/createEntrypoint.js +4 -0
  161. package/dist/error/http.d.ts +168 -0
  162. package/dist/error/http.d.ts.map +1 -1
  163. package/dist/error/payload.d.ts +336 -0
  164. package/dist/error/payload.d.ts.map +1 -1
  165. package/dist/error/payload.js +112 -0
  166. package/dist/error/pithyError.d.ts +22 -0
  167. package/dist/error/pithyError.d.ts.map +1 -1
  168. package/dist/error/pithyError.js +31 -1
  169. package/dist/http/routeSpec.d.ts +46 -1
  170. package/dist/http/routeSpec.d.ts.map +1 -1
  171. package/dist/http/routeSpec.js +39 -5
  172. package/dist/http/verification.d.ts.map +1 -1
  173. package/dist/http/verification.js +1 -1
  174. package/dist/i18n/translator.d.ts +2 -1
  175. package/dist/i18n/translator.d.ts.map +1 -1
  176. package/dist/index.d.ts +1 -1
  177. package/dist/index.d.ts.map +1 -1
  178. package/dist/index.js +3 -3
  179. package/dist/logger/record.d.ts +98 -0
  180. package/dist/logger/record.d.ts.map +1 -1
  181. package/dist/mcp/annotations.d.ts +20 -0
  182. package/dist/mcp/annotations.d.ts.map +1 -0
  183. package/dist/mcp/annotations.js +23 -0
  184. package/dist/mcp/tools.d.ts +75 -0
  185. package/dist/mcp/tools.d.ts.map +1 -0
  186. package/dist/mcp/tools.js +118 -0
  187. package/dist/openapi/document.d.ts.map +1 -1
  188. package/dist/openapi/document.js +8 -26
  189. package/dist/openapi/operations.d.ts +35 -0
  190. package/dist/openapi/operations.d.ts.map +1 -0
  191. package/dist/openapi/operations.js +37 -0
  192. package/dist/presence/channel.d.ts +11 -0
  193. package/dist/presence/channel.d.ts.map +1 -0
  194. package/dist/presence/channel.js +65 -0
  195. package/dist/presence/declaration.d.ts +154 -0
  196. package/dist/presence/declaration.d.ts.map +1 -0
  197. package/dist/presence/declaration.js +164 -0
  198. package/dist/presence/durableObject.d.ts +61 -0
  199. package/dist/presence/durableObject.d.ts.map +1 -0
  200. package/dist/presence/durableObject.js +257 -0
  201. package/dist/presence/frames.d.ts +166 -0
  202. package/dist/presence/frames.d.ts.map +1 -0
  203. package/dist/presence/frames.js +90 -0
  204. package/dist/presence/http/guard.d.ts +11 -0
  205. package/dist/presence/http/guard.d.ts.map +1 -0
  206. package/dist/presence/http/guard.js +20 -0
  207. package/dist/presence/http/responses.d.ts +31 -0
  208. package/dist/presence/http/responses.d.ts.map +1 -0
  209. package/dist/presence/http/responses.js +34 -0
  210. package/dist/presence/http/routes.d.ts +24 -0
  211. package/dist/presence/http/routes.d.ts.map +1 -0
  212. package/dist/presence/http/routes.js +127 -0
  213. package/dist/presence/http/schemas.d.ts +29 -0
  214. package/dist/presence/http/schemas.d.ts.map +1 -0
  215. package/dist/presence/http/schemas.js +28 -0
  216. package/dist/presence/names.d.ts +23 -0
  217. package/dist/presence/names.d.ts.map +1 -0
  218. package/dist/presence/names.js +22 -0
  219. package/dist/presence/room.d.ts +95 -0
  220. package/dist/presence/room.d.ts.map +1 -0
  221. package/dist/presence/room.js +105 -0
  222. package/dist/presence/ticket.d.ts +55 -0
  223. package/dist/presence/ticket.d.ts.map +1 -0
  224. package/dist/presence/ticket.js +109 -0
  225. package/dist/privacy/classify.js +1 -1
  226. package/dist/privacy/encrypted.js +1 -1
  227. package/dist/processing/activity.d.ts +134 -0
  228. package/dist/processing/activity.d.ts.map +1 -0
  229. package/dist/processing/activity.js +161 -0
  230. package/dist/processing/composed.d.ts +18 -0
  231. package/dist/processing/composed.d.ts.map +1 -0
  232. package/dist/processing/composed.js +28 -0
  233. package/dist/processing/vocabulary.d.ts +26 -0
  234. package/dist/processing/vocabulary.d.ts.map +1 -0
  235. package/dist/processing/vocabulary.js +35 -0
  236. package/dist/queue/consumer.d.ts +59 -0
  237. package/dist/queue/consumer.d.ts.map +1 -0
  238. package/dist/queue/consumer.js +46 -0
  239. package/dist/retention/composed.d.ts +37 -0
  240. package/dist/retention/composed.d.ts.map +1 -0
  241. package/dist/retention/composed.js +66 -0
  242. package/dist/retention/holds.d.ts +61 -0
  243. package/dist/retention/holds.d.ts.map +1 -0
  244. package/dist/retention/holds.js +107 -0
  245. package/dist/retention/holdsPurge.d.ts +22 -0
  246. package/dist/retention/holdsPurge.d.ts.map +1 -0
  247. package/dist/retention/holdsPurge.js +50 -0
  248. package/dist/retention/policy.d.ts +132 -0
  249. package/dist/retention/policy.d.ts.map +1 -0
  250. package/dist/retention/policy.js +197 -0
  251. package/dist/session/rotated.d.ts +34 -0
  252. package/dist/session/rotated.d.ts.map +1 -0
  253. package/dist/session/rotated.js +25 -0
  254. package/dist/test-utils/connectionFixtures.d.ts +13 -0
  255. package/dist/test-utils/connectionFixtures.d.ts.map +1 -0
  256. package/dist/test-utils/connectionFixtures.js +58 -0
  257. package/dist/test-utils/interleave.d.ts +12 -0
  258. package/dist/test-utils/interleave.d.ts.map +1 -0
  259. package/dist/test-utils/interleave.js +26 -0
  260. package/dist/testWorker.d.ts +8 -0
  261. package/dist/testWorker.d.ts.map +1 -0
  262. package/dist/testWorker.js +9 -0
  263. package/dist/version.generated.d.ts +1 -1
  264. package/dist/version.generated.js +1 -1
  265. package/dist/workflow/dispatchRoute.js +1 -1
  266. package/package.json +1 -1
  267. package/src/audit/auditEvent.ts +1 -1
  268. package/src/audit/chain.ts +551 -0
  269. package/src/audit/citations.ts +63 -0
  270. package/src/capability/bindings.ts +61 -0
  271. package/src/capability/capability.ts +156 -19
  272. package/src/capability/erasure.ts +82 -0
  273. package/src/capability/personColumns.ts +83 -0
  274. package/src/capability/personRows.ts +125 -0
  275. package/src/capability/purge.ts +158 -21
  276. package/src/capability/settings.ts +7 -0
  277. package/src/capability/vectorIndex.ts +60 -0
  278. package/src/cli/commandCatalog.generated.ts +487 -2
  279. package/src/cli/commandFacts.ts +12 -3
  280. package/src/cloudflare-test.d.ts +3 -1
  281. package/src/consent/purpose.ts +160 -0
  282. package/src/consent/reader.ts +134 -0
  283. package/src/controlPlane/audit/actions.ts +15 -4
  284. package/src/controlPlane/capability.ts +99 -20
  285. package/src/controlPlane/context.ts +10 -2
  286. package/src/controlPlane/data/connection.ts +16 -2
  287. package/src/controlPlane/data/connections.ts +231 -0
  288. package/src/controlPlane/data/hold.ts +52 -0
  289. package/src/controlPlane/data/oidcTrust.ts +249 -0
  290. package/src/controlPlane/data/tables.ts +7 -0
  291. package/src/controlPlane/data/trustFirstCall.ts +37 -0
  292. package/src/controlPlane/discovery/adminRoute.ts +71 -9
  293. package/src/controlPlane/discovery/routeInput.ts +86 -0
  294. package/src/controlPlane/discovery/toolName.ts +23 -0
  295. package/src/controlPlane/erasure/attest.ts +205 -0
  296. package/src/controlPlane/erasure/read.ts +169 -0
  297. package/src/controlPlane/error/errors.ts +69 -2
  298. package/src/controlPlane/evidence/derive.ts +426 -0
  299. package/src/controlPlane/evidence/mapping.ts +118 -0
  300. package/src/controlPlane/evidence/pack.ts +190 -0
  301. package/src/controlPlane/http/connections.ts +129 -0
  302. package/src/controlPlane/http/guard.ts +118 -9
  303. package/src/controlPlane/http/handlers.ts +172 -40
  304. package/src/controlPlane/http/holds.ts +108 -0
  305. package/src/controlPlane/http/presence.ts +71 -0
  306. package/src/controlPlane/http/processing.ts +19 -0
  307. package/src/controlPlane/http/responses.ts +590 -7
  308. package/src/controlPlane/http/routes.ts +302 -3
  309. package/src/controlPlane/http/schemas.ts +126 -0
  310. package/src/controlPlane/http/verify.ts +19 -2
  311. package/src/controlPlane/http/verifyTrust.ts +176 -0
  312. package/src/controlPlane/migrations/0004_connection_trusts.ts +30 -0
  313. package/src/controlPlane/migrations/0005_holds.ts +40 -0
  314. package/src/controlPlane/processing/derive.ts +359 -0
  315. package/src/controlPlane/processing/read.ts +102 -0
  316. package/src/controlPlane/scope/kind.ts +63 -0
  317. package/src/controlPlane/scope/scope.ts +65 -3
  318. package/src/controlPlane/wire.ts +26 -0
  319. package/src/createBackend.ts +31 -6
  320. package/src/createEntrypoint.ts +17 -0
  321. package/src/error/payload.ts +234 -0
  322. package/src/error/pithyError.ts +42 -0
  323. package/src/http/routeSpec.ts +75 -5
  324. package/src/http/verification.ts +3 -1
  325. package/src/i18n/translator.ts +2 -1
  326. package/src/index.ts +2 -0
  327. package/src/mcp/annotations.ts +38 -0
  328. package/src/mcp/tools.ts +220 -0
  329. package/src/openapi/document.ts +8 -28
  330. package/src/openapi/operations.ts +62 -0
  331. package/src/presence/channel.ts +46 -0
  332. package/src/presence/declaration.ts +283 -0
  333. package/src/presence/durableObject.ts +287 -0
  334. package/src/presence/frames.ts +153 -0
  335. package/src/presence/http/guard.ts +23 -0
  336. package/src/presence/http/responses.ts +63 -0
  337. package/src/presence/http/routes.ts +165 -0
  338. package/src/presence/http/schemas.ts +39 -0
  339. package/src/presence/names.ts +45 -0
  340. package/src/presence/room.ts +145 -0
  341. package/src/presence/ticket.ts +145 -0
  342. package/src/processing/activity.ts +289 -0
  343. package/src/processing/composed.ts +33 -0
  344. package/src/processing/vocabulary.ts +43 -0
  345. package/src/queue/consumer.ts +104 -0
  346. package/src/retention/composed.ts +75 -0
  347. package/src/retention/holds.ts +175 -0
  348. package/src/retention/holdsPurge.ts +50 -0
  349. package/src/retention/policy.ts +305 -0
  350. package/src/session/rotated.ts +53 -0
  351. package/src/test-utils/connectionFixtures.ts +58 -0
  352. package/src/test-utils/interleave.ts +31 -0
  353. package/src/testWorker.ts +13 -0
  354. package/src/version.generated.ts +1 -1
@@ -2,7 +2,10 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { PresenceRoomDescriptor } from "../../presence/http/responses";
5
6
  import { PrivacyPosture } from "../../privacy/posture";
7
+ import { EffectiveRetention } from "../../retention/policy";
8
+ import { ScopeKind } from "../scope/kind";
6
9
  import { ControlPlaneScope } from "../scope/scope";
7
10
  import { ManifestConfigKey, ManifestConfigValues } from "./configuration";
8
11
  import { CapabilityHealthReport, HealthSummary, HealthSummaryKey, healthReport, healthWire } from "./healthSummary";
@@ -32,6 +35,35 @@ export const AdminRouteMethod = z
32
35
  .describe("The HTTP method this admin route answers on.");
33
36
  export type AdminRouteMethod = z.infer<typeof AdminRouteMethod>;
34
37
 
38
+ /**
39
+ * An admin route's operation id (`docs/AGENT-ACCESS.md` §5): lowercase words joined by `.`, `_` or `-`.
40
+ *
41
+ * **Permanent once published.** An agent names a tool in prompts, saved workflows and its own record of what
42
+ * worked, so a renamed operation breaks callers silently — the same class of rule as a role name. Unique within
43
+ * its capability; `packages/cli/src/ci/adminOperations.test.ts` holds every kit capability's to a table.
44
+ */
45
+ export const AdminOperation = z
46
+ .string()
47
+ .max(64)
48
+ .regex(
49
+ /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/,
50
+ "An operation is lowercase words joined by `.`, `_` or `-`, e.g. `threads.reply`.",
51
+ )
52
+ .describe("An admin route's permanent name within its capability, e.g. `threads.reply`.");
53
+ export type AdminOperation = z.infer<typeof AdminOperation>;
54
+
55
+ /**
56
+ * An admin route's input as JSON Schema: an object whose `path`, `query` and `body` properties are each
57
+ * the JSON Schema of the validator the route runs on that part of the request.
58
+ *
59
+ * A record rather than a modeled schema, because it is JSON Schema — a vocabulary this manifest relays, never
60
+ * one it defines — and an MCP client publishes it verbatim as a tool's `inputSchema`.
61
+ */
62
+ export const AdminRouteInput = z
63
+ .record(z.string().describe("A JSON Schema keyword."), z.unknown().describe("Its value."))
64
+ .describe("A JSON Schema object describing the route's path parameters, query and body.");
65
+ export type AdminRouteInput = z.infer<typeof AdminRouteInput>;
66
+
35
67
  /** One admin route a capability contributes behind the `control-plane` strategy. */
36
68
  export const AdminRoute = z
37
69
  .object({
@@ -49,12 +81,31 @@ export const AdminRoute = z
49
81
  .string()
50
82
  .min(1)
51
83
  .describe("One line describing what the route does, for a management client to render beside it."),
84
+ // Optional on the wire: a client reading a Worker that states no kind treats the route as the most restrictive.
85
+ kind: ScopeKind.optional().describe(
86
+ "What kind of operation the route's scope grants — `reads`, `writes`, `sends`, `charges`, `deletes` or `credentials` — declared by the capability that owns the scope. Every route sharing a scope declares the same kind, so a management client maps its own roles onto kinds rather than onto scopes. Absent for a route with no scope, and from an older Worker, which a client treats as `credentials`, the most restrictive.",
87
+ ),
88
+ // Optional: absent says *this Worker predates operations*, and a client that names tools from them offers no
89
+ // tool for such a route rather than guessing a name.
90
+ operation: AdminOperation.optional().describe(
91
+ "The route's permanent name within its capability — `threads.reply`, `users.suspend` — declared beside the route by the capability that owns it. Never derived from the path, which moves when an adopter moves a `basePath`, and never from the scope, which several routes share. An MCP tool is named `<capability>_<operation>`. Absent from a Worker that predates #723.",
92
+ ),
93
+ input: AdminRouteInput.optional().describe(
94
+ "What the route takes, as one JSON Schema object with up to three properties — `path`, `query` and `body` — generated from the Zod schemas the route validates with, so it cannot drift from what the route accepts. Filled in by the seam from the composed router, never declared by hand. Absent from a Worker that predates #723.",
95
+ ),
52
96
  })
53
97
  .describe(
54
98
  "One admin route, described well enough for a management client to call it without holding any capability-specific knowledge.",
55
99
  );
56
100
  export type AdminRoute = z.infer<typeof AdminRoute>;
57
101
 
102
+ /**
103
+ * An admin route as a capability declares it: a scoped route states its kind, and a route with no scope has none.
104
+ * `Capability.adminRoutes` takes this, so a scoped route without a kind does not compile.
105
+ */
106
+ export type AdminRouteDeclaration = Omit<AdminRoute, "scope" | "kind"> &
107
+ ({ scope: ControlPlaneScope; kind: ScopeKind } | { scope: null; kind?: undefined });
108
+
58
109
  /**
59
110
  * One composed capability's declaration — everything about it that is the same for every caller.
60
111
  *
@@ -75,7 +126,7 @@ export const CapabilityDeclaration = z
75
126
  "The npm version of the package supplying this capability, or null where there is none — the adopter's own `app` capability has a name and no package. Reported per capability and never aggregated: a project composes some capabilities and not others, so only the intersection of what it composes and what changed is worth reporting. Joined against a release feed on the `package` beside it, never on `name` — the two are different kinds of thing.",
76
127
  ),
77
128
  // **Defaulted, like every field added after the first manifest shipped**, and for the reason written
78
- // out below against `healthKeys`: a Worker deployed before #626 sends no package, and a required
129
+ // out below against `healthKeys`: an older Worker sends no package, and a required
79
130
  // field would cost that client the whole manifest rather than the one key it did not get.
80
131
  package: z
81
132
  .string()
@@ -93,14 +144,14 @@ export const CapabilityDeclaration = z
93
144
  // version on purpose — a client dispatches on what the Worker declares right now — so absence is the
94
145
  // only way a new field can ship without breaking every Worker deployed before it. A required one fails
95
146
  // at the object level, which costs the client the *whole* manifest rather than the part it did not
96
- // know about, and every pane goes dark for an adopter whose only mistake was not upgrading yet (#352).
147
+ // know about, and every pane goes dark for an adopter whose only mistake was not upgrading yet.
97
148
  healthKeys: z
98
149
  .array(HealthSummaryKey)
99
150
  .default([])
100
151
  .describe(
101
152
  "The closed vocabulary of scalars this capability may report about its own state, or empty when it reports none — including when the Worker predates this field and says nothing at all. Declared alongside the routes so a client can render a key it has never heard of from what the Worker says about it — and so a *withheld* number is visible as a key with no value, rather than as silence.",
102
153
  ),
103
- // Both defaulted, for the reason stated above `healthKeys` and with the same force (#422). The two
154
+ // Both defaulted, for the reason stated above `healthKeys` and with the same force. The two
104
155
  // arrived together and travel together: a declaration with no value is a control with nothing behind
105
156
  // it, and a value with no declaration is a fact a client can only guess the meaning of.
106
157
  configKeys: z
@@ -112,6 +163,14 @@ export const CapabilityDeclaration = z
112
163
  config: ManifestConfigValues.default({}).describe(
113
164
  "What each declared fact resolved to for this deployment. The same answer for every caller, read off the capability's parsed config at assembly: there is no producer here, so no failure state, no per-request cost, and no staleness beyond the manifest itself.",
114
165
  ),
166
+ // Optional rather than defaulted: absent says *this Worker predates presence rooms*, which a client must not
167
+ // read as *this capability has none*.
168
+ presenceRooms: z
169
+ .array(PresenceRoomDescriptor)
170
+ .optional()
171
+ .describe(
172
+ "The presence room kinds this capability serves — who else has one of its records open, live — or empty when it serves none. Absent from a Worker that predates presence rooms. Mint a ticket into one at `POST /control-plane/presence/tickets` with the kind's own `scope`.",
173
+ ),
115
174
  })
116
175
  .describe(
117
176
  "One capability this Worker composes, the package and version it is at, the admin surface it exposes, the summary it may report, and the configured facts a client must respect to call any of it.",
@@ -123,12 +182,12 @@ export type CapabilityDeclaration = z.infer<typeof CapabilityDeclaration>;
123
182
  *
124
183
  * Two flat fields, and neither is what a consumer reads: {@link CapabilityDescriptor} decodes them into
125
184
  * one value that says which of the four states it is in. They are flat here because the wire has an
126
- * older half to keep working. A Worker deployed before #350 sends no `healthUnavailable` and a Worker
127
- * deployed before #317 sends neither field, and both must still parse; and a client pinned to a build
185
+ * older half to keep working. An older Worker may send no `healthUnavailable`, or neither
186
+ * field, and both must still parse; and a client pinned to a build
128
187
  * older than the Worker it is reading strips the field it has never heard of and lands on `health:
129
188
  * null`, which renders as silence rather than as a zero. Putting the fourth state *inside* `health`
130
189
  * would have cost that client the whole manifest for one capability's bad afternoon, which is the
131
- * failure #352 is about.
190
+ * failure the defaults above exist to prevent.
132
191
  */
133
192
  const CapabilityDescriptorWire = CapabilityDeclaration.extend({
134
193
  // Defaulted for the same reason as `healthKeys` above, and it lands on the meaning it already had: a
@@ -185,7 +244,7 @@ export const CapabilityDescriptor = z
185
244
  // had: a capability that declares no summary, and one that states no configured fact.
186
245
  //
187
246
  // **The `map` is load-bearing for the compiler and inert at run time, and both halves are the
188
- // point (#471).** `HealthSummaryKey.nominal` is `.default(null)`, so the key's input type has it
247
+ // point.** `HealthSummaryKey.nominal` is `.default(null)`, so the key's input type has it
189
248
  // optional and its output type has it present — and this encode sits exactly on that seam, so
190
249
  // spreading the keys through unchanged does not compile.
191
250
  //
@@ -214,7 +273,7 @@ export type CapabilityDescriptor = z.infer<typeof CapabilityDescriptor>;
214
273
  * now — so a capability that changed its paths or its scopes reports the change directly, and a schema
215
274
  * version would be a second source of truth to keep in sync with the first. That reasoning is unchanged.
216
275
  *
217
- * **And `capabilities[].version` travels with the package it belongs to** (#626). A version is only
276
+ * **And `capabilities[].version` travels with the package it belongs to**. A version is only
218
277
  * actionable joined against a release feed, a feed is keyed by package name, and a capability name is
219
278
  * not one — `controlplane` ships inside `@pithy-sh/core`. Reporting the version alone left every client
220
279
  * guessing the key, and the guess failed silently on the most frequently released package there is.
@@ -228,7 +287,7 @@ export type CapabilityDescriptor = z.infer<typeof CapabilityDescriptor>;
228
287
  * we just fixed", and "does this project predate the capability a pane needs". Reporting only one leaves
229
288
  * half the questions unanswerable, and they are the halves people actually ask.
230
289
  *
231
- * **And each entry carries a third thing: the configured facts that capability states** (#422). Knowing
290
+ * **And each entry carries a third thing: the configured facts that capability states**. Knowing
232
291
  * where a route is and which scope it needs is not always enough to call it — `POST
233
292
  * {base}/entitlements/grant` names a holder, and whether this project's holders are people or
234
293
  * organizations is a decision the adopter made in config. A client that cannot read the decision guesses
@@ -257,6 +316,9 @@ export const ControlPlaneManifest = z
257
316
  privacy: PrivacyPosture.optional().describe(
258
317
  "What this project declares about privacy and what its running code holds: classes and counts, never field names (`GET /control-plane/privacy` has those, behind `privacy:read`). Present on every Worker from #132, `declared` explicitly empty when nothing was declared; absent from an older Worker.",
259
318
  ),
319
+ retention: EffectiveRetention.optional().describe(
320
+ "How long this Worker keeps deleted accounts, the audit trail and each class of personal data, as its `retention` block takes effect: every window in days and where it came from. Absent from an older Worker.",
321
+ ),
260
322
  capabilities: z
261
323
  .array(CapabilityDescriptor)
262
324
  .describe(
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { requestContractOf } from "../../http/validation";
5
+ import { jsonSchema, type RouteGroup } from "../../openapi/document";
6
+ import { operationsOf } from "../../openapi/operations";
7
+ import type { AdminRoute, AdminRouteInput, CapabilityDeclaration } from "./adminRoute";
8
+
9
+ /**
10
+ * What each admin route takes, read off the composed router (#723, `docs/AGENT-ACCESS.md` §3).
11
+ *
12
+ * **Generated, never declared.** A route already states what it accepts on its own line — `validate("param",
13
+ * …)`, `validate("query", …)`, `validate("json", …)` — and that is the schema the request is actually parsed
14
+ * with. So the manifest's `input` is that schema, converted once, rather than a second description a capability
15
+ * would have to keep in step with the first. A route that changes its body changes its tool on the same commit.
16
+ *
17
+ * **One object, three parts.** An MCP tool takes one argument object, and a route reads three places, so the
18
+ * input is an object with a `path`, a `query` and a `body` property — each present only when the route
19
+ * validates that part. A client splits the arguments back out along the same three keys. A route that
20
+ * validates nothing still gets an input: an empty object, which says *this takes nothing* rather than *this
21
+ * Worker cannot say*.
22
+ */
23
+
24
+ /** The three parts of a request an admin route's input may carry, and the validator target each comes from. */
25
+ const PARTS = { param: "path", query: "query", json: "body" } as const;
26
+
27
+ /**
28
+ * The JSON Schema of one route's input, built from the request contracts on its handlers. The app's own MCP tools
29
+ * (`../../mcp/tools.ts`) take their input from here too, so a tool on either server has one shape.
30
+ */
31
+ export function inputOf(handlers: readonly unknown[]): AdminRouteInput {
32
+ const properties: Record<string, unknown> = {};
33
+ const required: string[] = [];
34
+ for (const handler of handlers) {
35
+ const contract = requestContractOf(handler);
36
+ if (contract === undefined) continue;
37
+ const part = PARTS[contract.target as keyof typeof PARTS];
38
+ if (part === undefined) continue;
39
+ properties[part] = jsonSchema(contract.schema, "input");
40
+ // A path parameter is always present. A query arrives as an object even when the URL carries none, so it is
41
+ // required only when its schema refuses an empty one; a body is required when its schema refuses none at all.
42
+ const absent = part === "query" ? {} : undefined;
43
+ if (part === "path" || !contract.schema.safeParse(absent).success) required.push(part);
44
+ }
45
+ return {
46
+ type: "object",
47
+ properties,
48
+ ...(required.length > 0 ? { required } : {}),
49
+ additionalProperties: false,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Every routed operation's input, keyed `METHOD path` as the manifest's admin routes are. Routes the groups do
55
+ * not hold are simply absent — an admin route the router does not serve is `missingAdminRoutes`'s finding, not
56
+ * this module's.
57
+ */
58
+ export function routeInputs(groups: readonly RouteGroup[]): ReadonlyMap<string, AdminRouteInput> {
59
+ const inputs = new Map<string, AdminRouteInput>();
60
+ for (const group of groups) {
61
+ for (const operation of operationsOf(group.routes)) {
62
+ inputs.set(`${operation.method.toUpperCase()} ${operation.path}`, inputOf(operation.handlers));
63
+ }
64
+ }
65
+ return inputs;
66
+ }
67
+
68
+ /**
69
+ * The declarations with each admin route's `input` filled in from the router.
70
+ *
71
+ * Without route groups — an app `createBackend` did not assemble — the declarations go out as they are, and a
72
+ * client offers no tool for a route it was told nothing about. That is the same answer an older Worker gets.
73
+ */
74
+ export function withRouteInputs(
75
+ declarations: readonly CapabilityDeclaration[],
76
+ inputs: ReadonlyMap<string, AdminRouteInput> | undefined,
77
+ ): CapabilityDeclaration[] {
78
+ if (inputs === undefined) return [...declarations];
79
+ return declarations.map((declaration) => ({
80
+ ...declaration,
81
+ adminRoutes: declaration.adminRoutes.map((route): AdminRoute => {
82
+ const input = inputs.get(`${route.method} ${route.path}`);
83
+ return input === undefined ? route : { ...route, input };
84
+ }),
85
+ }));
86
+ }
@@ -0,0 +1,23 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The one place an MCP tool name is made (`docs/AGENT-ACCESS.md` §5) — for the control-plane server's tools, and
6
+ * for the app's own MCP endpoint's (`../../mcp/tools.ts`), so one rule names both servers' tools.
7
+ *
8
+ * **Derived, never hand-written.** A tool is `<capability>_<operation>`: the capability's name, which already
9
+ * partitions migrations, tables and error codes, and the operation the capability declared beside the route. Two
10
+ * capabilities both exposing `threads.list` cannot collide, and nothing about the route's path or scope — both of
11
+ * which move or are shared — reaches the name.
12
+ *
13
+ * **Neither existing separator survives.** MCP names conventionally allow `[a-z0-9_-]`, so a scope's `:`, an
14
+ * audit action's `/` and an operation's `.` all become `_`. Anything else outside that alphabet does too, so a
15
+ * name is always one a client accepts — but a kit operation never needs it, and `AdminOperation` refuses one that
16
+ * would.
17
+ *
18
+ * **Once published, a tool name is permanent.** An agent references it in prompts and saved workflows, so this
19
+ * function's output for a given pair must never change. `toolName.test.ts` pins it.
20
+ */
21
+ export function controlPlaneToolName(capability: string, operation: string): string {
22
+ return `${capability}_${operation}`.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
23
+ }
@@ -0,0 +1,205 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AuditCitation } from "../../audit/citations";
5
+ import type { ErasureAuditActions, ErasureHistoryRow } from "../../capability/erasure";
6
+ import type { PurgeSubject } from "../../capability/purge";
7
+ import type {
8
+ ErasureAttestation,
9
+ ErasureCitation,
10
+ ErasurePeriodEntry,
11
+ ErasureState,
12
+ ErasureStepView,
13
+ } from "../http/responses";
14
+
15
+ /**
16
+ * **The erasure attestation, assembled (#823)** — pure functions over a subject's lifecycle rows and the audit events
17
+ * that name it, so every rule here is tested without a database.
18
+ *
19
+ * ## Which deletion
20
+ *
21
+ * A subject can be deleted, restored, and deleted again. The attestation is about the **deletion in force**: the
22
+ * first `deleted` row after the last `restored`. A subject whose last act was a restore has no erasure to attest.
23
+ * A hard deletion that brought a soft one forward writes a second `deleted` row in the same cycle, so the deletion
24
+ * reads as `hard` and comes due when the later row says.
25
+ *
26
+ * ## Where the purge stands
27
+ *
28
+ * `complete` once a `purged` row is written. `failed` when the last thing the purge wrote was its failure — a later
29
+ * pass that runs a step again puts it back `in_progress`. `scheduled` while nothing has run and the window is still
30
+ * open. `in_progress` otherwise. **Overdue** is not a state: a purge not complete two days after it came due has
31
+ * missed a daily pass, whatever it last wrote, and is named with the step it stopped at.
32
+ *
33
+ * ## A step run twice
34
+ *
35
+ * The daily pass is the purge's outer retry, so a failed purge's steps run again the next day and record again.
36
+ * Each capability is shown once, in the order it first ran, as it last finished.
37
+ */
38
+
39
+ /** How long after coming due a purge that has not completed is overdue: two daily passes. */
40
+ export const ERASURE_OVERDUE_AFTER_MS = 2 * 86_400_000;
41
+
42
+ /** The step a purge that has run nothing is named as having stopped at. */
43
+ export const ERASURE_NOT_STARTED = "due";
44
+
45
+ /** The rows of the deletion in force, or `null` when there is none — never deleted, or restored since. */
46
+ export function deletionInForce(rows: readonly ErasureHistoryRow[]): ErasureHistoryRow[] | null {
47
+ let start = -1;
48
+ for (let index = 0; index < rows.length; index += 1) {
49
+ const row = rows[index];
50
+ if (row?.action === "restored") start = -1;
51
+ else if (row?.action === "deleted" && start === -1) start = index;
52
+ }
53
+ return start === -1 ? null : rows.slice(start);
54
+ }
55
+
56
+ /** A citation as the wire carries it. */
57
+ function cited(citation: AuditCitation | undefined): ErasureCitation | null {
58
+ if (!citation) return null;
59
+ return {
60
+ eventId: citation.eventId,
61
+ action: citation.action,
62
+ occurredAt: citation.occurredAt.toISOString(),
63
+ tenant: citation.tenant,
64
+ seq: citation.seq,
65
+ hash: citation.hash,
66
+ };
67
+ }
68
+
69
+ /** The newest citation matching, or undefined. */
70
+ function latest(citations: readonly AuditCitation[], match: (citation: AuditCitation) => boolean) {
71
+ let found: AuditCitation | undefined;
72
+ for (const citation of citations) {
73
+ if (match(citation) && (!found || citation.occurredAt.getTime() >= found.occurredAt.getTime())) found = citation;
74
+ }
75
+ return found;
76
+ }
77
+
78
+ /** What one subject's attestation is assembled from. */
79
+ export interface AttestInput {
80
+ /** The kind of subject. */
81
+ readonly subject: PurgeSubject;
82
+ /** Its id. */
83
+ readonly subjectId: string;
84
+ /** Its lifecycle rows, oldest first. */
85
+ readonly rows: readonly ErasureHistoryRow[];
86
+ /** The audit events naming it, or `null` when no audit is composed. */
87
+ readonly citations: readonly AuditCitation[] | null;
88
+ /** The actions its purge records. */
89
+ readonly actions: ErasureAuditActions;
90
+ /** Now. */
91
+ readonly now: Date;
92
+ }
93
+
94
+ /** One subject's attestation, or `null` when it has no deletion in force. */
95
+ export function attest(input: AttestInput): ErasureAttestation | null {
96
+ const cycle = deletionInForce(input.rows);
97
+ const deletion = cycle?.[0];
98
+ if (!cycle || !deletion) return null;
99
+ const deletions = cycle.filter((row) => row.action === "deleted");
100
+ const hard = deletions.some((row) => row.mode === "hard");
101
+ const mode = hard ? "hard" : (deletion.mode ?? null);
102
+ const purgeAfter = [...deletions].reverse().find((row) => row.purgeAfter)?.purgeAfter ?? null;
103
+
104
+ // Only events recorded since this deletion: an earlier cycle's events are about a deletion that was undone.
105
+ const citations = (input.citations ?? []).filter((each) => each.occurredAt.getTime() >= deletion.at.getTime());
106
+ const steps = new Map<string, ErasureHistoryRow>();
107
+ let activity: ErasureHistoryRow | undefined;
108
+ let purged: ErasureHistoryRow | undefined;
109
+ for (const row of cycle) {
110
+ if (row.action === "purge_step" || row.action === "purge_retained") {
111
+ // Re-inserting would move a re-run step to the end; setting keeps its first place with its last row.
112
+ steps.set(row.step ?? "", row);
113
+ activity = row;
114
+ } else if (row.action === "purge_failed") activity = row;
115
+ else if (row.action === "purged") purged = row;
116
+ }
117
+
118
+ const state = stateOf({ purged, activity, purgeAfter, now: input.now });
119
+ const overdue =
120
+ (state === "in_progress" || state === "failed") &&
121
+ purgeAfter !== null &&
122
+ input.now.getTime() - purgeAfter.getTime() > ERASURE_OVERDUE_AFTER_MS;
123
+ const stoppedAt = state === "failed" || overdue ? (activity?.step ?? ERASURE_NOT_STARTED) : null;
124
+
125
+ const views: ErasureStepView[] = [...steps.values()].map((row) => {
126
+ const capability = row.step ?? "";
127
+ const retained = row.action === "purge_retained";
128
+ return {
129
+ capability,
130
+ disposition: retained ? "retained" : "erased",
131
+ reason: retained ? row.reason : null,
132
+ summary: retained ? null : row.reason,
133
+ completedAt: row.at.toISOString(),
134
+ audit: cited(latest(citations, (each) => each.action === input.actions.step && each.step === capability)),
135
+ };
136
+ });
137
+
138
+ return {
139
+ subject: { type: input.subject, id: input.subjectId },
140
+ deletion: {
141
+ at: deletion.at.toISOString(),
142
+ actorType: deletion.actorType,
143
+ reason: deletion.reason,
144
+ mode,
145
+ purgeAfter: purgeAfter ? purgeAfter.toISOString() : null,
146
+ audit: cited(citations.find((each) => input.actions.deleted.includes(each.action))),
147
+ },
148
+ state,
149
+ overdue,
150
+ stoppedAt,
151
+ steps: views,
152
+ completedAt: purged ? purged.at.toISOString() : null,
153
+ completion: purged ? cited(latest(citations, (each) => each.action === input.actions.purged)) : null,
154
+ failure: state === "failed" ? cited(latest(citations, (each) => each.action === input.actions.failed)) : null,
155
+ trail: input.citations === null ? "absent" : "recorded",
156
+ };
157
+ }
158
+
159
+ /** Where a purge stands, from its last activity. See the file header. */
160
+ function stateOf(input: {
161
+ purged: ErasureHistoryRow | undefined;
162
+ activity: ErasureHistoryRow | undefined;
163
+ purgeAfter: Date | null;
164
+ now: Date;
165
+ }): ErasureState {
166
+ if (input.purged) return "complete";
167
+ if (input.activity?.action === "purge_failed") return "failed";
168
+ if (!input.activity && (input.purgeAfter === null || input.purgeAfter.getTime() > input.now.getTime())) {
169
+ return "scheduled";
170
+ }
171
+ return "in_progress";
172
+ }
173
+
174
+ /** One attestation, summarized for the period read. */
175
+ export function periodEntry(attestation: ErasureAttestation): ErasurePeriodEntry {
176
+ return {
177
+ subject: attestation.subject,
178
+ state: attestation.state,
179
+ overdue: attestation.overdue,
180
+ purgeAfter: attestation.deletion.purgeAfter,
181
+ completedAt: attestation.completedAt,
182
+ stoppedAt: attestation.stoppedAt,
183
+ };
184
+ }
185
+
186
+ /** The period's counts and the purges that need someone, from its entries. */
187
+ export function summarize(entries: readonly ErasurePeriodEntry[]) {
188
+ const counts = { scheduled: 0, inProgress: 0, complete: 0, failed: 0, overdue: 0 };
189
+ for (const entry of entries) {
190
+ if (entry.state === "scheduled") counts.scheduled += 1;
191
+ else if (entry.state === "in_progress") counts.inProgress += 1;
192
+ else if (entry.state === "complete") counts.complete += 1;
193
+ else counts.failed += 1;
194
+ if (entry.overdue) counts.overdue += 1;
195
+ }
196
+ const attention = entries.filter((entry) => entry.state === "failed" || entry.overdue);
197
+ return { counts, attention };
198
+ }
199
+
200
+ /** Every entry, oldest due first; a deletion recorded before #823 has no due instant and sorts last. */
201
+ export function byDue(entries: readonly ErasurePeriodEntry[]): ErasurePeriodEntry[] {
202
+ const key = (entry: ErasurePeriodEntry) =>
203
+ entry.purgeAfter ? Date.parse(entry.purgeAfter) : Number.MAX_SAFE_INTEGER;
204
+ return [...entries].sort((a, b) => key(a) - key(b) || a.subject.id.localeCompare(b.subject.id));
205
+ }
@@ -0,0 +1,169 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type AuditCitation, composedAuditCitations } from "../../audit/citations";
5
+ import type { Capability } from "../../capability/capability";
6
+ import type { ErasureRecords } from "../../capability/erasure";
7
+ import type { PurgeSubject } from "../../capability/purge";
8
+ import { NotFoundError, ValidationError } from "../../error/pithyError";
9
+ import type {
10
+ ControlPlaneErasurePeriodResponse,
11
+ ControlPlaneErasureSubjectResponse,
12
+ ErasurePeriodEntry,
13
+ } from "../http/responses";
14
+ import type { ErasurePeriodQuery } from "../http/schemas";
15
+ import { attest, byDue, periodEntry, summarize } from "./attest";
16
+
17
+ /**
18
+ * **The two erasure reads (#823), over whatever this Worker composes.**
19
+ *
20
+ * The rows live in the capability that owns each subject — auth for a user, organization for an organization —
21
+ * and are read through the {@link ErasureRecords} each declares. The events live in the audit trail and are read
22
+ * through the citation reader `@pithy-sh/audit` declares. Neither is imported: both are found among the composed
23
+ * capabilities, so a Worker with no organization attests users only and says so, and one with no audit attests from
24
+ * the lifecycle rows and says the trail is absent.
25
+ */
26
+
27
+ /** The default window: a quarter. */
28
+ export const ERASURE_DEFAULT_WINDOW_MS = 90 * 86_400_000;
29
+
30
+ /** The longest window one read covers: a year and a day, so a year's audit is one call. */
31
+ export const ERASURE_MAX_WINDOW_MS = 366 * 86_400_000;
32
+
33
+ /** How many subjects one history or citation read names at once — inside D1's bound-parameter limit. */
34
+ const CHUNK = 50;
35
+
36
+ /** What the reads need from the seam. */
37
+ export interface ErasureReadDeps {
38
+ /** Every composed capability. */
39
+ readonly composition: () => readonly Capability[];
40
+ /** The clock. */
41
+ readonly now: () => Date;
42
+ }
43
+
44
+ /** The capability recording each subject's purges, first declared wins. */
45
+ export function erasureProviders(capabilities: readonly Capability[]): Map<PurgeSubject, ErasureRecords> {
46
+ const providers = new Map<PurgeSubject, ErasureRecords>();
47
+ for (const capability of capabilities) {
48
+ const records = capability.erasureRecords;
49
+ if (records && !providers.has(records.subject)) providers.set(records.subject, records);
50
+ }
51
+ return providers;
52
+ }
53
+
54
+ /** The window a period read covers, defaulted and bounded. */
55
+ export function erasureWindow(query: ErasurePeriodQuery, now: Date): { since: Date; until: Date } {
56
+ const until = query.until ? new Date(query.until) : now;
57
+ const since = query.since ? new Date(query.since) : new Date(until.getTime() - ERASURE_DEFAULT_WINDOW_MS);
58
+ if (since.getTime() > until.getTime()) {
59
+ throw new ValidationError({
60
+ message: "`since` is after `until`.",
61
+ detail: `erasure window ${since.toISOString()} to ${until.toISOString()}`,
62
+ });
63
+ }
64
+ if (until.getTime() - since.getTime() > ERASURE_MAX_WINDOW_MS) {
65
+ throw new ValidationError({
66
+ message: "An erasure window is at most 366 days. Ask for a shorter one.",
67
+ detail: `erasure window ${since.toISOString()} to ${until.toISOString()}`,
68
+ });
69
+ }
70
+ return { since, until };
71
+ }
72
+
73
+ /** Every value in chunks of {@link CHUNK}. */
74
+ function chunks<T>(values: readonly T[]): T[][] {
75
+ const out: T[][] = [];
76
+ for (let index = 0; index < values.length; index += CHUNK) out.push(values.slice(index, index + CHUNK));
77
+ return out;
78
+ }
79
+
80
+ /** The audit events a subject's purge records, for these ids, or `null` with no audit composed. */
81
+ async function citationsFor(
82
+ capabilities: readonly Capability[],
83
+ env: Record<string, unknown>,
84
+ records: ErasureRecords,
85
+ ids: readonly string[],
86
+ ): Promise<AuditCitation[] | null> {
87
+ const reader = composedAuditCitations(capabilities, env);
88
+ if (!reader) return null;
89
+ const { deleted, step, failed, purged } = records.auditActions;
90
+ const actions = [...deleted, step, failed, purged];
91
+ const found: AuditCitation[] = [];
92
+ for (const resourceIds of chunks(ids)) {
93
+ found.push(...(await reader({ resourceType: records.resourceType, resourceIds, actions })));
94
+ }
95
+ return found;
96
+ }
97
+
98
+ /** `GET /control-plane/erasure/:subjectType/:subjectId`. */
99
+ export async function readErasureSubject(
100
+ env: Record<string, unknown>,
101
+ deps: ErasureReadDeps,
102
+ subject: PurgeSubject,
103
+ subjectId: string,
104
+ ): Promise<ControlPlaneErasureSubjectResponse> {
105
+ const capabilities = deps.composition();
106
+ const records = erasureProviders(capabilities).get(subject);
107
+ if (!records) {
108
+ throw new NotFoundError({
109
+ message: `This Worker records no erasures for ${subject === "user" ? "a user" : "an organization"}.`,
110
+ action: `Compose the capability that owns ${subject} purges in this Worker, then ask again.`,
111
+ detail: `no composed capability declares erasureRecords for ${subject}`,
112
+ });
113
+ }
114
+ const now = deps.now();
115
+ const rows = await records.read(env).history([subjectId]);
116
+ const citations = rows.length === 0 ? null : await citationsFor(capabilities, env, records, [subjectId]);
117
+ const attestation =
118
+ rows.length === 0 ? null : attest({ subject, subjectId, rows, citations, actions: records.auditActions, now });
119
+ if (!attestation) {
120
+ throw new NotFoundError({
121
+ message: "No erasure on record for this subject.",
122
+ action: "It was never deleted, or it was restored. Nothing has been erased.",
123
+ detail: `no deletion in force for ${subject} ${subjectId}`,
124
+ });
125
+ }
126
+ return { generatedAt: now.toISOString(), attestation };
127
+ }
128
+
129
+ /** `GET /control-plane/erasure?since&until`. */
130
+ export async function readErasurePeriod(
131
+ env: Record<string, unknown>,
132
+ deps: ErasureReadDeps,
133
+ query: ErasurePeriodQuery,
134
+ ): Promise<ControlPlaneErasurePeriodResponse> {
135
+ const now = deps.now();
136
+ const { since, until } = erasureWindow(query, now);
137
+ const providers = erasureProviders(deps.composition());
138
+ const entries: ErasurePeriodEntry[] = [];
139
+ for (const [subject, records] of providers) {
140
+ const reader = records.read(env);
141
+ const ids = await reader.subjectsIn(since, until);
142
+ for (const batch of chunks(ids)) {
143
+ const rows = await reader.history(batch);
144
+ for (const subjectId of batch) {
145
+ const attestation = attest({
146
+ subject,
147
+ subjectId,
148
+ rows: rows.filter((row) => row.subjectId === subjectId),
149
+ // The period read counts and names; the citations are the subject read's.
150
+ citations: null,
151
+ actions: records.auditActions,
152
+ now,
153
+ });
154
+ if (attestation) entries.push(periodEntry(attestation));
155
+ }
156
+ }
157
+ }
158
+ const purges = byDue(entries);
159
+ const { counts, attention } = summarize(purges);
160
+ return {
161
+ generatedAt: now.toISOString(),
162
+ since: since.toISOString(),
163
+ until: until.toISOString(),
164
+ subjects: [...providers.keys()],
165
+ counts,
166
+ attention,
167
+ purges,
168
+ };
169
+ }