@delmaredigital/payload-experiments 0.2.0-rc.6 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +129 -11
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +24 -16
  4. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  5. package/dist/admin/actions/ConfirmDialog.js +37 -82
  6. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  7. package/dist/admin/actions/DecideDialog.js +30 -13
  8. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  9. package/dist/admin/actions/MakeContentResult.js +115 -16
  10. package/dist/admin/actions/actions.model.d.ts +2 -4
  11. package/dist/admin/actions/actions.model.js +2 -3
  12. package/dist/admin/allocation.model.js +3 -5
  13. package/dist/admin/api.d.ts +10 -4
  14. package/dist/admin/api.js +11 -7
  15. package/dist/admin/copy/actions.d.ts +39 -3
  16. package/dist/admin/copy/actions.js +40 -4
  17. package/dist/admin/copy/armSelect.js +5 -5
  18. package/dist/admin/copy/arms.d.ts +1 -7
  19. package/dist/admin/copy/arms.js +1 -7
  20. package/dist/admin/copy/audience.d.ts +2 -1
  21. package/dist/admin/copy/audience.js +3 -2
  22. package/dist/admin/copy/common.d.ts +13 -6
  23. package/dist/admin/copy/common.js +14 -8
  24. package/dist/admin/copy/goals.d.ts +21 -6
  25. package/dist/admin/copy/goals.js +27 -6
  26. package/dist/admin/copy/readiness.d.ts +19 -0
  27. package/dist/admin/copy/readiness.js +19 -0
  28. package/dist/admin/copy/recommendation.d.ts +0 -3
  29. package/dist/admin/copy/recommendation.js +0 -3
  30. package/dist/admin/copy/results.d.ts +41 -2
  31. package/dist/admin/copy/results.js +42 -3
  32. package/dist/admin/copy/review.d.ts +1 -1
  33. package/dist/admin/copy/review.js +1 -1
  34. package/dist/admin/copy/setting.d.ts +2 -0
  35. package/dist/admin/copy/setting.js +2 -0
  36. package/dist/admin/copy/surface.d.ts +7 -4
  37. package/dist/admin/copy/surface.js +9 -4
  38. package/dist/admin/copy/testPage.d.ts +12 -0
  39. package/dist/admin/copy/testPage.js +12 -0
  40. package/dist/admin/format/date.d.ts +2 -0
  41. package/dist/admin/format/date.js +10 -0
  42. package/dist/admin/labels.d.ts +2 -7
  43. package/dist/admin/labels.js +2 -7
  44. package/dist/admin/previewLinks.model.d.ts +2 -2
  45. package/dist/admin/previewLinks.model.js +5 -7
  46. package/dist/admin/results/CiChart.js +11 -21
  47. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  48. package/dist/admin/results/DataQualityPanel.js +30 -17
  49. package/dist/admin/results/DataTable.d.ts +16 -0
  50. package/dist/admin/results/DataTable.js +152 -0
  51. package/dist/admin/results/StatTile.d.ts +7 -0
  52. package/dist/admin/results/StatTile.js +22 -0
  53. package/dist/admin/results/VerdictHero.d.ts +13 -0
  54. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  55. package/dist/admin/results/releasePages.model.d.ts +17 -0
  56. package/dist/admin/results/releasePages.model.js +10 -0
  57. package/dist/admin/results/results.css +64 -0
  58. package/dist/admin/results/results.model.d.ts +6 -2
  59. package/dist/admin/results/results.model.js +12 -5
  60. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  61. package/dist/admin/surface/ResultsRoute.js +60 -31
  62. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  63. package/dist/admin/surface/SurfaceShell.js +49 -14
  64. package/dist/admin/surface/TestsHome.d.ts +6 -1
  65. package/dist/admin/surface/TestsHome.js +183 -112
  66. package/dist/admin/surface/TestsSurface.js +56 -16
  67. package/dist/admin/surface/contracts.d.ts +1 -0
  68. package/dist/admin/surface/goals/GoalForm.js +341 -308
  69. package/dist/admin/surface/goals/GoalsList.js +123 -89
  70. package/dist/admin/surface/goals/data.d.ts +8 -2
  71. package/dist/admin/surface/goals/data.js +11 -3
  72. package/dist/admin/surface/goals/goals.css +10 -13
  73. package/dist/admin/surface/home.model.d.ts +7 -8
  74. package/dist/admin/surface/home.model.js +23 -12
  75. package/dist/admin/surface/newTest/NewTest.js +5 -5
  76. package/dist/admin/surface/pageBlock/parts.js +31 -26
  77. package/dist/admin/surface/readiness/ActionDialogs.js +43 -14
  78. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  79. package/dist/admin/surface/readiness/Banner.js +63 -36
  80. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  81. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  82. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  83. package/dist/admin/surface/readiness/readiness.model.js +18 -9
  84. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  85. package/dist/admin/surface/readiness/useTransition.js +2 -1
  86. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  87. package/dist/admin/surface/results/ReleasePages.js +116 -0
  88. package/dist/admin/surface/results/ResultsBody.d.ts +15 -2
  89. package/dist/admin/surface/results/ResultsBody.js +281 -107
  90. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  91. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  92. package/dist/admin/surface/results/recommendation.model.js +2 -6
  93. package/dist/admin/surface/results/useResults.d.ts +21 -3
  94. package/dist/admin/surface/results/useResults.js +2 -1
  95. package/dist/admin/surface/setting/parts.js +75 -88
  96. package/dist/admin/surface/setting/setting.css +3 -15
  97. package/dist/admin/surface/surface.css +62 -47
  98. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  99. package/dist/admin/surface/test/GoalSection.js +39 -32
  100. package/dist/admin/surface/test/LengthSection.js +46 -34
  101. package/dist/admin/surface/test/Rail.d.ts +4 -0
  102. package/dist/admin/surface/test/Rail.js +83 -0
  103. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  104. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  105. package/dist/admin/surface/test/Section.d.ts +1 -1
  106. package/dist/admin/surface/test/Section.js +36 -16
  107. package/dist/admin/surface/test/TargetingSection.js +168 -142
  108. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  109. package/dist/admin/surface/test/TestPage.js +146 -59
  110. package/dist/admin/surface/test/VersionsSection.js +151 -120
  111. package/dist/admin/surface/test/fields.d.ts +11 -4
  112. package/dist/admin/surface/test/fields.js +84 -85
  113. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  114. package/dist/admin/surface/test/jumpToSection.js +10 -0
  115. package/dist/admin/surface/test/pageParts.js +9 -3
  116. package/dist/admin/surface/test/testPage.css +81 -14
  117. package/dist/admin/surface/useTestDocument.d.ts +1 -0
  118. package/dist/admin/surface/useTestDocument.js +40 -9
  119. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  120. package/dist/admin/targeting/targeting.model.js +3 -8
  121. package/dist/admin/theme.css +115 -36
  122. package/dist/admin/theme.d.ts +38 -0
  123. package/dist/admin/theme.js +38 -1
  124. package/dist/admin/useApi.d.ts +17 -2
  125. package/dist/admin/views/ExperimentsRootView.js +12 -2
  126. package/dist/collections/experiments/config.js +44 -1
  127. package/dist/collections/experiments/lockHook.js +16 -2
  128. package/dist/collections/identities.js +6 -1
  129. package/dist/collections/metrics.js +7 -0
  130. package/dist/collections/metricsGuards.d.ts +9 -0
  131. package/dist/collections/metricsGuards.js +33 -0
  132. package/dist/constants.d.ts +1 -0
  133. package/dist/constants.js +1 -0
  134. package/dist/contentDone.d.ts +4 -0
  135. package/dist/contentDone.js +5 -0
  136. package/dist/db/identities.d.ts +8 -7
  137. package/dist/db/identities.js +9 -6
  138. package/dist/db/metricUsage.d.ts +19 -0
  139. package/dist/db/metricUsage.js +54 -0
  140. package/dist/db/names.d.ts +5 -0
  141. package/dist/db/names.js +5 -0
  142. package/dist/db/publishedContent.d.ts +10 -0
  143. package/dist/db/publishedContent.js +43 -0
  144. package/dist/db/results.d.ts +2 -0
  145. package/dist/db/results.js +6 -2
  146. package/dist/db/snapshots.d.ts +18 -6
  147. package/dist/db/snapshots.js +23 -7
  148. package/dist/db/staleEdit.d.ts +6 -0
  149. package/dist/db/staleEdit.js +10 -0
  150. package/dist/db/sweep.js +11 -6
  151. package/dist/endpoints/events.js +3 -3
  152. package/dist/endpoints/index.js +19 -1
  153. package/dist/endpoints/recompute.js +2 -1
  154. package/dist/endpoints/results.js +5 -0
  155. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  156. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  157. package/dist/endpoints/summary.js +20 -2
  158. package/dist/endpoints/transitions.d.ts +15 -0
  159. package/dist/endpoints/transitions.js +36 -0
  160. package/dist/errors.d.ts +8 -0
  161. package/dist/errors.js +28 -0
  162. package/dist/exports/adminClient.d.ts +1 -0
  163. package/dist/exports/adminClient.js +1 -0
  164. package/dist/exports/index.d.ts +2 -1
  165. package/dist/exports/index.js +1 -0
  166. package/dist/exports/puckEditor.d.ts +1 -0
  167. package/dist/exports/puckEditor.js +1 -0
  168. package/dist/exports/rsc.d.ts +5 -1
  169. package/dist/exports/rsc.js +2 -0
  170. package/dist/hooks/hostHook.js +3 -2
  171. package/dist/hooks/pageGuard.d.ts +5 -0
  172. package/dist/hooks/pageGuard.js +121 -0
  173. package/dist/labels/arms.d.ts +7 -0
  174. package/dist/labels/arms.js +7 -0
  175. package/dist/log.d.ts +7 -0
  176. package/dist/log.js +38 -0
  177. package/dist/manifest/compile.d.ts +5 -0
  178. package/dist/manifest/compile.js +5 -1
  179. package/dist/options.js +23 -0
  180. package/dist/plugin.js +26 -2
  181. package/dist/puck/copy.d.ts +1 -1
  182. package/dist/puck/copy.js +6 -6
  183. package/dist/puck/editor/editor.chrome.css +235 -0
  184. package/dist/puck/editor/editor.css +241 -43
  185. package/dist/results/aggregate.js +4 -3
  186. package/dist/results/analyse.d.ts +4 -1
  187. package/dist/results/analyse.js +27 -11
  188. package/dist/results/cuped.js +3 -2
  189. package/dist/results/identityCredit.d.ts +15 -7
  190. package/dist/results/identityCredit.js +32 -33
  191. package/dist/results/rolloutUnits.js +2 -1
  192. package/dist/results/snapshot.js +8 -2
  193. package/dist/results/srm.js +2 -1
  194. package/dist/results/verdict.d.ts +2 -0
  195. package/dist/results/verdict.js +18 -1
  196. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  197. package/dist/sanitizeReadinessDetail.js +54 -0
  198. package/dist/server/linkIdentity.d.ts +10 -1
  199. package/dist/server/linkIdentity.js +20 -18
  200. package/dist/server/metadata.d.ts +5 -0
  201. package/dist/server/metadata.js +9 -1
  202. package/dist/server/recordContentChange.js +2 -1
  203. package/dist/server/recordExposures.js +3 -3
  204. package/dist/server/runResults.d.ts +3 -2
  205. package/dist/server/runResults.js +125 -34
  206. package/dist/server/trackConversion.js +5 -6
  207. package/dist/targeting/values.d.ts +4 -0
  208. package/dist/targeting/values.js +10 -0
  209. package/dist/transitions/addMetric.js +8 -0
  210. package/dist/transitions/archive.js +2 -3
  211. package/dist/transitions/checkStart.d.ts +6 -3
  212. package/dist/transitions/checkStart.js +15 -0
  213. package/dist/transitions/contentFields.d.ts +7 -0
  214. package/dist/transitions/contentFields.js +20 -0
  215. package/dist/transitions/decide.js +6 -1
  216. package/dist/transitions/deleteExperiment.js +2 -1
  217. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  218. package/dist/transitions/dismissContentEntry.js +45 -0
  219. package/dist/transitions/makeContent.d.ts +5 -2
  220. package/dist/transitions/makeContent.js +217 -118
  221. package/dist/transitions/phaseRecord.d.ts +8 -2
  222. package/dist/transitions/placements.d.ts +1 -0
  223. package/dist/transitions/placements.js +4 -0
  224. package/dist/transitions/preflightData.d.ts +1 -1
  225. package/dist/transitions/preflightData.js +42 -4
  226. package/dist/transitions/releasePages.d.ts +8 -0
  227. package/dist/transitions/releasePages.js +26 -0
  228. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  229. package/dist/transitions/restoreContentEntry.js +47 -0
  230. package/dist/transitions/start.js +30 -5
  231. package/dist/types/contracts.d.ts +14 -0
  232. package/dist/types/endpoints.d.ts +34 -3
  233. package/dist/types/index.d.ts +1 -0
  234. package/dist/types/index.js +1 -0
  235. package/dist/types/reasons.d.ts +3 -0
  236. package/dist/types/reasons.js +1 -0
  237. package/dist/types/snapshot.d.ts +6 -1
  238. package/dist/ui/Banner.d.ts +2 -0
  239. package/dist/ui/Banner.js +2 -0
  240. package/dist/ui/Breadcrumb.d.ts +7 -0
  241. package/dist/ui/Breadcrumb.js +25 -0
  242. package/dist/ui/Button.d.ts +15 -0
  243. package/dist/ui/Button.js +32 -0
  244. package/dist/ui/Card.d.ts +12 -0
  245. package/dist/ui/Card.js +40 -0
  246. package/dist/ui/Checkbox.d.ts +2 -0
  247. package/dist/ui/Checkbox.js +2 -0
  248. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  249. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  250. package/dist/ui/Dialog.d.ts +13 -0
  251. package/dist/ui/Dialog.js +85 -0
  252. package/dist/ui/EmptyState.d.ts +2 -0
  253. package/dist/ui/EmptyState.js +2 -0
  254. package/dist/ui/Feedback.d.ts +24 -0
  255. package/dist/ui/Feedback.js +40 -0
  256. package/dist/ui/Inputs.d.ts +34 -0
  257. package/dist/ui/Inputs.js +225 -0
  258. package/dist/ui/NumberInput.d.ts +2 -0
  259. package/dist/ui/NumberInput.js +2 -0
  260. package/dist/ui/Range.d.ts +2 -0
  261. package/dist/ui/Range.js +2 -0
  262. package/dist/ui/SearchInput.d.ts +2 -0
  263. package/dist/ui/SearchInput.js +2 -0
  264. package/dist/ui/SectionCard.d.ts +2 -0
  265. package/dist/ui/SectionCard.js +2 -0
  266. package/dist/ui/Select.d.ts +2 -0
  267. package/dist/ui/Select.js +2 -0
  268. package/dist/ui/StatusChip.d.ts +2 -0
  269. package/dist/ui/StatusChip.js +2 -0
  270. package/dist/ui/Tabs.d.ts +14 -0
  271. package/dist/ui/Tabs.js +54 -0
  272. package/dist/ui/TextInput.d.ts +2 -0
  273. package/dist/ui/TextInput.js +2 -0
  274. package/dist/ui/Textarea.d.ts +2 -0
  275. package/dist/ui/Textarea.js +2 -0
  276. package/dist/ui/Toggle.d.ts +2 -0
  277. package/dist/ui/Toggle.js +2 -0
  278. package/dist/ui/accent.d.ts +21 -0
  279. package/dist/ui/accent.js +81 -0
  280. package/dist/ui/index.d.ts +9 -0
  281. package/dist/ui/index.js +9 -0
  282. package/dist/ui/ui.css +92 -0
  283. package/package.json +1 -1
  284. package/dist/admin/components/chipsInput.css +0 -11
  285. package/dist/admin/results/ArmTable.d.ts +0 -8
  286. package/dist/admin/results/ArmTable.js +0 -113
  287. package/dist/admin/results/VerdictCard.d.ts +0 -9
  288. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  289. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  290. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  291. package/dist/admin/surface/test/TestHeader.js +0 -69
