@crowdsource.you/core 1.2.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 (339) hide show
  1. package/README.md +571 -0
  2. package/dist/cases.d.ts +66 -0
  3. package/dist/cases.d.ts.map +1 -0
  4. package/dist/cases.js +79 -0
  5. package/dist/cases.js.map +1 -0
  6. package/dist/client.d.ts +90 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +125 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/communityNotes.d.ts +49 -0
  11. package/dist/communityNotes.d.ts.map +1 -0
  12. package/dist/communityNotes.js +104 -0
  13. package/dist/communityNotes.js.map +1 -0
  14. package/dist/credential.d.ts +58 -0
  15. package/dist/credential.d.ts.map +1 -0
  16. package/dist/credential.js +71 -0
  17. package/dist/credential.js.map +1 -0
  18. package/dist/defaults.d.ts +61 -0
  19. package/dist/defaults.d.ts.map +1 -0
  20. package/dist/defaults.js +82 -0
  21. package/dist/defaults.js.map +1 -0
  22. package/dist/digest.d.ts +44 -0
  23. package/dist/digest.d.ts.map +1 -0
  24. package/dist/digest.js +79 -0
  25. package/dist/digest.js.map +1 -0
  26. package/dist/envelope.d.ts +143 -0
  27. package/dist/envelope.d.ts.map +1 -0
  28. package/dist/envelope.js +258 -0
  29. package/dist/envelope.js.map +1 -0
  30. package/dist/errors.d.ts +90 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +151 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/esm/cases.js +74 -0
  35. package/dist/esm/cases.js.map +1 -0
  36. package/dist/esm/client.js +121 -0
  37. package/dist/esm/client.js.map +1 -0
  38. package/dist/esm/communityNotes.js +100 -0
  39. package/dist/esm/communityNotes.js.map +1 -0
  40. package/dist/esm/credential.js +67 -0
  41. package/dist/esm/credential.js.map +1 -0
  42. package/dist/esm/defaults.js +77 -0
  43. package/dist/esm/defaults.js.map +1 -0
  44. package/dist/esm/digest.js +74 -0
  45. package/dist/esm/digest.js.map +1 -0
  46. package/dist/esm/envelope.js +252 -0
  47. package/dist/esm/envelope.js.map +1 -0
  48. package/dist/esm/errors.js +140 -0
  49. package/dist/esm/errors.js.map +1 -0
  50. package/dist/esm/express/index.js +29 -0
  51. package/dist/esm/express/index.js.map +1 -0
  52. package/dist/esm/express/middleware.js +293 -0
  53. package/dist/esm/express/middleware.js.map +1 -0
  54. package/dist/esm/express/store.js +69 -0
  55. package/dist/esm/express/store.js.map +1 -0
  56. package/dist/esm/express/verify.js +133 -0
  57. package/dist/esm/express/verify.js.map +1 -0
  58. package/dist/esm/index.js +57 -0
  59. package/dist/esm/index.js.map +1 -0
  60. package/dist/esm/outbox/client.js +43 -0
  61. package/dist/esm/outbox/client.js.map +1 -0
  62. package/dist/esm/outbox/decision.js +153 -0
  63. package/dist/esm/outbox/decision.js.map +1 -0
  64. package/dist/esm/outbox/delivery.js +129 -0
  65. package/dist/esm/outbox/delivery.js.map +1 -0
  66. package/dist/esm/outbox/enforcement/executor.js +219 -0
  67. package/dist/esm/outbox/enforcement/executor.js.map +1 -0
  68. package/dist/esm/outbox/enforcement/planner.js +326 -0
  69. package/dist/esm/outbox/enforcement/planner.js.map +1 -0
  70. package/dist/esm/outbox/evidence.js +173 -0
  71. package/dist/esm/outbox/evidence.js.map +1 -0
  72. package/dist/esm/outbox/inbound.js +87 -0
  73. package/dist/esm/outbox/inbound.js.map +1 -0
  74. package/dist/esm/outbox/index.js +71 -0
  75. package/dist/esm/outbox/index.js.map +1 -0
  76. package/dist/esm/outbox/intake.js +140 -0
  77. package/dist/esm/outbox/intake.js.map +1 -0
  78. package/dist/esm/outbox/integration.js +116 -0
  79. package/dist/esm/outbox/integration.js.map +1 -0
  80. package/dist/esm/outbox/outbox/dispatcher.js +114 -0
  81. package/dist/esm/outbox/outbox/dispatcher.js.map +1 -0
  82. package/dist/esm/outbox/outbox/service.js +271 -0
  83. package/dist/esm/outbox/outbox/service.js.map +1 -0
  84. package/dist/esm/outbox/postgres/index.js +41 -0
  85. package/dist/esm/outbox/postgres/index.js.map +1 -0
  86. package/dist/esm/outbox/postgres/registries.js +155 -0
  87. package/dist/esm/outbox/postgres/registries.js.map +1 -0
  88. package/dist/esm/outbox/postgres/reportColumns.js +191 -0
  89. package/dist/esm/outbox/postgres/reportColumns.js.map +1 -0
  90. package/dist/esm/outbox/postgres/store/enforcement.js +158 -0
  91. package/dist/esm/outbox/postgres/store/enforcement.js.map +1 -0
  92. package/dist/esm/outbox/postgres/store/events.js +101 -0
  93. package/dist/esm/outbox/postgres/store/events.js.map +1 -0
  94. package/dist/esm/outbox/postgres/store/index.js +59 -0
  95. package/dist/esm/outbox/postgres/store/index.js.map +1 -0
  96. package/dist/esm/outbox/postgres/store/outbox.js +207 -0
  97. package/dist/esm/outbox/postgres/store/outbox.js.map +1 -0
  98. package/dist/esm/outbox/postgres/store/reports.js +231 -0
  99. package/dist/esm/outbox/postgres/store/reports.js.map +1 -0
  100. package/dist/esm/outbox/postgres/store/transaction.js +29 -0
  101. package/dist/esm/outbox/postgres/store/transaction.js.map +1 -0
  102. package/dist/esm/outbox/postgres/tables.js +270 -0
  103. package/dist/esm/outbox/postgres/tables.js.map +1 -0
  104. package/dist/esm/outbox/reconciliation.js +157 -0
  105. package/dist/esm/outbox/reconciliation.js.map +1 -0
  106. package/dist/esm/outbox/reportStatus.js +22 -0
  107. package/dist/esm/outbox/reportStatus.js.map +1 -0
  108. package/dist/esm/outbox/retention.js +33 -0
  109. package/dist/esm/outbox/retention.js.map +1 -0
  110. package/dist/esm/outbox/store/types.js +30 -0
  111. package/dist/esm/outbox/store/types.js.map +1 -0
  112. package/dist/esm/outbox/types.js +23 -0
  113. package/dist/esm/outbox/types.js.map +1 -0
  114. package/dist/esm/outbox/webhook.js +145 -0
  115. package/dist/esm/outbox/webhook.js.map +1 -0
  116. package/dist/esm/package.json +1 -0
  117. package/dist/esm/reports.js +74 -0
  118. package/dist/esm/reports.js.map +1 -0
  119. package/dist/esm/testing/fixtures.js +132 -0
  120. package/dist/esm/testing/fixtures.js.map +1 -0
  121. package/dist/esm/testing/index.js +33 -0
  122. package/dist/esm/testing/index.js.map +1 -0
  123. package/dist/esm/testing/sandbox.js +337 -0
  124. package/dist/esm/testing/sandbox.js.map +1 -0
  125. package/dist/esm/testing/webhook-simulator.js +93 -0
  126. package/dist/esm/testing/webhook-simulator.js.map +1 -0
  127. package/dist/esm/transport.js +177 -0
  128. package/dist/esm/transport.js.map +1 -0
  129. package/dist/esm/webhookEndpoints.js +139 -0
  130. package/dist/esm/webhookEndpoints.js.map +1 -0
  131. package/dist/express/index.d.ts +32 -0
  132. package/dist/express/index.d.ts.map +1 -0
  133. package/dist/express/index.js +40 -0
  134. package/dist/express/index.js.map +1 -0
  135. package/dist/express/middleware.d.ts +97 -0
  136. package/dist/express/middleware.d.ts.map +1 -0
  137. package/dist/express/middleware.js +298 -0
  138. package/dist/express/middleware.js.map +1 -0
  139. package/dist/express/store.d.ts +45 -0
  140. package/dist/express/store.d.ts.map +1 -0
  141. package/dist/express/store.js +72 -0
  142. package/dist/express/store.js.map +1 -0
  143. package/dist/express/verify.d.ts +86 -0
  144. package/dist/express/verify.d.ts.map +1 -0
  145. package/dist/express/verify.js +138 -0
  146. package/dist/express/verify.js.map +1 -0
  147. package/dist/index.d.ts +67 -0
  148. package/dist/index.d.ts.map +1 -0
  149. package/dist/index.js +89 -0
  150. package/dist/index.js.map +1 -0
  151. package/dist/outbox/client.d.ts +35 -0
  152. package/dist/outbox/client.d.ts.map +1 -0
  153. package/dist/outbox/client.js +46 -0
  154. package/dist/outbox/client.js.map +1 -0
  155. package/dist/outbox/decision.d.ts +36 -0
  156. package/dist/outbox/decision.d.ts.map +1 -0
  157. package/dist/outbox/decision.js +159 -0
  158. package/dist/outbox/decision.js.map +1 -0
  159. package/dist/outbox/delivery.d.ts +50 -0
  160. package/dist/outbox/delivery.d.ts.map +1 -0
  161. package/dist/outbox/delivery.js +135 -0
  162. package/dist/outbox/delivery.js.map +1 -0
  163. package/dist/outbox/enforcement/executor.d.ts +26 -0
  164. package/dist/outbox/enforcement/executor.d.ts.map +1 -0
  165. package/dist/outbox/enforcement/executor.js +222 -0
  166. package/dist/outbox/enforcement/executor.js.map +1 -0
  167. package/dist/outbox/enforcement/planner.d.ts +50 -0
  168. package/dist/outbox/enforcement/planner.d.ts.map +1 -0
  169. package/dist/outbox/enforcement/planner.js +333 -0
  170. package/dist/outbox/enforcement/planner.js.map +1 -0
  171. package/dist/outbox/evidence.d.ts +114 -0
  172. package/dist/outbox/evidence.d.ts.map +1 -0
  173. package/dist/outbox/evidence.js +181 -0
  174. package/dist/outbox/evidence.js.map +1 -0
  175. package/dist/outbox/inbound.d.ts +78 -0
  176. package/dist/outbox/inbound.d.ts.map +1 -0
  177. package/dist/outbox/inbound.js +91 -0
  178. package/dist/outbox/inbound.js.map +1 -0
  179. package/dist/outbox/index.d.ts +79 -0
  180. package/dist/outbox/index.d.ts.map +1 -0
  181. package/dist/outbox/index.js +98 -0
  182. package/dist/outbox/index.js.map +1 -0
  183. package/dist/outbox/intake.d.ts +62 -0
  184. package/dist/outbox/intake.d.ts.map +1 -0
  185. package/dist/outbox/intake.js +145 -0
  186. package/dist/outbox/intake.js.map +1 -0
  187. package/dist/outbox/integration.d.ts +62 -0
  188. package/dist/outbox/integration.d.ts.map +1 -0
  189. package/dist/outbox/integration.js +119 -0
  190. package/dist/outbox/integration.js.map +1 -0
  191. package/dist/outbox/outbox/dispatcher.d.ts +26 -0
  192. package/dist/outbox/outbox/dispatcher.d.ts.map +1 -0
  193. package/dist/outbox/outbox/dispatcher.js +119 -0
  194. package/dist/outbox/outbox/dispatcher.js.map +1 -0
  195. package/dist/outbox/outbox/service.d.ts +124 -0
  196. package/dist/outbox/outbox/service.d.ts.map +1 -0
  197. package/dist/outbox/outbox/service.js +279 -0
  198. package/dist/outbox/outbox/service.js.map +1 -0
  199. package/dist/outbox/postgres/index.d.ts +44 -0
  200. package/dist/outbox/postgres/index.d.ts.map +1 -0
  201. package/dist/outbox/postgres/index.js +51 -0
  202. package/dist/outbox/postgres/index.js.map +1 -0
  203. package/dist/outbox/postgres/registries.d.ts +69 -0
  204. package/dist/outbox/postgres/registries.d.ts.map +1 -0
  205. package/dist/outbox/postgres/registries.js +159 -0
  206. package/dist/outbox/postgres/registries.js.map +1 -0
  207. package/dist/outbox/postgres/reportColumns.d.ts +154 -0
  208. package/dist/outbox/postgres/reportColumns.d.ts.map +1 -0
  209. package/dist/outbox/postgres/reportColumns.js +195 -0
  210. package/dist/outbox/postgres/reportColumns.js.map +1 -0
  211. package/dist/outbox/postgres/store/enforcement.d.ts +33 -0
  212. package/dist/outbox/postgres/store/enforcement.d.ts.map +1 -0
  213. package/dist/outbox/postgres/store/enforcement.js +161 -0
  214. package/dist/outbox/postgres/store/enforcement.js.map +1 -0
  215. package/dist/outbox/postgres/store/events.d.ts +46 -0
  216. package/dist/outbox/postgres/store/events.d.ts.map +1 -0
  217. package/dist/outbox/postgres/store/events.js +104 -0
  218. package/dist/outbox/postgres/store/events.js.map +1 -0
  219. package/dist/outbox/postgres/store/index.d.ts +27 -0
  220. package/dist/outbox/postgres/store/index.d.ts.map +1 -0
  221. package/dist/outbox/postgres/store/index.js +62 -0
  222. package/dist/outbox/postgres/store/index.js.map +1 -0
  223. package/dist/outbox/postgres/store/outbox.d.ts +8 -0
  224. package/dist/outbox/postgres/store/outbox.d.ts.map +1 -0
  225. package/dist/outbox/postgres/store/outbox.js +210 -0
  226. package/dist/outbox/postgres/store/outbox.js.map +1 -0
  227. package/dist/outbox/postgres/store/reports.d.ts +69 -0
  228. package/dist/outbox/postgres/store/reports.d.ts.map +1 -0
  229. package/dist/outbox/postgres/store/reports.js +234 -0
  230. package/dist/outbox/postgres/store/reports.js.map +1 -0
  231. package/dist/outbox/postgres/store/transaction.d.ts +49 -0
  232. package/dist/outbox/postgres/store/transaction.d.ts.map +1 -0
  233. package/dist/outbox/postgres/store/transaction.js +32 -0
  234. package/dist/outbox/postgres/store/transaction.js.map +1 -0
  235. package/dist/outbox/postgres/tables.d.ts +737 -0
  236. package/dist/outbox/postgres/tables.d.ts.map +1 -0
  237. package/dist/outbox/postgres/tables.js +273 -0
  238. package/dist/outbox/postgres/tables.js.map +1 -0
  239. package/dist/outbox/reconciliation.d.ts +49 -0
  240. package/dist/outbox/reconciliation.d.ts.map +1 -0
  241. package/dist/outbox/reconciliation.js +162 -0
  242. package/dist/outbox/reconciliation.js.map +1 -0
  243. package/dist/outbox/reportStatus.d.ts +12 -0
  244. package/dist/outbox/reportStatus.d.ts.map +1 -0
  245. package/dist/outbox/reportStatus.js +25 -0
  246. package/dist/outbox/reportStatus.js.map +1 -0
  247. package/dist/outbox/retention.d.ts +33 -0
  248. package/dist/outbox/retention.d.ts.map +1 -0
  249. package/dist/outbox/retention.js +36 -0
  250. package/dist/outbox/retention.js.map +1 -0
  251. package/dist/outbox/store/types.d.ts +466 -0
  252. package/dist/outbox/store/types.d.ts.map +1 -0
  253. package/dist/outbox/store/types.js +31 -0
  254. package/dist/outbox/store/types.js.map +1 -0
  255. package/dist/outbox/types.d.ts +660 -0
  256. package/dist/outbox/types.d.ts.map +1 -0
  257. package/dist/outbox/types.js +24 -0
  258. package/dist/outbox/types.js.map +1 -0
  259. package/dist/outbox/webhook.d.ts +15 -0
  260. package/dist/outbox/webhook.d.ts.map +1 -0
  261. package/dist/outbox/webhook.js +148 -0
  262. package/dist/outbox/webhook.js.map +1 -0
  263. package/dist/reports.d.ts +73 -0
  264. package/dist/reports.d.ts.map +1 -0
  265. package/dist/reports.js +78 -0
  266. package/dist/reports.js.map +1 -0
  267. package/dist/testing/fixtures.d.ts +62 -0
  268. package/dist/testing/fixtures.d.ts.map +1 -0
  269. package/dist/testing/fixtures.js +137 -0
  270. package/dist/testing/fixtures.js.map +1 -0
  271. package/dist/testing/index.d.ts +36 -0
  272. package/dist/testing/index.d.ts.map +1 -0
  273. package/dist/testing/index.js +43 -0
  274. package/dist/testing/index.js.map +1 -0
  275. package/dist/testing/sandbox.d.ts +109 -0
  276. package/dist/testing/sandbox.d.ts.map +1 -0
  277. package/dist/testing/sandbox.js +342 -0
  278. package/dist/testing/sandbox.js.map +1 -0
  279. package/dist/testing/webhook-simulator.d.ts +78 -0
  280. package/dist/testing/webhook-simulator.d.ts.map +1 -0
  281. package/dist/testing/webhook-simulator.js +98 -0
  282. package/dist/testing/webhook-simulator.js.map +1 -0
  283. package/dist/transport.d.ts +59 -0
  284. package/dist/transport.d.ts.map +1 -0
  285. package/dist/transport.js +181 -0
  286. package/dist/transport.js.map +1 -0
  287. package/dist/webhookEndpoints.d.ts +147 -0
  288. package/dist/webhookEndpoints.d.ts.map +1 -0
  289. package/dist/webhookEndpoints.js +143 -0
  290. package/dist/webhookEndpoints.js.map +1 -0
  291. package/package.json +127 -0
  292. package/src/cases.ts +119 -0
  293. package/src/client.ts +202 -0
  294. package/src/communityNotes.ts +194 -0
  295. package/src/credential.ts +100 -0
  296. package/src/defaults.ts +87 -0
  297. package/src/digest.ts +91 -0
  298. package/src/envelope.ts +432 -0
  299. package/src/errors.ts +165 -0
  300. package/src/express/index.ts +43 -0
  301. package/src/express/middleware.ts +387 -0
  302. package/src/express/store.ts +85 -0
  303. package/src/express/verify.ts +166 -0
  304. package/src/index.ts +107 -0
  305. package/src/outbox/client.ts +75 -0
  306. package/src/outbox/decision.ts +209 -0
  307. package/src/outbox/delivery.ts +163 -0
  308. package/src/outbox/enforcement/executor.ts +288 -0
  309. package/src/outbox/enforcement/planner.ts +377 -0
  310. package/src/outbox/evidence.ts +231 -0
  311. package/src/outbox/inbound.ts +140 -0
  312. package/src/outbox/index.ts +160 -0
  313. package/src/outbox/intake.ts +175 -0
  314. package/src/outbox/integration.ts +205 -0
  315. package/src/outbox/outbox/dispatcher.ts +131 -0
  316. package/src/outbox/outbox/service.ts +444 -0
  317. package/src/outbox/postgres/index.ts +59 -0
  318. package/src/outbox/postgres/registries.ts +177 -0
  319. package/src/outbox/postgres/reportColumns.ts +281 -0
  320. package/src/outbox/postgres/store/enforcement.ts +186 -0
  321. package/src/outbox/postgres/store/events.ts +112 -0
  322. package/src/outbox/postgres/store/index.ts +72 -0
  323. package/src/outbox/postgres/store/outbox.ts +273 -0
  324. package/src/outbox/postgres/store/reports.ts +349 -0
  325. package/src/outbox/postgres/store/transaction.ts +64 -0
  326. package/src/outbox/postgres/tables.ts +339 -0
  327. package/src/outbox/reconciliation.ts +199 -0
  328. package/src/outbox/reportStatus.ts +24 -0
  329. package/src/outbox/retention.ts +34 -0
  330. package/src/outbox/store/types.ts +528 -0
  331. package/src/outbox/types.ts +753 -0
  332. package/src/outbox/webhook.ts +165 -0
  333. package/src/reports.ts +138 -0
  334. package/src/testing/fixtures.ts +183 -0
  335. package/src/testing/index.ts +57 -0
  336. package/src/testing/sandbox.ts +469 -0
  337. package/src/testing/webhook-simulator.ts +166 -0
  338. package/src/transport.ts +250 -0
  339. package/src/webhookEndpoints.ts +256 -0
