@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
@@ -0,0 +1,176 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { WebhookUnverifiedError } from "../../error/pithyError";
5
+ import { type JwksCache, type OidcJwksFetch, verifyOidcToken } from "../../http/oidcWebhook";
6
+ import { ControlPlaneContext } from "../context";
7
+ import type { ControlPlaneConnection } from "../data/connection";
8
+ import { claimsMatch, findVerifyingTrust, observedClaims } from "../data/oidcTrust";
9
+ import {
10
+ ControlPlaneInsufficientScopeError,
11
+ ControlPlaneInvalidCredentialError,
12
+ ControlPlaneNotConnectedError,
13
+ } from "../error/errors";
14
+ import { type ControlPlaneRequirement, ControlPlaneScope, scopeCovers } from "../scope/scope";
15
+
16
+ /**
17
+ * Verify one call made under an OIDC trust (#722, `docs/AGENT-ACCESS.md` §6.2) — the second credential kind's
18
+ * half of the decision `verify.ts` makes for a key.
19
+ *
20
+ * ## Per call, and the call still names its scope
21
+ *
22
+ * An identity token proves **who**, never **what**. So every call presents the workload's token on
23
+ * `pithy-control-plane-oidc`, names the trust on `pithy-control-plane-trust`, and names the one scope it
24
+ * exercises on `pithy-control-plane-scope`; the token is verified against the trust on every call, and the
25
+ * scope is checked against the connection's grant exactly as a key's token is.
26
+ *
27
+ * **No exchange, deliberately.** Trading the token once for a short-lived credential would keep every
28
+ * property of the key path, and it would put a key on the workload's host — the custody this credential kind
29
+ * exists to remove. What remains is stated rather than hidden: a captured token replays against this one
30
+ * audience until it expires, which is the provider's lifetime, minutes, and the same bound as revocation's
31
+ * latency. There is no `jti` to spend, because one token serves every call in its job.
32
+ *
33
+ * ## The existing verifier, not a second one
34
+ *
35
+ * The token goes through `verifyOidcToken` from `core/src/http/oidcWebhook.ts`, with its ordering intact:
36
+ * the algorithm pinned before a key is touched, the key resolved by `kid` from the issuer's published set,
37
+ * the signature checked over the received segments, and only then a claim read. Its predicate is handed
38
+ * `() => true` on purpose — the issuer, audience and freshness are still its to check, and the claims match
39
+ * runs here, after, because a refusal here has to know **what** the token carried, which a boolean cannot say.
40
+ *
41
+ * ## One error, with one narrow exception
42
+ *
43
+ * Every failure raises {@link ControlPlaneInvalidCredentialError}, as on the key path, with the step in
44
+ * `detail`. The exception is what it carries, never what it says: a claim mismatch on a token whose signature,
45
+ * issuer and audience all verified records the claims it carried for the audit row. That is what makes a
46
+ * custom issuer workable and a profiled one self-correcting. Before those three verify, nothing is recorded.
47
+ */
48
+
49
+ /** What the trust path needs from the outside world. Every one is injectable, so every step is testable. */
50
+ export interface ControlPlaneTrustVerifyDeps {
51
+ /**
52
+ * This environment's connection, or null when none is registered. One per environment (`docs/CONTROL-PLANE.md`
53
+ * §15), so a call names only the trust, and the connection is the one the Worker's own environment owns.
54
+ */
55
+ loadEnvironmentConnection: () => Promise<ControlPlaneConnection | null>;
56
+ /** This Worker's environment name, checked against the connection's. */
57
+ environment: string;
58
+ /** The clock. */
59
+ now: () => Date;
60
+ /**
61
+ * Where the issuers' key sets are held between calls. Built once per seam, never per request: an anonymous
62
+ * caller naming a trust and an invented `kid` must cost a map lookup, not a round trip to the issuer.
63
+ */
64
+ jwksCache: JwksCache;
65
+ /** The HTTP seam the issuers' key endpoints are reached through. Defaults to the runtime's `fetch`. */
66
+ transport?: OidcJwksFetch;
67
+ /**
68
+ * Record the first allowed call under a trust, so a listing can stop saying *awaiting a first call*. Evidence
69
+ * rather than part of the decision: it runs after the call is allowed, once, and a failure is swallowed.
70
+ */
71
+ recordFirstCall: (connectionId: string, trustId: string, at: Date) => Promise<void>;
72
+ }
73
+
74
+ /** One inbound call under a trust, reduced to what verification needs. */
75
+ export interface ControlPlaneTrustCall {
76
+ /** The workload's OIDC token, from `pithy-control-plane-oidc`. */
77
+ token: string | undefined;
78
+ /** The trust the call names, from `pithy-control-plane-trust`. */
79
+ trustId: string | undefined;
80
+ /** The scope the call names, from `pithy-control-plane-scope`. */
81
+ scope: string | undefined;
82
+ /** What the route demands. */
83
+ requirement: ControlPlaneRequirement;
84
+ }
85
+
86
+ /** Raise the one credential error, naming the failing step for the log and nobody else. */
87
+ function deny(step: string): never {
88
+ throw new ControlPlaneInvalidCredentialError({ detail: step });
89
+ }
90
+
91
+ /** Verify one call under an OIDC trust, or throw. Returns the context the handler runs under. */
92
+ export async function verifyControlPlaneTrustCall(
93
+ call: ControlPlaneTrustCall,
94
+ deps: ControlPlaneTrustVerifyDeps,
95
+ ): Promise<ControlPlaneContext> {
96
+ // 1. Everything the call must name, before anything is loaded. A lookup an anonymous caller can buy with a
97
+ // header it did not finish writing is a lookup nobody needed.
98
+ if (!call.token) deny("no OIDC token presented");
99
+ if (!call.trustId) deny("an OIDC call named no trust");
100
+ const scope = ControlPlaneScope.safeParse(call.scope);
101
+ if (!scope.success) deny("an OIDC call named no well-formed scope");
102
+
103
+ // 2. The environment's connection, and the trust it names. Lookups, not grants.
104
+ const now = deps.now();
105
+ const connection = await deps.loadEnvironmentConnection();
106
+ if (!connection) {
107
+ throw new ControlPlaneNotConnectedError({ detail: `no connection registered in environment ${deps.environment}` });
108
+ }
109
+ if (connection.environment !== deps.environment) {
110
+ deny(`connection ${connection.id} is bound to ${connection.environment}, not ${deps.environment}`);
111
+ }
112
+ const trust = findVerifyingTrust(connection.trusts ?? [], call.trustId, now);
113
+ if (!trust) deny(`trust ${call.trustId} is not a live trust on connection ${connection.id}`);
114
+
115
+ // 3. The token, against the trust's issuer and audience, through the verifier the webhooks use. Its own
116
+ // errors for an unreachable key endpoint (502) and a misconfiguration (500) pass through: neither is the
117
+ // caller's, and both still refuse the call.
118
+ let claims: Record<string, unknown>;
119
+ try {
120
+ claims = await verifyOidcToken(call.token, {
121
+ issuers: [trust.issuer],
122
+ jwksUrl: trust.jwksUrl,
123
+ audience: trust.audience,
124
+ claims: () => true,
125
+ jwksCache: deps.jwksCache,
126
+ ...(deps.transport === undefined ? {} : { transport: deps.transport }),
127
+ now,
128
+ });
129
+ } catch (error) {
130
+ if (error instanceof WebhookUnverifiedError)
131
+ deny(`trust ${trust.trustId}: ${error.payload.detail ?? "unverified"}`);
132
+ throw error;
133
+ }
134
+
135
+ // 4. The subject constraint — the boundary. Signature, issuer and audience have verified, so the claims the
136
+ // token carried are recorded on the refusal; the response is the same one every other step gives.
137
+ if (!claimsMatch(trust.claims, claims)) {
138
+ throw new ControlPlaneInvalidCredentialError({
139
+ detail: `trust ${trust.trustId}: the token's claims do not match the trust's`,
140
+ refusedTrust: { connectionId: connection.id, trustId: trust.trustId, observedClaims: observedClaims(claims) },
141
+ });
142
+ }
143
+
144
+ // 5. Scope: what the call named, and what the adopter granted. The same check, and the same refusal, as a key's.
145
+ const subject = typeof claims.sub === "string" && claims.sub !== "" ? claims.sub : `trust:${trust.trustId}`;
146
+ if (!scopeCovers(call.requirement, scope.data, connection.scopes)) {
147
+ throw new ControlPlaneInsufficientScopeError({
148
+ detail: `required ${String(call.requirement)}; call named ${scope.data}; connection grants [${connection.scopes.join(", ")}]; trust ${trust.trustId}`,
149
+ caller: {
150
+ connectionId: connection.id,
151
+ subject,
152
+ keyId: trust.trustId,
153
+ credentialKind: "trust",
154
+ actorKind: connection.actorKind,
155
+ },
156
+ });
157
+ }
158
+
159
+ // 6. The first call this trust has answered, recorded once. Never part of the decision: the call is allowed.
160
+ if (trust.firstCallAt === null) {
161
+ await deps.recordFirstCall(connection.id, trust.trustId, now).catch(() => undefined);
162
+ }
163
+
164
+ return ControlPlaneContext.parse({
165
+ connectionId: connection.id,
166
+ environment: connection.environment,
167
+ issuer: trust.issuer,
168
+ subject,
169
+ scope: scope.data,
170
+ grantedScopes: connection.scopes,
171
+ keyId: trust.trustId,
172
+ tokenId: typeof claims.jti === "string" ? claims.jti : "",
173
+ credentialKind: "trust",
174
+ actorKind: connection.actorKind,
175
+ });
176
+ }
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * A connection's OIDC trusts — `#722`, the second credential kind beside a registered key.
9
+ *
10
+ * **Appended, not folded into `0001`.** The seam has shipped and adopters' databases hold rows written by it.
11
+ *
12
+ * **One JSON column beside `keys`, defaulting to an empty list.** The same reasons `keys` is one column rather
13
+ * than a child table: verifying a call stays a single read, and revoking a trust is one `UPDATE`. Not null,
14
+ * with `'[]'` as its default, so every existing row reads as a connection holding no trust — which is exactly
15
+ * what it is — and nothing is backfilled.
16
+ */
17
+ export const controlplane_0004_connection_trusts: Migration = {
18
+ up: async (db: Kysely<unknown>): Promise<void> => {
19
+ await db.schema
20
+ .alterTable("pithyControlplaneConnections")
21
+ .addColumn("trusts", "text", (column) => column.notNull().defaultTo("[]"))
22
+ .execute();
23
+ },
24
+
25
+ down: async (db: Kysely<unknown>): Promise<void> => {
26
+ // Dropped in place, for the reason `0002` gives: a rebuild would rewrite every row of a table holding live
27
+ // credentials. Every key on the row survives; the trusts go with their column.
28
+ await db.schema.alterTable("pithyControlplaneConnections").dropColumn("trusts").execute();
29
+ },
30
+ };
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * Legal holds. Appended: the seam has shipped and adopters' databases hold its rows.
9
+ *
10
+ * One row per hold, kept after it is released, so the table is the record of every hold that ever stood. Indexed on
11
+ * `(state, subject_type, subject_id)`, because the question every purge and prune asks is "is an active hold on this
12
+ * subject", and it is asked once per step.
13
+ */
14
+ export const controlplane_0005_holds: Migration = {
15
+ up: async (db: Kysely<unknown>): Promise<void> => {
16
+ await db.schema
17
+ .createTable("pithyControlplaneHolds")
18
+ .addColumn("id", "text", (c) => c.primaryKey())
19
+ .addColumn("subjectType", "text", (c) => c.notNull())
20
+ .addColumn("subjectId", "text")
21
+ .addColumn("reason", "text", (c) => c.notNull())
22
+ .addColumn("placedBy", "text", (c) => c.notNull())
23
+ .addColumn("placedAt", "integer", (c) => c.notNull())
24
+ .addColumn("state", "text", (c) => c.notNull())
25
+ .addColumn("releasedAt", "integer")
26
+ .addColumn("releasedBy", "text")
27
+ .addColumn("releaseReason", "text")
28
+ .execute();
29
+ await db.schema
30
+ .createIndex("pithyControlplaneHoldsSubjectIdx")
31
+ .on("pithyControlplaneHolds")
32
+ .columns(["state", "subjectType", "subjectId"])
33
+ .execute();
34
+ },
35
+
36
+ down: async (db: Kysely<unknown>): Promise<void> => {
37
+ await db.schema.dropIndex("pithyControlplaneHoldsSubjectIdx").execute();
38
+ await db.schema.dropTable("pithyControlplaneHolds").execute();
39
+ },
40
+ };
@@ -0,0 +1,359 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { ConsentPurpose } from "../../consent/purpose";
5
+ import type { ConsentPurposeStanding } from "../../consent/reader";
6
+ import type { PrivacyInventory } from "../../privacy/report";
7
+ import type { ResolvedActivity } from "../../processing/activity";
8
+ import type { RecipientSource } from "../../processing/vocabulary";
9
+ import type { EffectiveRetention } from "../../retention/policy";
10
+ import { EVIDENCE_CONTROLS, type EvidenceControlId, type EvidenceState } from "../evidence/mapping";
11
+ import type {
12
+ ControlPlaneProcessingResponse,
13
+ ProcessingConsentView,
14
+ ProcessingEntry,
15
+ ProcessingGap,
16
+ ProcessingRecipientView,
17
+ ProcessingRetentionView,
18
+ } from "../http/responses";
19
+
20
+ /**
21
+ * The record of processing activities, derived. Pure: `./read.ts` gathers what the Worker declares and records, and
22
+ * this turns it into the GDPR Article 30 record.
23
+ *
24
+ * **It describes the processing and changes none of it.** Every gap is named and none is filled in: an activity with
25
+ * no legal basis reads as having none, and the record never guesses one.
26
+ */
27
+
28
+ /** The evidence pack's controls that protect stored personal data, which every activity cites. */
29
+ export const PROCESSING_MEASURES = [
30
+ "data-protection",
31
+ "audit-logging",
32
+ "management-access",
33
+ "key-rotation",
34
+ "route-verification",
35
+ "csrf",
36
+ ] as const satisfies readonly EvidenceControlId[];
37
+
38
+ /** The platform every Worker runs on, which receives everything it serves and stores. */
39
+ export const CLOUDFLARE = "Cloudflare";
40
+
41
+ const CLOUDFLARE_RECEIVES =
42
+ "Every request this Worker serves, and everything it stores in D1, KV, R2 and Durable Objects.";
43
+
44
+ /** What the record knows of consent. */
45
+ export type ConsentInput =
46
+ /** The composed reader answered: every purpose in its catalog, with counts. */
47
+ | { readonly state: "read"; readonly standings: readonly ConsentPurposeStanding[] }
48
+ /** No consent capability is composed: purposes may be declared, and no grant can be recorded. */
49
+ | { readonly state: "not-composed"; readonly declared: readonly ConsentPurpose[] }
50
+ /**
51
+ * A reader is composed and its record could not be read: its whole catalog, read from memory, or null where the
52
+ * reader cannot list one.
53
+ */
54
+ | { readonly state: "unreadable"; readonly catalog: readonly ConsentPurpose[] | null };
55
+
56
+ /** A recipient a binding implies, on the capability that declares the binding. */
57
+ export interface BindingRecipient {
58
+ readonly name: string;
59
+ readonly receives: string;
60
+ readonly classes: readonly string[];
61
+ readonly capability: string;
62
+ }
63
+
64
+ /** Everything the derivation reads. */
65
+ export interface ProcessingInputs {
66
+ readonly activities: readonly ResolvedActivity[];
67
+ /** The privacy inventory, or null where it could not be walked. */
68
+ readonly inventory: PrivacyInventory | null;
69
+ /** The resolved retention, or null where the Worker recorded none. */
70
+ readonly retention: EffectiveRetention | null;
71
+ readonly consent: ConsentInput;
72
+ readonly bindingRecipients: readonly BindingRecipient[];
73
+ /** Each control's state from the evidence pack, or null where the connection may not read it. */
74
+ readonly measures: Readonly<Partial<Record<EvidenceControlId, EvidenceState>>> | null;
75
+ /** Whether a gap may name a stored table and field, which only a connection holding `privacy:read` may read. */
76
+ readonly fieldNames: boolean;
77
+ }
78
+
79
+ /** The record, without the envelope the route adds. */
80
+ export type DerivedProcessingRecord = Omit<ControlPlaneProcessingResponse, "environment" | "generatedAt" | "version">;
81
+
82
+ /** A capability shaped enough to say which bindings it needs. */
83
+ interface BindingDeclarer {
84
+ readonly name: string;
85
+ readonly requiredBindings: readonly { readonly type: string; readonly name: string }[];
86
+ }
87
+
88
+ /** Workers AI, once for each capability whose `ai` binding this Worker has. */
89
+ export function workersAiRecipients(
90
+ capabilities: readonly BindingDeclarer[],
91
+ env: Record<string, unknown>,
92
+ ): BindingRecipient[] {
93
+ return capabilities.flatMap((capability) =>
94
+ capability.requiredBindings
95
+ .filter((binding) => binding.type === "ai" && env[binding.name] != null)
96
+ .map((binding) => ({
97
+ name: "Cloudflare Workers AI",
98
+ receives: `What ${capability.name} sends for inference through the ${binding.name} binding.`,
99
+ classes: [],
100
+ capability: capability.name,
101
+ })),
102
+ );
103
+ }
104
+
105
+ /** The catalog's purposes, however the record learned of them, or null where it could learn none. */
106
+ function catalogOf(consent: ConsentInput): ReadonlyMap<string, ConsentPurpose> | null {
107
+ const purposes =
108
+ consent.state === "read"
109
+ ? consent.standings.map((entry) => entry.purpose)
110
+ : consent.state === "unreadable"
111
+ ? consent.catalog
112
+ : consent.declared;
113
+ return purposes === null ? null : new Map(purposes.map((purpose) => [purpose.id, purpose]));
114
+ }
115
+
116
+ /** A consent-based activity's purpose and its counts, where they are known. */
117
+ function consentView(id: string, consent: ConsentInput): ProcessingConsentView {
118
+ const purpose = catalogOf(consent)?.get(id);
119
+ const counted = consent.state === "read" ? consent.standings.find((entry) => entry.purpose.id === id) : undefined;
120
+ const zero = consent.state === "not-composed" && purpose !== undefined ? 0 : null;
121
+ return {
122
+ purpose: id,
123
+ noticeVersion: purpose?.noticeVersion ?? null,
124
+ explicit: purpose?.explicit ?? null,
125
+ grants: counted?.grants ?? zero,
126
+ withdrawals: counted?.withdrawals ?? zero,
127
+ standing: counted?.standing ?? zero,
128
+ };
129
+ }
130
+
131
+ /** The retention behind an activity's classes. */
132
+ function retentionView(
133
+ classes: readonly string[],
134
+ retention: EffectiveRetention | null,
135
+ ): ProcessingRetentionView | null {
136
+ if (retention === null) return null;
137
+ return {
138
+ classAges: retention.classAges.filter((age) => classes.includes(age.class)).map((age) => ({ ...age })),
139
+ purgeWindows: retention.purgeWindows.map((window) => ({ ...window })),
140
+ };
141
+ }
142
+
143
+ /** A recipient as the record shows it. */
144
+ function recipient(
145
+ name: string,
146
+ receives: string,
147
+ classes: readonly string[],
148
+ source: RecipientSource,
149
+ ): ProcessingRecipientView {
150
+ return { name, receives, classes: [...classes], source };
151
+ }
152
+
153
+ /** The gaps one activity has. */
154
+ function activityGaps(
155
+ activity: ResolvedActivity,
156
+ stored: ReadonlySet<string> | null,
157
+ catalog: ReadonlyMap<string, ConsentPurpose> | null,
158
+ ): ProcessingGap[] {
159
+ const gaps: ProcessingGap[] = [];
160
+ if (activity.legalBasis === null) {
161
+ gaps.push({
162
+ kind: "no-legal-basis",
163
+ subject: activity.id,
164
+ detail: "No legal basis is declared. State one on the activity, or in the processing block's overrides.",
165
+ });
166
+ }
167
+ if (activity.legalBasis === "consent") {
168
+ if (activity.consentPurpose === null) {
169
+ gaps.push({
170
+ kind: "no-consent-purpose",
171
+ subject: activity.id,
172
+ detail: "The basis is consent and no consent purpose is named.",
173
+ });
174
+ } else if (catalog !== null && !catalog.has(activity.consentPurpose)) {
175
+ gaps.push({
176
+ kind: "no-consent-purpose",
177
+ subject: activity.id,
178
+ detail: `The basis is consent, and no composed catalog declares the purpose ${activity.consentPurpose}.`,
179
+ });
180
+ }
181
+ }
182
+ if (stored !== null) {
183
+ const unstored = activity.classes.filter((each) => !stored.has(each));
184
+ if (unstored.length > 0) {
185
+ gaps.push({
186
+ kind: "class-not-stored",
187
+ subject: activity.id,
188
+ detail: `It claims ${unstored.join(", ")}, which no composed schema stores.`,
189
+ });
190
+ }
191
+ }
192
+ return gaps;
193
+ }
194
+
195
+ /** Stored classes no activity of their capability, and none of the adopter's, claims. Tables only with field names. */
196
+ function unclaimedGaps(
197
+ activities: readonly ResolvedActivity[],
198
+ inventory: PrivacyInventory,
199
+ fieldNames: boolean,
200
+ ): ProcessingGap[] {
201
+ const adopter = new Set(activities.filter((each) => each.declaredBy === "config").flatMap((each) => each.classes));
202
+ const byCapability = new Map<string, Map<string, Set<string>>>();
203
+ for (const schema of inventory.schemas) {
204
+ const claimed = new Set(
205
+ activities.filter((each) => each.declaredBy === schema.capability).flatMap((each) => each.classes),
206
+ );
207
+ for (const field of schema.fields) {
208
+ if (claimed.has(field.class) || adopter.has(field.class)) continue;
209
+ const classes = byCapability.get(schema.capability) ?? new Map<string, Set<string>>();
210
+ const tables = classes.get(field.class) ?? new Set<string>();
211
+ tables.add(schema.name);
212
+ classes.set(field.class, tables);
213
+ byCapability.set(schema.capability, classes);
214
+ }
215
+ }
216
+ return [...byCapability].flatMap(([capability, classes]) =>
217
+ [...classes].map(([cls, tables]) => ({
218
+ kind: "class-unclaimed" as const,
219
+ subject: capability,
220
+ detail: fieldNames
221
+ ? `It stores ${cls} in ${[...tables].join(", ")}, and no processing activity claims it.`
222
+ : `It stores ${cls}, and no processing activity claims it.`,
223
+ })),
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Every stored `phi` field no consent purpose in the catalog covers: field by field with field names, and otherwise
229
+ * once for each capability that stores one. Silent where the catalog is unknown.
230
+ */
231
+ function uncoveredPhi(
232
+ inventory: PrivacyInventory,
233
+ catalog: ReadonlyMap<string, ConsentPurpose> | null,
234
+ fieldNames: boolean,
235
+ ): ProcessingGap[] {
236
+ if (catalog === null) return [];
237
+ const covered = [...catalog.values()].some((purpose) => purpose.classes.includes("phi"));
238
+ if (covered) return [];
239
+ if (!fieldNames) {
240
+ const capabilities = new Set(
241
+ inventory.schemas
242
+ .filter((schema) => schema.fields.some((field) => field.class === "phi"))
243
+ .map((schema) => schema.capability),
244
+ );
245
+ return [...capabilities].map((capability) => ({
246
+ kind: "phi-not-covered" as const,
247
+ subject: capability,
248
+ detail: "It stores phi, and no consent purpose covers phi.",
249
+ }));
250
+ }
251
+ return inventory.schemas.flatMap((schema) =>
252
+ schema.fields
253
+ .filter((field) => field.class === "phi")
254
+ .map((field) => ({
255
+ kind: "phi-not-covered" as const,
256
+ subject: `${schema.capability}/${schema.name}.${field.path}`,
257
+ detail: "A phi field is stored, and no consent purpose covers phi.",
258
+ })),
259
+ );
260
+ }
261
+
262
+ /** Every purpose covering a class that holds no standing grant. Silent where the record could not be read. */
263
+ function grantlessPurposes(consent: ConsentInput): ProcessingGap[] {
264
+ if (consent.state === "unreadable") return [];
265
+ if (consent.state === "not-composed") {
266
+ return consent.declared
267
+ .filter((purpose) => purpose.classes.length > 0)
268
+ .map((purpose) => ({
269
+ kind: "no-standing-grant" as const,
270
+ subject: purpose.id,
271
+ detail: `It covers ${purpose.classes.join(", ")}, and no consent capability is composed to record a grant.`,
272
+ }));
273
+ }
274
+ return consent.standings
275
+ .filter((entry) => entry.purpose.classes.length > 0 && entry.standing === 0)
276
+ .map((entry) => ({
277
+ kind: "no-standing-grant" as const,
278
+ subject: entry.purpose.id,
279
+ detail: `It covers ${entry.purpose.classes.join(", ")}, and no subject holds a current, unexpired grant.`,
280
+ }));
281
+ }
282
+
283
+ /** Derive the record from what the Worker declares and records. */
284
+ export function deriveProcessingRecord(inputs: ProcessingInputs): DerivedProcessingRecord {
285
+ const { activities, inventory, retention, consent } = inputs;
286
+ const stored = inventory === null ? null : new Set(inventory.observed);
287
+ const catalog = catalogOf(consent);
288
+
289
+ const entries: ProcessingEntry[] = activities.map((activity) => ({
290
+ id: activity.id,
291
+ purpose: activity.purpose,
292
+ declaredBy: activity.declaredBy,
293
+ classes: [...activity.classes],
294
+ legalBasis: activity.legalBasis,
295
+ basisSource: activity.basisSource,
296
+ consent:
297
+ activity.legalBasis === "consent" && activity.consentPurpose !== null
298
+ ? consentView(activity.consentPurpose, consent)
299
+ : null,
300
+ recipients: [
301
+ recipient(CLOUDFLARE, CLOUDFLARE_RECEIVES, activity.classes, "platform"),
302
+ ...inputs.bindingRecipients
303
+ .filter((each) => each.capability === activity.declaredBy)
304
+ .map((each) => recipient(each.name, each.receives, each.classes, "binding")),
305
+ ...activity.recipients.map((each) => recipient(each.name, each.receives, each.classes, "declared")),
306
+ ],
307
+ retention: retentionView(activity.classes, retention),
308
+ measures: [...PROCESSING_MEASURES],
309
+ gaps: activityGaps(activity, stored, catalog),
310
+ }));
311
+
312
+ const recipients: ProcessingRecipientView[] = [
313
+ recipient(CLOUDFLARE, CLOUDFLARE_RECEIVES, inventory?.observed ?? [], "platform"),
314
+ ];
315
+ const seen = new Set<string>();
316
+ const others = [
317
+ ...inputs.bindingRecipients.map((each) => recipient(each.name, each.receives, each.classes, "binding")),
318
+ ...activities.flatMap((activity) =>
319
+ activity.recipients.map((each) => recipient(each.name, each.receives, each.classes, "declared")),
320
+ ),
321
+ ];
322
+ for (const each of others) {
323
+ const key = `${each.name}\u0000${each.receives}`;
324
+ if (seen.has(key)) continue;
325
+ seen.add(key);
326
+ recipients.push(each);
327
+ }
328
+
329
+ const notDerivable: string[] = [];
330
+ if (inventory === null) {
331
+ notDerivable.push("The privacy inventory could not be walked, so data classes are not cross-checked.");
332
+ }
333
+ if (consent.state === "unreadable") {
334
+ notDerivable.push("The consent record could not be read, so grant counts are unknown.");
335
+ }
336
+ if (catalog === null) {
337
+ notDerivable.push(
338
+ "The consent catalog could not be listed, so consent purposes and phi coverage are not cross-checked.",
339
+ );
340
+ }
341
+
342
+ return {
343
+ activities: entries,
344
+ recipients,
345
+ measures: PROCESSING_MEASURES.map((control) => ({
346
+ control,
347
+ title: EVIDENCE_CONTROLS[control].title,
348
+ soc2: [...EVIDENCE_CONTROLS[control].soc2],
349
+ iso27001: [...EVIDENCE_CONTROLS[control].iso27001],
350
+ state: inputs.measures?.[control] ?? null,
351
+ })),
352
+ gaps: [
353
+ ...(inventory === null ? [] : unclaimedGaps(activities, inventory, inputs.fieldNames)),
354
+ ...(inventory === null ? [] : uncoveredPhi(inventory, catalog, inputs.fieldNames)),
355
+ ...grantlessPurposes(consent),
356
+ ],
357
+ notDerivable,
358
+ };
359
+ }