@@ -1,3 +1,6 @@
1
+ import { XpAnalysisError } from '../errors.js';
2
+ import { safeError } from '../log.js';
3
+ import { createLocalReq } from 'payload';
1
4
  import { resultsCandidates, resultsExecutor, writeSnapshots } from '../db/snapshots.js';
2
5
  import { sweepEvents } from '../db/sweep.js';
3
6
  import { armMismatchCount, identityRows, metricEventRows, phaseExposureRows, rolloutExposureRows } from '../db/results.js';
@@ -5,8 +8,8 @@ import { raceDeadline, ResultsBudgetError, RESULTS_TIMEOUT_MARGIN_MS } from '../
5
8
  import { analysePhase } from '../results/analyse.js';
6
9
  import { firstExposures, perUnitValues, timestampMs } from '../results/aggregate.js';
7
10
  import { rolloutAnalysisUnits } from '../results/rolloutUnits.js';
8
- import { creditUserEvents } from '../results/identityCredit.js';
9
- async function analyseCandidate(exec, candidate, options, now, deadline, unavailableMetrics) {
11
+ import { creditUserEvents, eligibleIdentityLinks } from '../results/identityCredit.js';
12
+ async function analyseCandidate(exec, candidate, options, now, deadline, unavailableMetrics, logger) {
10
13
  const { phase } = candidate;
11
14
  // Rollout assignment preserves the original test ranges, but its SRM compares
12
15
  // only the shipped group against holdout with weights {100-h, h}.
@@ -58,6 +61,7 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
58
61
  ...new Set(identities.map((identity)=>identity.userId))
59
62
  ];
60
63
  const linked = new Set(userIds);
64
+ const identityConflicts = new Set();
61
65
  const credit = (events, userKeyed)=>{
62
66
  if (candidate.unitType === 'user') return events.map((event)=>({
63
67
  ...event,
@@ -68,13 +72,12 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
68
72
  value: event.value,
69
73
  ts: event.ts
70
74
  }));
71
- const credited = creditUserEvents(units, identities, userEvents).events;
75
+ const credited = creditUserEvents(units, identities, userEvents, (userId)=>identityConflicts.add(userId)).events;
72
76
  return userKeyed ? credited : [
73
77
  ...events.filter((event)=>event.userId == null || !linked.has(event.userId)),
74
78
  ...credited
75
79
  ];
76
80
  };
77
- const diagnostics = creditUserEvents(units, identities, []);
78
81
  const metricData = [];
79
82
  for (const definition of phase.metricDefinitions){
80
83
  try {
@@ -88,7 +91,7 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
88
91
  let events, covariates;
89
92
  if (definition.source.startsWith('events:')) {
90
93
  const eventName = definition.source.slice(7);
91
- if (!options.eventSources?.some((source)=>source.name === eventName)) throw new Error('Metric source unavailable');
94
+ if (!options.eventSources?.some((source)=>source.name === eventName)) throw new XpAnalysisError('source-unavailable');
92
95
  const read = async (start)=>{
93
96
  if (candidate.unitType === 'user') return metricEventRows(exec, {
94
97
  eventName,
@@ -131,9 +134,9 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
131
134
  }
132
135
  } else {
133
136
  const source = options.metricSources?.find((source)=>source.id === definition.source);
134
- if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new Error('Metric source unavailable');
137
+ if (!source || source.kind !== kind || definition.keyedBy && source.keyedBy !== definition.keyedBy) throw new XpAnalysisError('source-unavailable');
135
138
  const userKeyed = source.keyedBy === 'user' && candidate.unitType === 'visitor';
136
- if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new Error('Metric unit type unavailable');
139
+ if (candidate.unitType === 'user' && source.keyedBy !== 'user') throw new XpAnalysisError('unit-type-unavailable');
137
140
  const sourceIds = userKeyed ? userIds : ids;
138
141
  events = credit(await raceDeadline(deadline, ()=>source.query({
139
142
  from,
@@ -141,35 +144,51 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
141
144
  unitIds: sourceIds
142
145
  })), userKeyed);
143
146
  if (phase.analysis.cuped === 'auto' && source.covariate) {
144
- const sourceUnits = userKeyed ? creditUserEvents(units, identities, userIds.map((userId)=>({
145
- userId,
146
- ts: now,
147
- value: 0
148
- }))).events.map((event)=>({
149
- unitId: identities.find((identity)=>identity.anonymousId === event.unitId).userId,
150
- firstExposureTs: units.get(event.unitId).firstTs
147
+ const userUnits = new Map();
148
+ if (userKeyed) for (const link of identities){
149
+ const unit = units.get(link.anonymousId);
150
+ if (!unit) continue;
151
+ // Hosts aggregate pre-period values in SQL; per-event rows over 14 days would break the one-row-per-unit contract and multiply host traffic.
152
+ // CUPED only reduces variance: mis-credited pre-exposure values add noise, but cannot bias the effect because they are independent of assignment.
153
+ if (!eligibleIdentityLinks([
154
+ link
155
+ ], unit.firstTs).length) continue;
156
+ const previous = userUnits.get(link.userId);
157
+ if (!previous || +unit.firstTs < +previous.firstExposureTs || +unit.firstTs === +previous.firstExposureTs && link.anonymousId < previous.unitId) {
158
+ userUnits.set(link.userId, {
159
+ unitId: link.anonymousId,
160
+ firstExposureTs: unit.firstTs
161
+ });
162
+ }
163
+ }
164
+ const sourceUnits = userKeyed ? [
165
+ ...userUnits
166
+ ].map(([unitId, unit])=>({
167
+ unitId,
168
+ firstExposureTs: unit.firstExposureTs
151
169
  })) : [
152
170
  ...units
153
171
  ].map(([unitId, unit])=>({
154
172
  unitId,
155
- firstExposureTs: unit.firstTs
173
+ firstExposureTs: new Date(timestampMs(unit.firstTs))
156
174
  }));
157
175
  const pre = await raceDeadline(deadline, ()=>source.covariate({
158
176
  units: sourceUnits,
159
177
  lookbackDays: 14
160
178
  }));
161
- if (pre.some((row)=>!Number.isFinite(row.value))) throw new Error('Covariates must be finite');
162
- const credited = credit(pre.map((row)=>({
163
- ...row,
164
- ts: now
165
- })), userKeyed);
166
- covariates = new Map(credited.map((row)=>[
167
- row.unitId,
168
- row.value
169
- ]));
179
+ if (pre.some((row)=>!Number.isFinite(row.value))) throw new XpAnalysisError('non-finite-covariates');
180
+ covariates = new Map(pre.flatMap((row)=>{
181
+ const unitId = userKeyed ? userUnits.get(row.unitId)?.unitId : row.unitId;
182
+ return unitId === undefined ? [] : [
183
+ [
184
+ unitId,
185
+ row.value
186
+ ]
187
+ ];
188
+ }));
170
189
  }
171
190
  }
172
- if (events.some((event)=>!Number.isFinite(event.value))) throw new Error('Metric values must be finite');
191
+ if (events.some((event)=>!Number.isFinite(event.value))) throw new XpAnalysisError('non-finite-values');
173
192
  metricData.push({
174
193
  metricId: definition.metricId,
175
194
  values: perUnitValues(units, events, {
@@ -182,7 +201,22 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
182
201
  } : {}
183
202
  });
184
203
  } catch (error) {
185
- const reason = error instanceof Error ? error.message : 'Metric source unavailable';
204
+ let reason = error instanceof ResultsBudgetError ? 'budget' : 'source-failed';
205
+ if (error instanceof XpAnalysisError) {
206
+ switch(error.code){
207
+ case 'source-unavailable':
208
+ case 'unit-type-unavailable':
209
+ case 'non-finite-values':
210
+ case 'non-finite-covariates':
211
+ reason = error.code;
212
+ break;
213
+ }
214
+ }
215
+ logger.error({
216
+ err: safeError(error),
217
+ experimentId: phase.experiment,
218
+ metricId: definition.metricId
219
+ }, 'Experiment metric source failed');
186
220
  metricData.push({
187
221
  metricId: definition.metricId,
188
222
  values: new Map(),
@@ -204,9 +238,14 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
204
238
  units,
205
239
  metricData,
206
240
  status,
241
+ onMetricError: (error, metricId)=>logger.error({
242
+ err: safeError(error),
243
+ experimentId: phase.experiment,
244
+ metricId
245
+ }, 'Experiment metric analysis failed'),
207
246
  excludedMultiArm: exposed.excludedMultiArm,
208
247
  armMismatch: await armMismatchCount(exec, query),
209
- identityConflictShare: userIds.length ? diagnostics.conflicts / userIds.length : 0,
248
+ identityConflictShare: userIds.length ? identityConflicts.size / userIds.length : 0,
210
249
  ...phase.phaseKind === 'rollout' ? {
211
250
  winnerArm: phase.arms.find((arm)=>arm.kind !== 'holdout').key
212
251
  } : {}
@@ -247,12 +286,12 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
247
286
  if (!(error instanceof ResultsBudgetError)) summary.failed++;
248
287
  summary.budgetExhausted = error instanceof ResultsBudgetError;
249
288
  payload.logger.error({
250
- err: error,
289
+ err: safeError(error),
251
290
  experimentId
252
291
  }, 'Experiment scheduling failed');
253
292
  }
254
- // Query order is oldest phase snapshot first; grouping keeps each experiment's
255
- // phases together while preserving that scheduling priority.
293
+ // Analysis precedes health-only refreshes, then oldest phase snapshots first;
294
+ // grouping keeps each experiment's phases together with that priority.
256
295
  const experiments = new Map();
257
296
  for (const candidate of candidates){
258
297
  const id = candidate.phase.experiment;
@@ -271,10 +310,62 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
271
310
  const snapshots = [];
272
311
  for (const phase of phases){
273
312
  if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
274
- snapshots.push(await analyseCandidate(exec, phase, options, now, analysisDeadline, summary.unavailableMetrics));
313
+ // Health refreshes must not reopen inference after the late-beacon grace.
314
+ snapshots.push(phase.frozenSnapshot ? {
315
+ ...phase.frozenSnapshot,
316
+ computedAt: now.toISOString()
317
+ } : await analyseCandidate(exec, phase, options, now, analysisDeadline, summary.unavailableMetrics, payload.logger));
318
+ }
319
+ if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
320
+ const current = phases.find((candidate)=>candidate.kind === 'page' && candidate.phase.phase === candidate.currentPhase && (candidate.status === 'running' || candidate.status === 'decided'));
321
+ if (current) {
322
+ const req = await raceDeadline(analysisDeadline, ()=>createLocalReq({}, payload));
323
+ const health = [];
324
+ // Decided tests serve only the winner and Original, including rollouts.
325
+ const arms = current.phase.phaseKind === 'rollout' ? [
326
+ ...current.phase.arms.filter((arm)=>arm.kind !== 'holdout'),
327
+ ...current.testArms.filter((arm)=>arm.kind === 'control')
328
+ ] : current.testArms.filter((arm)=>arm.kind !== 'holdout' && (current.status !== 'decided' || arm.kind === 'control' || arm.key === current.winnerArm));
329
+ const targets = new Set(arms.flatMap((arm)=>arm.pageTarget ? [
330
+ arm.pageTarget
331
+ ] : []));
332
+ for (const path of targets){
333
+ let published = false, found = false, unchecked = false;
334
+ for (const collection of options.pages?.collections ?? []){
335
+ try {
336
+ const page = await raceDeadline(analysisDeadline, ()=>collection.findByPath(path, req));
337
+ found ||= page != null;
338
+ published ||= page?.published === true;
339
+ } catch (error) {
340
+ if (error instanceof ResultsBudgetError) throw error;
341
+ payload.logger.error({
342
+ err: safeError(error),
343
+ experimentId: id
344
+ }, 'Experiment version page lookup failed');
345
+ unchecked = true;
346
+ }
347
+ }
348
+ if (!published) health.push({
349
+ code: !found && (unchecked || !options.pages?.collections.length) ? 'version-page-unchecked' : 'version-page-unavailable',
350
+ path
351
+ });
352
+ }
353
+ for (const snapshot of snapshots)snapshot.health = health;
275
354
  }
276
355
  if (Date.now() >= analysisDeadline) throw new ResultsBudgetError();
277
- await writeSnapshots(exec, snapshots);
356
+ // Order and optional empty health are semantically identical. Frozen
357
+ // inference gets a new row only when its page availability changes.
358
+ const canonicalHealth = (snapshot)=>JSON.stringify((snapshot.health ?? []).map(({ code, path })=>[
359
+ code,
360
+ path
361
+ ]).map((entry)=>JSON.stringify(entry)).sort((a, b)=>a < b ? -1 : a > b ? 1 : 0));
362
+ const changed = snapshots.filter((snapshot, index)=>!phases[index].frozenSnapshot || canonicalHealth(snapshot) !== canonicalHealth(phases[index].frozenSnapshot));
363
+ // Unchanged health is a successful refresh with nothing to write.
364
+ if (!changed.length) {
365
+ summary.ran++;
366
+ continue;
367
+ }
368
+ await writeSnapshots(exec, changed);
278
369
  summary.ran++;
279
370
  if (summary.processedIds.length < 100) summary.processedIds.push(id);
280
371
  } catch (error) {
@@ -282,7 +373,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
282
373
  if (!(error instanceof ResultsBudgetError)) summary.failed++;
283
374
  summary.budgetExhausted ||= error instanceof ResultsBudgetError;
284
375
  payload.logger.error({
285
- err: error,
376
+ err: safeError(error),
286
377
  experimentId: id
287
378
  }, 'Experiment results failed');
288
379
  }
@@ -302,7 +393,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
302
393
  if (!(error instanceof ResultsBudgetError)) summary.failed++;
303
394
  summary.budgetExhausted ||= error instanceof ResultsBudgetError;
304
395
  payload.logger.error({
305
- err: error
396
+ err: safeError(error)
306
397
  }, 'Experiment retention sweep failed');
307
398
  }
308
399
  }
@@ -1,3 +1,4 @@
1
+ import { safeError } from '../log.js';
1
2
  import { NextRequest } from 'next/server.js';
2
3
  import { pooledExecutor } from '../db/executor.js';
3
4
  import { insertEvents } from '../db/events.js';
@@ -39,9 +40,8 @@ export async function trackConversion(serverRuntime, event) {
39
40
  } catch (error) {
40
41
  try {
41
42
  payload.logger.error({
42
- err: error,
43
- msg: 'Experiments: trackConversion getUser failed'
44
- });
43
+ err: safeError(error)
44
+ }, 'Experiments: trackConversion getUser failed');
45
45
  } catch {}
46
46
  }
47
47
  }
@@ -61,9 +61,8 @@ export async function trackConversion(serverRuntime, event) {
61
61
  } catch (error) {
62
62
  try {
63
63
  payload?.logger.error({
64
- err: error,
65
- msg: 'Experiments: trackConversion failed'
66
- });
64
+ err: safeError(error)
65
+ }, 'Experiments: trackConversion failed');
67
66
  } catch {}
68
67
  }
69
68
  }
@@ -0,0 +1,4 @@
1
+ /** Trim and deduplicate list inputs while preserving commas inside array items. */
2
+ export declare function coerceListValue(raw: unknown): string[];
3
+ /** Only the read boundary accepts legacy comma-separated document values. */
4
+ export declare function readListValue(raw: unknown): string[];
@@ -0,0 +1,10 @@
1
+ /** Trim and deduplicate list inputs while preserving commas inside array items. */ export function coerceListValue(raw) {
2
+ return [
3
+ ...new Set((Array.isArray(raw) ? raw.map(String) : [
4
+ String(raw ?? '')
5
+ ]).map((value)=>value.trim()).filter(Boolean))
6
+ ];
7
+ }
8
+ /** Only the read boundary accepts legacy comma-separated document values. */ export function readListValue(raw) {
9
+ return coerceListValue(typeof raw === 'string' ? raw.split(',') : raw);
10
+ }
@@ -1,3 +1,4 @@
1
+ import { lockMetricsForFreeze } from '../db/metricUsage.js';
1
2
  import { readState } from '../db/state.js';
2
3
  import { XpRefusal } from '../errors.js';
3
4
  import { relationshipId } from './checkStart.js';
@@ -44,7 +45,14 @@ export async function addMetric(req, { experimentId, metric, role, options }) {
44
45
  if (ids.some((id)=>id !== undefined && String(id) === String(metric))) refuse('duplicate-metric', {
45
46
  message: 'This metric is already part of the experiment.'
46
47
  });
48
+ const lockedMetrics = await lockMetricsForFreeze(exec, caps.definitions.map((metric)=>metric.id));
49
+ if (!lockedMetrics) {
50
+ throw new XpRefusal('stale-draft', 'This goal was deleted. Choose another goal.', undefined, 409);
51
+ }
47
52
  const definition = caps.definitions[0];
53
+ if (lockedMetrics[0].slug !== definition.slug || lockedMetrics[0].source !== definition.source) {
54
+ throw new XpRefusal('stale-draft', 'This goal changed. Review it again before adding it.', undefined, 409);
55
+ }
48
56
  if (current.unitType === 'user' && definition.keyedBy === 'visitor' && !definition.source.startsWith('events:')) refuse('unit-source-mismatch');
49
57
  // §7.8: phase lookup serves the access-authorised transition write.
50
58
  const phases = await req.payload.find({
@@ -1,3 +1,4 @@
1
+ import { contentDone } from '../contentDone.js';
1
2
  import { relationshipId } from './checkStart.js';
2
3
  import { expectedConfirm, matchesConfirm, storedWinnerLabel } from './confirmPhrases.js';
3
4
  import { withTransition } from './kernel.js';
@@ -14,9 +15,7 @@ export async function archive(req, { experimentId, mode, confirmation }) {
14
15
  if (mode === 'stopped' ? current.status !== 'stopped' : current.status !== 'decided') refuse('invalid-status');
15
16
  if (mode === 'hard-coded' && current.kind !== 'code') refuse('invalid-status');
16
17
  if (mode === 'after-content') {
17
- const report = decision?.contentReport;
18
- const docs = Array.isArray(report) ? report : report?.docs;
19
- if (!Array.isArray(docs) || !docs.every((doc)=>doc?.ok === true)) refuse('invalid-status');
18
+ if (!contentDone(decision?.contentReport)) refuse('invalid-status');
20
19
  }
21
20
  if (mode === 'discard' && !matchesConfirm(expectedConfirm('discard', ''), confirmation)) refuse('confirmation-required');
22
21
  if (mode === 'hard-coded') {
@@ -1,7 +1,7 @@
1
1
  import type { Arm, DocId, ExperimentKind, ExposurePolicy, FlagPoint, MetricSource, ResolvedOptions, Status, TargetingRule, UnitType } from '../types/contracts.js';
2
2
  import type { ArmIssueCode, PreflightResult } from '../types/endpoints.js';
3
3
  import type { BlockPlacement, FlagPlacement } from './placements.js';
4
- export type ReadinessCode = ArmIssueCode | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'page-no-targets' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
4
+ export type ReadinessCode = ArmIssueCode | 'invalid-status' | 'no-planned-duration' | 'no-primary-metric' | 'no-reach-per-day' | 'page-no-targets' | 'page-target-unpublished' | 'page-targets-unchecked' | 'version-page-unavailable' | 'version-page-unchecked' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'no-placements' | 'empty-slot' | 'make-content-unsupported' | 'layer-unit-mismatch' | 'layer-full' | 'unknown-metric-source' | 'unit-source-mismatch' | 'block-path-rule' | 'undeclared-targeting-key' | 'invalid-targeting-rule';
5
5
  export interface StartExperiment {
6
6
  id: DocId;
7
7
  slug: string;
@@ -38,6 +38,7 @@ export interface StartMetric {
38
38
  id: DocId;
39
39
  source: string;
40
40
  }
41
+ export type PlacementFailure = 'placements-failed' | 'forbidden' | 'not-found';
41
42
  export interface CheckStartInput {
42
43
  experiment: StartExperiment;
43
44
  metrics: StartMetric[];
@@ -45,9 +46,11 @@ export interface CheckStartInput {
45
46
  flagPoints?: FlagPoint[];
46
47
  placements?: BlockPlacement[];
47
48
  flagPlacements?: FlagPlacement[];
48
- placementError?: string;
49
- flagPlacementError?: string;
49
+ placementError?: PlacementFailure;
50
+ flagPlacementError?: PlacementFailure;
50
51
  flagPointBusy?: boolean;
52
+ unpublishedPageTargets?: string[];
53
+ pageTargetsUnchecked?: boolean;
51
54
  layer?: {
52
55
  unitType: UnitType;
53
56
  usedSlices: number[];
@@ -56,6 +56,9 @@ function invalidParams(arms, point) {
56
56
  'viewport'
57
57
  ].includes(e.exposurePolicy) || positive(e.analysis?.reachPerDay));
58
58
  add('page-no-targets', e.kind !== 'page' || e.arms.filter((arm)=>arm.kind !== 'holdout').every((arm)=>typeof arm.pageTarget === 'string' && arm.pageTarget.trim().length > 0));
59
+ add('page-target-unpublished', !input.unpublishedPageTargets?.length, input.unpublishedPageTargets?.[0]);
60
+ add('page-targets-unchecked', true);
61
+ if (input.pageTargetsUnchecked) checks[checks.length - 1].warning = true;
59
62
  add('page-user-unit', e.kind !== 'page' || e.unitType === 'visitor');
60
63
  const point = input.flagPoints?.find((point)=>point.id === e.flagPoint);
61
64
  add('flag-no-point', e.kind !== 'flag' || !!point);
@@ -72,6 +75,12 @@ function invalidParams(arms, point) {
72
75
  add('no-placements', e.kind !== 'block' || !input.placementError && placements.length > 0, input.placementError);
73
76
  const empty = placements.filter((placement)=>e.arms.some((arm)=>arm.kind !== 'holdout' && (!arm.slot || !(placement.slots[arm.slot] > 0))));
74
77
  add('empty-slot', e.kind !== 'block' || empty.length === 0, empty);
78
+ add('make-content-unsupported', true);
79
+ const localized = placements.find((placement)=>placement.localized);
80
+ if (localized) Object.assign(checks[checks.length - 1], {
81
+ warning: true,
82
+ detail: localized.title
83
+ });
75
84
  add('layer-unit-mismatch', e.layer == null || input.layer?.unitType === e.unitType);
76
85
  const free = 100 - new Set(input.layer?.usedSlices).size;
77
86
  add('layer-full', e.layer == null || !!input.layer && free >= e.allocation, {
@@ -82,6 +91,12 @@ function invalidParams(arms, point) {
82
91
  add('unknown-metric-source', unknown.length === 0, unknown);
83
92
  const mismatches = input.metrics.filter((metric)=>!metric.source.startsWith('events:') && e.unitType === 'user' && input.metricSources?.some((source)=>source.id === metric.source && source.keyedBy === 'visitor'));
84
93
  add('unit-source-mismatch', mismatches.length === 0, mismatches);
94
+ const pathRules = (e.targeting ?? []).flatMap((rule, index)=>rule.attribute === 'path' ? [
95
+ index
96
+ ] : []);
97
+ add('block-path-rule', e.kind !== 'block' || pathRules.length === 0, {
98
+ rules: pathRules
99
+ });
85
100
  const undeclared = (e.targeting ?? []).filter((rule)=>!Object.hasOwn(input.attributeKeys ?? {}, rule.attribute));
86
101
  add('undeclared-targeting-key', undeclared.length === 0, {
87
102
  message: 'Targeting uses an undeclared attribute.',
@@ -0,0 +1,7 @@
1
+ import type { Block, Field, SanitizedConfig } from 'payload';
2
+ /** Payload keeps both inline definitions and slug references in blockReferences. */
3
+ export declare function contentBlocks(field: Extract<Field, {
4
+ type: 'blocks';
5
+ }>, blocks?: Block[]): Block[];
6
+ /** Any localized data makes a full-document publish unsafe in Payload 3.90. */
7
+ export declare function hasLocalizedFields(fields: Field[], config: Pick<SanitizedConfig, 'localization' | 'blocks'>, seen?: Set<Field>): boolean;
@@ -0,0 +1,20 @@
1
+ /** Payload keeps both inline definitions and slug references in blockReferences. */ export function contentBlocks(field, blocks = []) {
2
+ return [
3
+ ...field.blocks,
4
+ ...(field.blockReferences ?? []).flatMap((reference)=>typeof reference === 'string' ? blocks.filter((block)=>block.slug === reference) : [
5
+ reference
6
+ ])
7
+ ];
8
+ }
9
+ /** Any localized data makes a full-document publish unsafe in Payload 3.90. */ export function hasLocalizedFields(fields, config, seen = new Set()) {
10
+ if (!config.localization) return false;
11
+ const blocks = config.blocks ?? [];
12
+ return fields.some((field)=>{
13
+ if (seen.has(field)) return false;
14
+ const branch = new Set(seen).add(field);
15
+ if ('localized' in field && field.localized) return true;
16
+ if ('tabs' in field && field.tabs.some((tab)=>tab.localized || hasLocalizedFields(tab.fields, config, branch))) return true;
17
+ if ('fields' in field && hasLocalizedFields(field.fields, config, branch)) return true;
18
+ return field.type === 'blocks' && contentBlocks(field, blocks).some((block)=>hasLocalizedFields(block.fields, config, branch));
19
+ });
20
+ }
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
1
2
  import { reclaimSlices } from '../db/slices.js';
2
3
  import { decideGuard } from '../results/decideGuard.js';
3
4
  import { relationshipId } from './checkStart.js';
@@ -112,7 +113,11 @@ export async function decide(req, { experimentId, winnerArm, note, confirmation
112
113
  allocationChanges: [],
113
114
  targeting: test.targeting,
114
115
  metricDefinitions: test.metricDefinitions,
115
- analysis: test.analysis,
116
+ analysis: {
117
+ ...test.analysis,
118
+ // Older test phases may predate freezing the effective tuning value.
119
+ sequentialTuningParameter: test.analysis?.sequentialTuningParameter ?? DEFAULT_SEQUENTIAL_TUNING_PARAMETER
120
+ },
116
121
  contentChanges: []
117
122
  });
118
123
  }
@@ -1,3 +1,4 @@
1
+ import { safeError } from '../log.js';
1
2
  import { txExecutor } from '../db/executor.js';
2
3
  import { deleteExperimentSnapshots, serializeDelete } from '../db/delete.js';
3
4
  import { deleteExperimentEvents } from '../db/events.js';
@@ -45,7 +46,7 @@ import { readExperiment, refuse } from './start.js';
45
46
  } catch (error) {
46
47
  req.payload.logger.error({
47
48
  experimentId,
48
- err: error
49
+ err: safeError(error)
49
50
  }, 'Experiment cascade failed; aborting delete');
50
51
  throw error;
51
52
  }
@@ -0,0 +1,8 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import type { DocId } from '../types/contracts.js';
3
+ import type { DismissContentEntryBody } from '../types/endpoints.js';
4
+ export declare function dismissContentEntry(req: PayloadRequest, { experimentId, collection, id }: DismissContentEntryBody & {
5
+ experimentId: DocId;
6
+ }): Promise<{
7
+ doc: import("payload").JsonObject & import("payload").TypeWithID;
8
+ }>;
@@ -0,0 +1,45 @@
1
+ import { relationshipId } from './checkStart.js';
2
+ import { withTransition } from './kernel.js';
3
+ import { readExperiment, refuse, updateExperiment } from './start.js';
4
+ export async function dismissContentEntry(req, { experimentId, collection, id }) {
5
+ const before = await readExperiment(req, experimentId);
6
+ // Only the audit report changes; assignment and the manifest stay the same.
7
+ const { result: doc } = await withTransition(req, {
8
+ experimentId,
9
+ layerId: relationshipId(before.layer),
10
+ flagPointId: before.kind === 'flag' ? before.flagPoint : undefined,
11
+ bump: false
12
+ }, async ({ req })=>{
13
+ const current = await readExperiment(req, experimentId);
14
+ const decision = current.decision;
15
+ const report = decision?.contentReport;
16
+ const index = Array.isArray(report?.docs) ? report.docs.findIndex((entry)=>entry.collection === collection && String(entry.id) === String(id)) : -1;
17
+ const entry = report?.docs?.[index];
18
+ if (current.status !== 'decided' || !decision?.winnerArm || decision.discardedAt) refuse('not-decided');
19
+ if (!entry) refuse('entry-not-found');
20
+ if (entry.ok !== false) refuse('entry-ok');
21
+ if (entry.dismissedAt) refuse('entry-dismissed');
22
+ const docs = report.docs.map((entry, i)=>i === index ? {
23
+ ...entry,
24
+ dismissedAt: new Date().toISOString(),
25
+ dismissedBy: req.user?.id == null ? null : String(req.user.id)
26
+ } : entry);
27
+ return updateExperiment(req, experimentId, {
28
+ decision: {
29
+ ...decision,
30
+ contentReport: {
31
+ ...report,
32
+ docs
33
+ }
34
+ }
35
+ });
36
+ });
37
+ req.payload.logger.info({
38
+ experimentId,
39
+ collection,
40
+ id
41
+ }, 'Experiment content entry dismissed');
42
+ return {
43
+ doc
44
+ };
45
+ }
@@ -1,11 +1,14 @@
1
1
  import { type PayloadRequest } from 'payload';
2
+ import type { ContentReportReason } from '../types/reasons.js';
2
3
  import type { DocId, ResolvedOptions } from '../types/contracts.js';
3
4
  type ContentDoc = {
4
5
  collection: string;
5
6
  id: DocId;
6
7
  ok: boolean;
7
- detail?: string;
8
- error?: string;
8
+ detail?: ContentReportReason;
9
+ error?: ContentReportReason;
10
+ dismissedAt?: string;
11
+ dismissedBy?: string | null;
9
12
  };
10
13
  export declare function makeContent<TUser extends {
11
14
  id: DocId;