@@ -0,0 +1,288 @@
1
+ import type { Decision } from '@crowdsource.you/contracts';
2
+ import { planEnforcement } from './planner.js';
3
+ import type {
4
+ ModerationEnforcementKey,
5
+ ModerationEnforcementStore,
6
+ } from '../store/types.js';
7
+ import type {
8
+ EnforcementEffect,
9
+ EnforcementOutcome,
10
+ EnforcementPreviousState,
11
+ EnforcementSubject,
12
+ ModerationEnforcementConfig,
13
+ ModerationEnforcementMode,
14
+ ModerationLogger,
15
+ ModerationMetrics,
16
+ PlannedEnforcementAction,
17
+ } from '../types.js';
18
+
19
+ /**
20
+ * Carrying out a decision, exactly once.
21
+ *
22
+ * Two guarantees, and everything here exists for one of them.
23
+ *
24
+ * **Once.** The idempotency key is `decisionId + revision + action`, and the
25
+ * unique index on the enforcement collection is that key. Each action CLAIMS its
26
+ * row before doing anything; a second attempt — a redelivered webhook, a
27
+ * reclaimed outbox lease, a manual replay — loses the insert and does nothing.
28
+ * Reading "have I done this?" and then acting would leave the gap between the
29
+ * two, which is exactly when a redelivery arrives.
30
+ *
31
+ * **Reversibly.** Every action that changes state records what the state WAS,
32
+ * and a reversal puts that back. So a correction does not lift a content warning
33
+ * the author set themselves, and a restore returns an object to the state it
34
+ * actually had rather than to a guess at one.
35
+ *
36
+ * `observe` mode runs all of this except the effect. That is deliberate: the
37
+ * plan, the claim and the record are identical to production, so what the mode
38
+ * proves is exactly what will happen when it is switched off — and the audit
39
+ * trail is real rather than a log line saying a decision was seen.
40
+ *
41
+ * The application supplies the tables and one `apply`. Everything above is this
42
+ * package's and is not configurable, because it is the invariant rather than the
43
+ * policy.
44
+ */
45
+
46
+ /**
47
+ * Whether the current mode allows this action to actually happen.
48
+ *
49
+ * `observe` allows nothing — that is the mode. `manual` allows what the
50
+ * application declared reversible: those actions give something BACK, and
51
+ * holding them behind a human review means a wrongly-removed object stays
52
+ * removed while somebody reads a queue. Taking content down still waits for a
53
+ * person. `automatic` allows the mapped set.
54
+ */
55
+ function modeAllows<TAction extends string>(
56
+ mode: ModerationEnforcementMode,
57
+ action: TAction,
58
+ config: ModerationEnforcementConfig<TAction>,
59
+ ): boolean {
60
+ switch (mode) {
61
+ case 'observe':
62
+ return false;
63
+ case 'manual':
64
+ return (config.reversibleActions ?? []).includes(action);
65
+ case 'automatic':
66
+ return true;
67
+ }
68
+ }
69
+
70
+ export interface EnforcementExecutor<TAction extends string> {
71
+ /**
72
+ * Plan and carry out everything this decision revision asks for.
73
+ *
74
+ * Returns one outcome per planned action, in plan order, so a caller can
75
+ * record what happened without asking a second time.
76
+ */
77
+ apply(input: {
78
+ decision: Decision;
79
+ caseId: string;
80
+ subject: EnforcementSubject;
81
+ /** Defaults to the configured mode. Explicit in tests. */
82
+ mode?: ModerationEnforcementMode;
83
+ }): Promise<EnforcementOutcome<TAction>[]>;
84
+ }
85
+
86
+ export function createEnforcementExecutor<TAction extends string>(input: {
87
+ enforcement: ModerationEnforcementStore;
88
+ config: ModerationEnforcementConfig<TAction>;
89
+ defaultMode: ModerationEnforcementMode;
90
+ logger: ModerationLogger;
91
+ metrics?: ModerationMetrics;
92
+ }): EnforcementExecutor<TAction> {
93
+ const { enforcement, config, logger } = input;
94
+
95
+ const count = (
96
+ action: TAction,
97
+ mode: ModerationEnforcementMode,
98
+ result: string,
99
+ ): void => {
100
+ input.metrics?.incrementCounter('crowdsource_enforcement_total', 1, {
101
+ action,
102
+ mode,
103
+ result,
104
+ });
105
+ };
106
+
107
+ /**
108
+ * What the earlier action this one reverses left behind.
109
+ *
110
+ * Read from the most recent APPLIED row, so an action that was recorded but
111
+ * never carried out cannot be mistaken for one that changed something. This is
112
+ * the mechanism behind "only lift what moderation set": an absent row means
113
+ * the state was not moderation's doing, and the application's `apply` decides
114
+ * what to do with that.
115
+ */
116
+ const previousStateFor = async (
117
+ action: TAction,
118
+ subject: EnforcementSubject,
119
+ ): Promise<
120
+ { previousState?: EnforcementPreviousState; previousAction: TAction } | undefined
121
+ > => {
122
+ const reversed: TAction | readonly TAction[] | undefined = config.reverses?.[action];
123
+ if (reversed === undefined) return undefined;
124
+ /**
125
+ * One action may reverse several. The most recent APPLIED row across the
126
+ * whole set wins, so `apply` receives what actually happened last rather
127
+ * than what a single declared action happened to be.
128
+ */
129
+ const candidates: readonly TAction[] =
130
+ typeof reversed === 'string' ? [reversed] : reversed;
131
+ if (candidates.length === 0) return undefined;
132
+ const row = await enforcement.latestApplied({
133
+ subjectType: subject.type,
134
+ subjectId: subject.id,
135
+ actions: candidates,
136
+ });
137
+ if (row === null) return undefined;
138
+ /**
139
+ * The row's own action, narrowed THROUGH the declared set rather than cast.
140
+ * The query guarantees membership; finding it here is what lets
141
+ * `previousAction` be a `TAction` without asserting that it is one.
142
+ */
143
+ const previousAction = candidates.find((candidate) => candidate === row.action);
144
+ if (previousAction === undefined) return undefined;
145
+ return {
146
+ ...(row.previousState === undefined ? {} : { previousState: row.previousState }),
147
+ previousAction,
148
+ };
149
+ };
150
+
151
+ const applyOne = async (
152
+ planned: PlannedEnforcementAction<TAction>,
153
+ context: { decision: Decision; caseId: string; subject: EnforcementSubject },
154
+ mode: ModerationEnforcementMode,
155
+ ): Promise<EnforcementOutcome<TAction>> => {
156
+ const { decision, caseId, subject } = context;
157
+
158
+ /**
159
+ * The claim, and the ONLY way this row is ever addressed afterwards: the
160
+ * idempotency key itself, never a record id the insert happened to return.
161
+ * `false` means another delivery of this same decision revision already
162
+ * holds it, which is an answer rather than an error — a read of "have I done
163
+ * this?" before writing would leave the gap a redelivery arrives in.
164
+ */
165
+ const key: ModerationEnforcementKey = {
166
+ decisionId: decision.id,
167
+ decisionRevision: decision.revision,
168
+ action: planned.action,
169
+ };
170
+ const claimed = await enforcement.claim({
171
+ ...key,
172
+ caseId,
173
+ subjectType: subject.type,
174
+ subjectId: subject.id,
175
+ outcome: decision.outcome,
176
+ ...(planned.recommendedAction === undefined
177
+ ? {}
178
+ : { recommendedAction: planned.recommendedAction }),
179
+ reason: planned.reason.slice(0, 500),
180
+ mode,
181
+ now: new Date(),
182
+ });
183
+ if (!claimed) {
184
+ count(planned.action, mode, 'duplicate');
185
+ return { action: planned.action, result: 'duplicate' };
186
+ }
187
+
188
+ if (!modeAllows(mode, planned.action, config)) {
189
+ await enforcement.markSkipped(key, {
190
+ skippedReason:
191
+ mode === 'observe'
192
+ ? 'observe mode: recorded, not applied'
193
+ : `${mode} mode does not apply '${planned.action}' automatically`,
194
+ now: new Date(),
195
+ });
196
+ count(planned.action, mode, 'recorded');
197
+ return { action: planned.action, result: 'recorded' };
198
+ }
199
+
200
+ try {
201
+ const reversal = await previousStateFor(planned.action, subject);
202
+ /**
203
+ * An application with no sanction primitive supplies no `apply`, and every
204
+ * planned action is recorded with the reason rather than silently
205
+ * dropped. The plan, the claim and the audit row stay real, so "CrowdSource
206
+ * decided this and this application has no way to carry it out" is written
207
+ * down — which is the record that would justify building the primitive.
208
+ */
209
+ const noPrimitive: EnforcementEffect<TAction> = {
210
+ changed: false,
211
+ reason: 'This application has no enforcement primitive for any action',
212
+ };
213
+ const effect: EnforcementEffect<TAction> = await (config.apply?.({
214
+ action: planned.action,
215
+ subject,
216
+ ...(reversal ?? {}),
217
+ decision,
218
+ }) ?? Promise.resolve(noPrimitive));
219
+
220
+ if (!effect.changed) {
221
+ /**
222
+ * `recordedAs` corrects the LABEL, never the claim. The row keeps the
223
+ * planned action because that is half the idempotency key and is what
224
+ * was decided; the effective label rides alongside it and is what
225
+ * reaches the report.
226
+ */
227
+ await enforcement.markSkipped(key, {
228
+ skippedReason: effect.reason.slice(0, 300),
229
+ ...(effect.recordedAs === undefined ? {} : { recordedAs: effect.recordedAs }),
230
+ now: new Date(),
231
+ });
232
+ count(planned.action, mode, 'recorded');
233
+ return {
234
+ action: planned.action,
235
+ result: 'recorded',
236
+ ...(effect.recordedAs === undefined ? {} : { recordedAs: effect.recordedAs }),
237
+ };
238
+ }
239
+
240
+ const appliedAt = new Date();
241
+ await enforcement.markApplied(key, {
242
+ appliedAt,
243
+ ...(effect.previousState === undefined
244
+ ? {}
245
+ : { previousState: effect.previousState }),
246
+ now: appliedAt,
247
+ });
248
+ count(planned.action, mode, 'applied');
249
+ return { action: planned.action, result: 'applied' };
250
+ } catch (error: unknown) {
251
+ /**
252
+ * The claim goes back so a retry can try again. Keeping it would make a
253
+ * transient failure permanent: the action would be deduplicated away
254
+ * forever and the decision would silently never be carried out.
255
+ */
256
+ await enforcement.releaseClaim(key);
257
+ logger.error('[CrowdSource] enforcement effect failed, claim released', {
258
+ decisionId: decision.id,
259
+ revision: decision.revision,
260
+ action: planned.action,
261
+ error: error instanceof Error ? error.message : String(error),
262
+ });
263
+ throw error;
264
+ }
265
+ };
266
+
267
+ return {
268
+ async apply(applyInput) {
269
+ const mode = applyInput.mode ?? input.defaultMode;
270
+ const plan = planEnforcement(applyInput.decision, config);
271
+ const outcomes: EnforcementOutcome<TAction>[] = [];
272
+ for (const planned of plan) {
273
+ outcomes.push(
274
+ await applyOne(
275
+ planned,
276
+ {
277
+ decision: applyInput.decision,
278
+ caseId: applyInput.caseId,
279
+ subject: applyInput.subject,
280
+ },
281
+ mode,
282
+ ),
283
+ );
284
+ }
285
+ return outcomes;
286
+ },
287
+ };
288
+ }
@@ -0,0 +1,377 @@
1
+ import type { Decision, RecommendedAction, Severity } from '@crowdsource.you/contracts';
2
+ import type { ModerationEnforcementConfig, PlannedEnforcementAction } from '../types.js';
3
+
4
+ /**
5
+ * Deciding what an application will do about a decision — and nothing else.
6
+ *
7
+ * Pure: no database, no clock, no configuration. A decision in, a plan out. That
8
+ * is what makes the mapping testable as a table rather than as an integration
9
+ * scenario, and it is why `observe` mode is a real audit rather than a comment —
10
+ * the plan is computed identically in every mode and only its EXECUTION is
11
+ * gated.
12
+ *
13
+ * ## The application maps recommendations, not findings
14
+ *
15
+ * `decision.recommendedActions` is what this reads, falling back to severity
16
+ * only when a violation arrives with no recommendation at all.
17
+ *
18
+ * The reason is a division of labour: the jury classified the material and the
19
+ * consensus engine turned that into a recommendation under a versioned policy.
20
+ * An application that re-derived its action from raw severity would be quietly
21
+ * re-deciding the case with a second, unversioned policy of its own — and the
22
+ * two would diverge the first time CrowdSource's policy was updated. The
23
+ * fallback exists because a `violation` the application did nothing about would
24
+ * be worse than a mapped one.
25
+ *
26
+ * ## Why this ALGORITHM is shared and only the TABLES are per-application
27
+ *
28
+ * Every application has different actions, so the tables have to be its own. The
29
+ * algorithm must not be, because it carries a correctness property that is
30
+ * invisible by construction — see {@link withRestoreForNoViolation}. An
31
+ * application writing its own planner is a chance to ship that bug silently, and
32
+ * no test written against the application's own mapping would catch it.
33
+ */
34
+
35
+ const SEVERITY_ORDER: readonly Severity[] = ['low', 'medium', 'high', 'critical'];
36
+
37
+ /**
38
+ * Recommendations that ask for NO effect.
39
+ *
40
+ * An unmapped recommendation goes to `reviewAction`, because a recommendation
41
+ * this application has no action for is a decision a human should see. These
42
+ * three are the exception, and the distinction is not cosmetic: they are
43
+ * CrowdSource saying "take no action", so routing them to review would put a
44
+ * human in front of every cleared case and bury the ones that need looking at.
45
+ *
46
+ * An application's own table still wins — it may genuinely want `allow` to mean
47
+ * something — but the default for an application that maps nothing has to be
48
+ * "do nothing" rather than "wake somebody".
49
+ */
50
+ const NO_EFFECT_RECOMMENDATIONS: ReadonlySet<string> = new Set([
51
+ 'allow',
52
+ 'no_action',
53
+ 'no_global_effect',
54
+ ]);
55
+
56
+ function actionForRecommendation<TAction extends string>(
57
+ recommended: RecommendedAction,
58
+ config: ModerationEnforcementConfig<TAction>,
59
+ ): TAction {
60
+ const mapped = config.recommendationToAction?.[recommended];
61
+ if (mapped !== undefined) return mapped;
62
+ return NO_EFFECT_RECOMMENDATIONS.has(recommended)
63
+ ? config.noneAction
64
+ : config.reviewAction;
65
+ }
66
+
67
+ function highestSeverity(decision: Decision): Severity | undefined {
68
+ let highest: Severity | undefined;
69
+ for (const finding of decision.findings) {
70
+ if (
71
+ highest === undefined ||
72
+ SEVERITY_ORDER.indexOf(finding.severity) > SEVERITY_ORDER.indexOf(highest)
73
+ ) {
74
+ highest = finding.severity;
75
+ }
76
+ }
77
+ return highest;
78
+ }
79
+
80
+ /**
81
+ * `no_violation` always carries a restore, whatever it recommended.
82
+ *
83
+ * This exists because of a failure that is very easy to ship and very hard to
84
+ * see. A correction is a new revision whose outcome is `no_violation`, and its
85
+ * recommendation is frequently `no_action` — which is CrowdSource saying "take
86
+ * no NEW action", not "leave what you already did in place". Mapping that
87
+ * straight through plans nothing, and the object an earlier revision removed
88
+ * stays removed forever: the appeal succeeded, the case says the content was
89
+ * fine, and nothing in the application ever puts it back. No error, no log line,
90
+ * no failing test anywhere else.
91
+ *
92
+ * `allow`, `restore` and `no_action` are listed together as an application's
93
+ * options for `no_violation` precisely because choosing between them needs
94
+ * knowledge only the application has — whether it did something earlier. So the
95
+ * plan always includes the restore, and the executor records "there was nothing
96
+ * restricted" when that is the case, which is evidence rather than a silent
97
+ * no-op.
98
+ */
99
+ function restoreActions<TAction extends string>(
100
+ declared: TAction | readonly TAction[] | null,
101
+ ): readonly TAction[] {
102
+ if (declared === null) return [];
103
+ return typeof declared === 'string' ? [declared] : declared;
104
+ }
105
+
106
+ function withRestoreForNoViolation<TAction extends string>(
107
+ decision: Decision,
108
+ planned: readonly PlannedEnforcementAction<TAction>[],
109
+ declared: TAction | readonly TAction[] | null,
110
+ ): readonly PlannedEnforcementAction<TAction>[] {
111
+ /**
112
+ * `null` is a decision the application made, not an omission — the type
113
+ * requires it to be written down. An application with no sanction primitive
114
+ * has nothing an earlier revision could have removed, so there is nothing for
115
+ * a correction to put back.
116
+ */
117
+ if (decision.outcome !== 'no_violation') return planned;
118
+ /**
119
+ * EVERY declared restore, not just the first. An application whose levers are
120
+ * "hide it" and "label it" has two things a correction must undo, and planning
121
+ * one leaves the other stuck forever — the object is un-hidden and stays
122
+ * labelled, with no error and nothing failing. That is the same failure this
123
+ * function exists to prevent, applied to the second reversible action instead
124
+ * of the first.
125
+ *
126
+ * A restore with nothing to undo is not waste: the executor records
127
+ * `changed: false` with its reason, which is evidence that it was checked.
128
+ */
129
+ const missing = restoreActions(declared).filter(
130
+ (action) => !planned.some((entry) => entry.action === action),
131
+ );
132
+ if (missing.length === 0) return planned;
133
+ return [
134
+ ...planned,
135
+ ...missing.map((action) => ({
136
+ action,
137
+ reason: 'No violation: undo any earlier restriction',
138
+ })),
139
+ ];
140
+ }
141
+
142
+ /**
143
+ * Collapse a plan to the actions that can coexist.
144
+ *
145
+ * A decision may recommend both removal and a label; a removed object does not
146
+ * need one, and recording both would claim two effects where one happened. So an
147
+ * action absorbs whatever the application declared it absorbs, and the explicit
148
+ * "nothing" never survives alongside anything else.
149
+ *
150
+ * `reviewAction` always survives — it is a note for a human, and dropping it
151
+ * because something else was also done is how a `suspend_user` recommendation
152
+ * gets lost.
153
+ */
154
+ function collapse<TAction extends string>(
155
+ actions: readonly PlannedEnforcementAction<TAction>[],
156
+ config: ModerationEnforcementConfig<TAction>,
157
+ ): PlannedEnforcementAction<TAction>[] {
158
+ const byAction = new Map<TAction, PlannedEnforcementAction<TAction>>();
159
+ for (const planned of actions) {
160
+ if (!byAction.has(planned.action)) byAction.set(planned.action, planned);
161
+ }
162
+
163
+ for (const [action, absorbed] of Object.entries(config.absorb ?? {}) as [
164
+ TAction,
165
+ readonly TAction[] | undefined,
166
+ ][]) {
167
+ if (!byAction.has(action)) continue;
168
+ for (const loser of absorbed ?? []) {
169
+ if (loser === config.reviewAction) continue;
170
+ byAction.delete(loser);
171
+ }
172
+ }
173
+
174
+ if (byAction.size > 1) byAction.delete(config.noneAction);
175
+
176
+ return Array.from(byAction.values());
177
+ }
178
+
179
+ /**
180
+ * What the application will do about this decision.
181
+ *
182
+ * Never empty: a decision that produces no action produces an explicit
183
+ * `noneAction`, because a row saying "we decided to do nothing, and why" is
184
+ * evidence and an absent row is a question.
185
+ */
186
+ /**
187
+ * Raised when `restoreAction` names actions that are being UNDONE rather than
188
+ * actions that DO the undoing.
189
+ *
190
+ * A doc line is not enough for this one. An inverted `restoreAction` type-checks
191
+ * — both directions are `TAction[]` — and it does not fail at runtime either: it
192
+ * plans, it claims, and it applies a restriction and a label on an accepted
193
+ * appeal. It succeeds at the opposite thing, on the one path whose whole purpose
194
+ * is to give something back.
195
+ *
196
+ * The signal is unambiguous when it exists. `reverses` maps each undoing action
197
+ * to what it undoes, so its VALUES are targets and its KEYS are actors. An
198
+ * action that appears only as a target has been declared as a restore by
199
+ * someone reading the targets, which is exactly the mistake this catches.
200
+ *
201
+ * Deliberately narrow: an action absent from `reverses` entirely is fine (it may
202
+ * undo something needing no previous state), and one that is both a key and a
203
+ * value is fine (it undoes and is undone). Only "a target, and never an actor"
204
+ * is reported, so a correct configuration cannot trip it.
205
+ */
206
+ export class ModerationRestoreDirectionError extends Error {
207
+ constructor(inverted: readonly string[]) {
208
+ super(
209
+ `restoreAction names [${inverted.join(', ')}], which 'reverses' lists as actions ` +
210
+ 'being UNDONE rather than actions that do the undoing. restoreAction holds what ' +
211
+ "the planner emits on 'no_violation' — the restoring actions, e.g. " +
212
+ "['restore', 'unlabel_sensitive'] — not the restrictions being lifted. As written, " +
213
+ 'an accepted appeal would apply the punishment it was correcting.',
214
+ );
215
+ this.name = 'ModerationRestoreDirectionError';
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Refuse an inverted `restoreAction` at construction rather than at the first
221
+ * correction, which may be weeks later and reads as a moderation decision
222
+ * rather than a configuration error.
223
+ */
224
+ export function assertRestoreDirection<TAction extends string>(
225
+ config: ModerationEnforcementConfig<TAction>,
226
+ ): void {
227
+ const reverses: Partial<Record<TAction, TAction | readonly TAction[]>> =
228
+ config.reverses ?? {};
229
+ const entries = Object.entries(reverses) as [
230
+ TAction,
231
+ TAction | readonly TAction[] | undefined,
232
+ ][];
233
+ const actors = new Set<string>(entries.map(([actor]) => actor));
234
+ const targets = new Set<string>();
235
+ for (const [, undone] of entries) {
236
+ if (undone === undefined) continue;
237
+ for (const action of typeof undone === 'string' ? [undone] : undone) {
238
+ targets.add(action);
239
+ }
240
+ }
241
+ const inverted = restoreActions(config.restoreAction).filter(
242
+ (action) => targets.has(action) && !actors.has(action),
243
+ );
244
+ if (inverted.length > 0) throw new ModerationRestoreDirectionError(inverted);
245
+ }
246
+
247
+ export function planEnforcement<TAction extends string>(
248
+ decision: Decision,
249
+ config: ModerationEnforcementConfig<TAction>,
250
+ ): PlannedEnforcementAction<TAction>[] {
251
+ const fromRecommendations = decision.recommendedActions.map(
252
+ (recommended): PlannedEnforcementAction<TAction> => ({
253
+ action: actionForRecommendation(recommended.action, config),
254
+ reason: `CrowdSource recommended ${recommended.action}`,
255
+ recommendedAction: recommended.action,
256
+ }),
257
+ );
258
+
259
+ if (fromRecommendations.length > 0) {
260
+ const collapsed = collapse(
261
+ withRestoreForNoViolation(decision, fromRecommendations, config.restoreAction),
262
+ config,
263
+ );
264
+ return collapsed.length > 0
265
+ ? collapsed
266
+ : [
267
+ {
268
+ action: config.noneAction,
269
+ reason: 'No recommended action maps to an effect this application has',
270
+ },
271
+ ];
272
+ }
273
+
274
+ switch (decision.outcome) {
275
+ case 'violation': {
276
+ const severity = highestSeverity(decision);
277
+ /**
278
+ * A `violation` with no findings cannot happen — the contract refuses it —
279
+ * so an absent severity here means a newer CrowdSource sent something this
280
+ * code has not seen. A human looks at it rather than a default removing an
281
+ * object.
282
+ */
283
+ if (severity === undefined) {
284
+ return [
285
+ {
286
+ action: config.reviewAction,
287
+ reason: 'Violation carried no finding severity this version understands',
288
+ },
289
+ ];
290
+ }
291
+ return [
292
+ {
293
+ action: config.severityFallback?.[severity] ?? config.reviewAction,
294
+ reason: `Violation with no recommended action, highest severity ${severity}`,
295
+ },
296
+ ];
297
+ }
298
+
299
+ case 'no_violation':
300
+ /**
301
+ * A restore, always planned — even when nothing was restricted. The
302
+ * executor records it as not applied with the reason, which is how "we
303
+ * checked and there was nothing to undo" is distinguishable from "we never
304
+ * looked".
305
+ */
306
+ {
307
+ const declared = restoreActions(config.restoreAction);
308
+ return declared.length === 0
309
+ ? [
310
+ {
311
+ action: config.noneAction,
312
+ reason: 'No violation, and this application has nothing to restore',
313
+ },
314
+ ]
315
+ : declared.map((action) => ({
316
+ action,
317
+ reason: 'No violation: undo any earlier restriction',
318
+ }));
319
+ }
320
+
321
+ case 'insufficient_context':
322
+ case 'inconclusive':
323
+ case 'escalated':
324
+ /**
325
+ * None of these is "remove", and none is "it was fine": absence of
326
+ * consensus is neither guilt nor innocence, so the application changes
327
+ * nothing on its own and asks a human.
328
+ */
329
+ return [
330
+ {
331
+ action: config.reviewAction,
332
+ reason: `Outcome ${decision.outcome}: no automatic action, internal review`,
333
+ },
334
+ ];
335
+
336
+ case 'content_unavailable':
337
+ case 'duplicate':
338
+ return [
339
+ {
340
+ action: config.noneAction,
341
+ reason: `Outcome ${decision.outcome}: nothing to enforce`,
342
+ },
343
+ ];
344
+
345
+ default:
346
+ /**
347
+ * An outcome the contract does not currently define. A newer server must
348
+ * not break an older client, and the safe reading of an unknown outcome is
349
+ * a human, never a default effect.
350
+ */
351
+ return [
352
+ {
353
+ action: config.reviewAction,
354
+ reason: 'Decision outcome not recognised by this version of the application',
355
+ },
356
+ ];
357
+ }
358
+ }
359
+
360
+ /**
361
+ * The one action worth recording on the report.
362
+ *
363
+ * One field, several planned actions, so it has to be the one that answers "what
364
+ * happened to this object". The application's `precedence` decides, strongest
365
+ * first — and `reviewAction` should sit near the end but ABOVE `noneAction`,
366
+ * because a reporter told "nothing happened" when a human is about to look is
367
+ * being told something untrue.
368
+ */
369
+ export function primaryAction<TAction extends string>(
370
+ actions: readonly TAction[],
371
+ precedence: readonly TAction[],
372
+ ): TAction | undefined {
373
+ for (const candidate of precedence) {
374
+ if (actions.includes(candidate)) return candidate;
375
+ }
376
+ return actions[0];
377
+ }