@delmaredigital/payload-experiments 0.2.0-rc.7 → 0.3.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (336) hide show
  1. package/README.md +202 -26
  2. package/dist/admin/PreviewLinks.d.ts +2 -1
  3. package/dist/admin/PreviewLinks.js +50 -21
  4. package/dist/admin/TestsNavLink.d.ts +3 -1
  5. package/dist/admin/TestsNavLink.js +2 -2
  6. package/dist/admin/actions/ConfirmDialog.d.ts +7 -4
  7. package/dist/admin/actions/ConfirmDialog.js +37 -82
  8. package/dist/admin/actions/DecideDialog.d.ts +3 -1
  9. package/dist/admin/actions/DecideDialog.js +30 -13
  10. package/dist/admin/actions/MakeContentResult.d.ts +13 -2
  11. package/dist/admin/actions/MakeContentResult.js +115 -16
  12. package/dist/admin/actions/actions.model.d.ts +2 -4
  13. package/dist/admin/actions/actions.model.js +2 -3
  14. package/dist/admin/allocation.model.js +3 -5
  15. package/dist/admin/api.d.ts +10 -4
  16. package/dist/admin/api.js +11 -7
  17. package/dist/admin/armSelect/ArmSelectField.js +14 -22
  18. package/dist/admin/arms/arms.model.d.ts +1 -21
  19. package/dist/admin/arms/arms.model.js +1 -153
  20. package/dist/admin/copy/actions.d.ts +39 -3
  21. package/dist/admin/copy/actions.js +40 -4
  22. package/dist/admin/copy/armSelect.js +5 -5
  23. package/dist/admin/copy/arms.d.ts +1 -6
  24. package/dist/admin/copy/arms.js +1 -16
  25. package/dist/admin/copy/audience.d.ts +2 -1
  26. package/dist/admin/copy/audience.js +3 -2
  27. package/dist/admin/copy/common.d.ts +13 -6
  28. package/dist/admin/copy/common.js +14 -8
  29. package/dist/admin/copy/goals.d.ts +21 -6
  30. package/dist/admin/copy/goals.js +27 -6
  31. package/dist/admin/copy/readiness.d.ts +22 -0
  32. package/dist/admin/copy/readiness.js +21 -0
  33. package/dist/admin/copy/recommendation.d.ts +0 -3
  34. package/dist/admin/copy/recommendation.js +0 -3
  35. package/dist/admin/copy/results.d.ts +43 -2
  36. package/dist/admin/copy/results.js +44 -3
  37. package/dist/admin/copy/review.d.ts +2 -2
  38. package/dist/admin/copy/review.js +2 -2
  39. package/dist/admin/copy/setting.d.ts +2 -0
  40. package/dist/admin/copy/setting.js +2 -0
  41. package/dist/admin/copy/surface.d.ts +7 -5
  42. package/dist/admin/copy/surface.js +9 -5
  43. package/dist/admin/copy/testPage.d.ts +10 -0
  44. package/dist/admin/copy/testPage.js +10 -0
  45. package/dist/admin/format/date.d.ts +2 -0
  46. package/dist/admin/format/date.js +10 -0
  47. package/dist/admin/paths.d.ts +2 -1
  48. package/dist/admin/paths.js +2 -1
  49. package/dist/admin/previewLinks.model.d.ts +4 -2
  50. package/dist/admin/previewLinks.model.js +8 -7
  51. package/dist/admin/results/CiChart.js +11 -21
  52. package/dist/admin/results/DataQualityPanel.d.ts +4 -2
  53. package/dist/admin/results/DataQualityPanel.js +30 -17
  54. package/dist/admin/results/DataTable.d.ts +16 -0
  55. package/dist/admin/results/DataTable.js +152 -0
  56. package/dist/admin/results/StatTile.d.ts +7 -0
  57. package/dist/admin/results/StatTile.js +22 -0
  58. package/dist/admin/results/VerdictHero.d.ts +13 -0
  59. package/dist/admin/results/{VerdictCard.js → VerdictHero.js} +15 -12
  60. package/dist/admin/results/legacy.d.ts +16 -0
  61. package/dist/admin/results/legacy.js +27 -0
  62. package/dist/admin/results/releasePages.model.d.ts +17 -0
  63. package/dist/admin/results/releasePages.model.js +10 -0
  64. package/dist/admin/results/results.css +64 -0
  65. package/dist/admin/results/results.model.d.ts +11 -7
  66. package/dist/admin/results/results.model.js +24 -7
  67. package/dist/admin/results/snapshot.d.ts +14 -0
  68. package/dist/admin/results/snapshot.js +3 -0
  69. package/dist/admin/surface/ResultsRoute.d.ts +1 -1
  70. package/dist/admin/surface/ResultsRoute.js +59 -34
  71. package/dist/admin/surface/SurfaceShell.d.ts +11 -2
  72. package/dist/admin/surface/SurfaceShell.js +49 -14
  73. package/dist/admin/surface/TestsHome.d.ts +6 -1
  74. package/dist/admin/surface/TestsHome.js +182 -114
  75. package/dist/admin/surface/TestsSurface.js +56 -16
  76. package/dist/admin/surface/goals/GoalForm.js +341 -308
  77. package/dist/admin/surface/goals/GoalsList.js +123 -89
  78. package/dist/admin/surface/goals/data.d.ts +8 -2
  79. package/dist/admin/surface/goals/data.js +24 -16
  80. package/dist/admin/surface/goals/goals.css +10 -13
  81. package/dist/admin/surface/home.model.d.ts +8 -8
  82. package/dist/admin/surface/home.model.js +23 -12
  83. package/dist/admin/surface/newTest/NewTest.js +9 -9
  84. package/dist/admin/surface/pageBlock/parts.js +31 -26
  85. package/dist/admin/surface/readiness/ActionDialogs.js +47 -19
  86. package/dist/admin/surface/readiness/Banner.d.ts +3 -0
  87. package/dist/admin/surface/readiness/Banner.js +58 -33
  88. package/dist/admin/surface/readiness/SaveStatus.d.ts +1 -0
  89. package/dist/admin/surface/readiness/SaveStatus.js +6 -3
  90. package/dist/admin/surface/readiness/readiness.model.d.ts +2 -0
  91. package/dist/admin/surface/readiness/readiness.model.js +25 -9
  92. package/dist/admin/surface/readiness/useTransition.d.ts +1 -0
  93. package/dist/admin/surface/readiness/useTransition.js +2 -1
  94. package/dist/admin/surface/results/ReleasePages.d.ts +14 -0
  95. package/dist/admin/surface/results/ReleasePages.js +116 -0
  96. package/dist/admin/surface/results/ResultsBody.d.ts +11 -1
  97. package/dist/admin/surface/results/ResultsBody.js +277 -105
  98. package/dist/admin/surface/results/ResultsHeader.d.ts +10 -0
  99. package/dist/admin/surface/results/ResultsHeader.js +177 -0
  100. package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
  101. package/dist/admin/surface/results/recommendation.model.js +12 -9
  102. package/dist/admin/surface/results/useResults.d.ts +21 -3
  103. package/dist/admin/surface/results/useResults.js +2 -1
  104. package/dist/admin/surface/setting/parts.js +75 -88
  105. package/dist/admin/surface/setting/setting.css +3 -15
  106. package/dist/admin/surface/surface.css +61 -47
  107. package/dist/admin/surface/test/AdvancedSection.js +55 -48
  108. package/dist/admin/surface/test/GoalSection.js +46 -47
  109. package/dist/admin/surface/test/LengthSection.js +46 -34
  110. package/dist/admin/surface/test/Rail.d.ts +4 -0
  111. package/dist/admin/surface/test/Rail.js +83 -0
  112. package/dist/admin/surface/test/ReadinessBar.d.ts +5 -0
  113. package/dist/admin/surface/test/ReadinessBar.js +33 -0
  114. package/dist/admin/surface/test/Section.d.ts +1 -1
  115. package/dist/admin/surface/test/Section.js +36 -16
  116. package/dist/admin/surface/test/TargetingSection.js +168 -142
  117. package/dist/admin/surface/test/TestPage.d.ts +2 -1
  118. package/dist/admin/surface/test/TestPage.js +146 -68
  119. package/dist/admin/surface/test/VersionsSection.js +151 -120
  120. package/dist/admin/surface/test/fields.d.ts +11 -4
  121. package/dist/admin/surface/test/fields.js +84 -85
  122. package/dist/admin/surface/test/jumpToSection.d.ts +3 -0
  123. package/dist/admin/surface/test/jumpToSection.js +10 -0
  124. package/dist/admin/surface/test/pageParts.js +9 -3
  125. package/dist/admin/surface/test/testPage.css +81 -14
  126. package/dist/admin/surface/test/testPage.model.d.ts +2 -3
  127. package/dist/admin/surface/test/testPage.model.js +1 -21
  128. package/dist/admin/surface/useTestDocument.js +12 -5
  129. package/dist/admin/targeting/targeting.model.d.ts +1 -3
  130. package/dist/admin/targeting/targeting.model.js +3 -8
  131. package/dist/admin/theme.css +115 -36
  132. package/dist/admin/theme.d.ts +38 -0
  133. package/dist/admin/theme.js +38 -1
  134. package/dist/admin/useApi.d.ts +17 -2
  135. package/dist/admin/useLayerUsage.js +14 -23
  136. package/dist/admin/views/ExperimentsRootView.js +12 -2
  137. package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
  138. package/dist/admin/views/TestsNavLinkServer.js +43 -0
  139. package/dist/arms/copy.d.ts +6 -0
  140. package/dist/arms/copy.js +16 -0
  141. package/dist/arms/model.d.ts +23 -0
  142. package/dist/arms/model.js +173 -0
  143. package/dist/collections/experiments/config.js +25 -0
  144. package/dist/collections/experiments/lockHook.js +3 -1
  145. package/dist/collections/identities.js +6 -1
  146. package/dist/collections/metrics.js +7 -0
  147. package/dist/collections/metricsGuards.d.ts +9 -0
  148. package/dist/collections/metricsGuards.js +33 -0
  149. package/dist/contentDone.d.ts +4 -0
  150. package/dist/contentDone.js +5 -0
  151. package/dist/db/appends.d.ts +6 -2
  152. package/dist/db/appends.js +10 -4
  153. package/dist/db/identities.d.ts +8 -7
  154. package/dist/db/identities.js +9 -6
  155. package/dist/db/locks.d.ts +6 -0
  156. package/dist/db/locks.js +13 -1
  157. package/dist/db/metricUsage.d.ts +19 -0
  158. package/dist/db/metricUsage.js +54 -0
  159. package/dist/db/names.d.ts +5 -0
  160. package/dist/db/names.js +5 -0
  161. package/dist/db/pageOccupancy.d.ts +12 -0
  162. package/dist/db/pageOccupancy.js +45 -0
  163. package/dist/db/publishedContent.d.ts +10 -0
  164. package/dist/db/publishedContent.js +43 -0
  165. package/dist/db/results.d.ts +2 -0
  166. package/dist/db/results.js +6 -2
  167. package/dist/db/snapshots.d.ts +18 -6
  168. package/dist/db/snapshots.js +25 -8
  169. package/dist/db/sweep.js +11 -6
  170. package/dist/endpoints/index.js +19 -1
  171. package/dist/endpoints/results.js +5 -0
  172. package/dist/endpoints/sanitizeReadinessDetail.d.ts +1 -0
  173. package/dist/endpoints/sanitizeReadinessDetail.js +2 -0
  174. package/dist/endpoints/summary.js +20 -2
  175. package/dist/endpoints/transitions.d.ts +15 -0
  176. package/dist/endpoints/transitions.js +36 -0
  177. package/dist/errors.d.ts +8 -0
  178. package/dist/errors.js +28 -0
  179. package/dist/exports/adminRsc.d.ts +1 -0
  180. package/dist/exports/adminRsc.js +1 -0
  181. package/dist/exports/index.d.ts +2 -1
  182. package/dist/exports/index.js +1 -0
  183. package/dist/exports/puck.d.ts +2 -1
  184. package/dist/exports/puck.js +1 -1
  185. package/dist/exports/puckEditor.d.ts +4 -1
  186. package/dist/exports/puckEditor.js +3 -1
  187. package/dist/exports/rsc.d.ts +6 -1
  188. package/dist/exports/rsc.js +2 -0
  189. package/dist/hooks/pageGuard.d.ts +5 -0
  190. package/dist/hooks/pageGuard.js +139 -0
  191. package/dist/lib/fetchAllDocs.d.ts +16 -0
  192. package/dist/lib/fetchAllDocs.js +56 -0
  193. package/dist/log.js +6 -3
  194. package/dist/manifest/compile.d.ts +5 -0
  195. package/dist/manifest/compile.js +5 -1
  196. package/dist/options.js +16 -0
  197. package/dist/plugin.js +30 -2
  198. package/dist/puck/ExperimentPicker.js +47 -16
  199. package/dist/puck/ExperimentSlot.d.ts +51 -1
  200. package/dist/puck/ExperimentSlot.js +23 -12
  201. package/dist/puck/copy.d.ts +6 -5
  202. package/dist/puck/copy.js +6 -5
  203. package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
  204. package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
  205. package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
  206. package/dist/puck/editor/ResultsReadout.d.ts +6 -0
  207. package/dist/puck/editor/ResultsReadout.js +99 -0
  208. package/dist/puck/editor/VersionBuilder.d.ts +34 -0
  209. package/dist/puck/editor/VersionBuilder.js +317 -0
  210. package/dist/puck/editor/armWrites.d.ts +7 -0
  211. package/dist/puck/editor/armWrites.js +71 -0
  212. package/dist/puck/editor/armsCache.d.ts +14 -0
  213. package/dist/puck/editor/armsCache.js +78 -0
  214. package/dist/puck/editor/copy.d.ts +33 -1
  215. package/dist/puck/editor/copy.js +45 -8
  216. package/dist/puck/editor/createTest.d.ts +1 -1
  217. package/dist/puck/editor/createTest.js +25 -14
  218. package/dist/puck/editor/editor.chrome.css +121 -0
  219. package/dist/puck/editor/editor.css +288 -204
  220. package/dist/puck/editor/editor.model.d.ts +4 -1
  221. package/dist/puck/editor/editor.model.js +10 -7
  222. package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
  223. package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
  224. package/dist/puck/experimentSlotField.d.ts +2 -0
  225. package/dist/puck/experimentSlotField.js +11 -3
  226. package/dist/puck/hostContext.d.ts +10 -0
  227. package/dist/puck/hostContext.js +12 -0
  228. package/dist/puck/routes.d.ts +12 -0
  229. package/dist/puck/routes.js +11 -0
  230. package/dist/puck/slotConfig.d.ts +1 -1
  231. package/dist/puck/slotConfig.js +2 -2
  232. package/dist/results/aggregate.js +4 -3
  233. package/dist/results/analyse.d.ts +4 -1
  234. package/dist/results/analyse.js +27 -11
  235. package/dist/results/cuped.js +3 -2
  236. package/dist/results/identityCredit.d.ts +15 -7
  237. package/dist/results/identityCredit.js +32 -33
  238. package/dist/results/rolloutUnits.js +2 -1
  239. package/dist/results/snapshot.js +8 -2
  240. package/dist/results/srm.js +2 -1
  241. package/dist/sanitizeReadinessDetail.d.ts +7 -0
  242. package/dist/sanitizeReadinessDetail.js +63 -0
  243. package/dist/server/linkIdentity.d.ts +10 -1
  244. package/dist/server/linkIdentity.js +17 -15
  245. package/dist/server/metadata.d.ts +5 -0
  246. package/dist/server/metadata.js +18 -3
  247. package/dist/server/normaliseContentRelationships.d.ts +7 -0
  248. package/dist/server/normaliseContentRelationships.js +62 -0
  249. package/dist/server/recordContentChange.d.ts +1 -1
  250. package/dist/server/recordContentChange.js +17 -8
  251. package/dist/server/runResults.d.ts +3 -2
  252. package/dist/server/runResults.js +157 -35
  253. package/dist/targeting/values.d.ts +4 -0
  254. package/dist/targeting/values.js +10 -0
  255. package/dist/transitions/addMetric.js +8 -0
  256. package/dist/transitions/archive.js +2 -3
  257. package/dist/transitions/checkStart.d.ts +10 -3
  258. package/dist/transitions/checkStart.js +25 -0
  259. package/dist/transitions/contentFields.d.ts +7 -0
  260. package/dist/transitions/contentFields.js +20 -0
  261. package/dist/transitions/decide.js +22 -2
  262. package/dist/transitions/dismissContentEntry.d.ts +8 -0
  263. package/dist/transitions/dismissContentEntry.js +45 -0
  264. package/dist/transitions/makeContent.d.ts +5 -2
  265. package/dist/transitions/makeContent.js +217 -118
  266. package/dist/transitions/phaseRecord.d.ts +8 -2
  267. package/dist/transitions/placements.d.ts +3 -6
  268. package/dist/transitions/placements.js +12 -1
  269. package/dist/transitions/preflightData.d.ts +9 -1
  270. package/dist/transitions/preflightData.js +138 -20
  271. package/dist/transitions/releasePages.d.ts +8 -0
  272. package/dist/transitions/releasePages.js +26 -0
  273. package/dist/transitions/restoreContentEntry.d.ts +8 -0
  274. package/dist/transitions/restoreContentEntry.js +47 -0
  275. package/dist/transitions/start.js +62 -7
  276. package/dist/types/contracts.d.ts +24 -4
  277. package/dist/types/endpoints.d.ts +34 -3
  278. package/dist/types/index.d.ts +1 -0
  279. package/dist/types/index.js +1 -0
  280. package/dist/types/reasons.d.ts +3 -0
  281. package/dist/types/reasons.js +1 -0
  282. package/dist/types/snapshot.d.ts +6 -1
  283. package/dist/ui/Banner.d.ts +2 -0
  284. package/dist/ui/Banner.js +2 -0
  285. package/dist/ui/Breadcrumb.d.ts +7 -0
  286. package/dist/ui/Breadcrumb.js +25 -0
  287. package/dist/ui/Button.d.ts +15 -0
  288. package/dist/ui/Button.js +32 -0
  289. package/dist/ui/Card.d.ts +12 -0
  290. package/dist/ui/Card.js +40 -0
  291. package/dist/ui/Checkbox.d.ts +2 -0
  292. package/dist/ui/Checkbox.js +2 -0
  293. package/dist/{admin/components → ui}/ChipsInput.d.ts +4 -3
  294. package/dist/{admin/components → ui}/ChipsInput.js +20 -12
  295. package/dist/ui/Dialog.d.ts +13 -0
  296. package/dist/ui/Dialog.js +85 -0
  297. package/dist/ui/EmptyState.d.ts +2 -0
  298. package/dist/ui/EmptyState.js +2 -0
  299. package/dist/ui/Feedback.d.ts +24 -0
  300. package/dist/ui/Feedback.js +40 -0
  301. package/dist/ui/Inputs.d.ts +34 -0
  302. package/dist/ui/Inputs.js +225 -0
  303. package/dist/ui/NumberInput.d.ts +2 -0
  304. package/dist/ui/NumberInput.js +2 -0
  305. package/dist/ui/Range.d.ts +2 -0
  306. package/dist/ui/Range.js +2 -0
  307. package/dist/ui/SearchInput.d.ts +2 -0
  308. package/dist/ui/SearchInput.js +2 -0
  309. package/dist/ui/SectionCard.d.ts +2 -0
  310. package/dist/ui/SectionCard.js +2 -0
  311. package/dist/ui/Select.d.ts +2 -0
  312. package/dist/ui/Select.js +2 -0
  313. package/dist/ui/StatusChip.d.ts +2 -0
  314. package/dist/ui/StatusChip.js +2 -0
  315. package/dist/ui/Tabs.d.ts +16 -0
  316. package/dist/ui/Tabs.js +61 -0
  317. package/dist/ui/TextInput.d.ts +2 -0
  318. package/dist/ui/TextInput.js +2 -0
  319. package/dist/ui/Textarea.d.ts +2 -0
  320. package/dist/ui/Textarea.js +2 -0
  321. package/dist/ui/Toggle.d.ts +2 -0
  322. package/dist/ui/Toggle.js +2 -0
  323. package/dist/ui/accent.d.ts +40 -0
  324. package/dist/ui/accent.js +139 -0
  325. package/dist/ui/index.d.ts +9 -0
  326. package/dist/ui/index.js +9 -0
  327. package/dist/ui/ui.css +92 -0
  328. package/package.json +5 -2
  329. package/dist/admin/components/chipsInput.css +0 -11
  330. package/dist/admin/results/ArmTable.d.ts +0 -8
  331. package/dist/admin/results/ArmTable.js +0 -113
  332. package/dist/admin/results/VerdictCard.d.ts +0 -9
  333. package/dist/admin/surface/goals/GoalsBreadcrumb.d.ts +0 -5
  334. package/dist/admin/surface/goals/GoalsBreadcrumb.js +0 -47
  335. package/dist/admin/surface/test/TestHeader.d.ts +0 -4
  336. package/dist/admin/surface/test/TestHeader.js +0 -70
@@ -1,7 +1,14 @@
1
+ import { Forbidden, NotFound } from 'payload';
2
+ import { safeError } from '../log.js';
1
3
  import { powerMetricWeeks } from '@delmaredigital/experiments-stats';
2
4
  import { checkStart, relationshipId } from './checkStart.js';
3
5
  import { flagPlacements, scanBlockPlacements } from './placements.js';
4
- const failureDetail = (cause)=>cause instanceof Error ? cause.message : 'Placement lookup failed.';
6
+ import { routedPageTests } from '../db/pageOccupancy.js';
7
+ import { pooledExecutor, txExecutor } from '../db/executor.js';
8
+ const failureDetail = (cause)=>{
9
+ const name = cause instanceof Error ? cause.name : undefined;
10
+ return cause instanceof Forbidden || name === 'Forbidden' ? 'forbidden' : cause instanceof NotFound || name === 'NotFound' ? 'not-found' : 'placements-failed';
11
+ };
5
12
  /** Read-only inputs shared with Start's pre-transaction checks. Start must re-read
6
13
  * the document and lock-sensitive facts inside its transition (CORE-10b).
7
14
  */ export async function loadStartInputs(req, options, experimentId) {
@@ -23,6 +30,30 @@ const failureDetail = (cause)=>cause instanceof Error ? cause.message : 'Placeme
23
30
  experiment
24
31
  ])).get(String(experiment.id)) ?? null;
25
32
  }
33
+ /** Occupancy from the arms the runtime routes on (frozen rounds), shared by preflight and
34
+ * Start's locked recheck. Raw SQL on the request's transaction when there is one. */ export async function loadPageTestsInUse(req) {
35
+ return routedPageTests(await req.transactionID != null ? txExecutor(req) : pooledExecutor(req.payload));
36
+ }
37
+ /** Page targets no registered collection reports as published. A lookup that throws counts as
38
+ * unpublished (logged). Start calls it again under its page-path locks on a detached request. */ export async function unpublishedPageTargets(req, options, experiment) {
39
+ const targets = await Promise.all(experiment.arms.filter((arm)=>arm.kind !== 'holdout' && arm.pageTarget?.trim()).map(async (arm)=>{
40
+ for (const collection of options.pages?.collections ?? []){
41
+ try {
42
+ const page = await collection.findByPath(arm.pageTarget, req);
43
+ if (page?.published === true) return null;
44
+ } catch (cause) {
45
+ req.payload.logger.error({
46
+ err: safeError(cause),
47
+ experimentId: experiment.id,
48
+ collection: collection.slug,
49
+ id: arm.pageTarget
50
+ }, 'Experiment page lookup failed');
51
+ }
52
+ }
53
+ return arm.pageTarget;
54
+ }));
55
+ return targets.filter((target)=>target !== null);
56
+ }
26
57
  const metricIds = (experiment)=>[
27
58
  experiment.metrics?.primary,
28
59
  ...experiment.metrics?.secondary ?? [],
@@ -53,6 +84,7 @@ const metricIds = (experiment)=>[
53
84
  })();
54
85
  const blockSlugs = new Set(experiments.filter((e)=>e.kind === 'block').map((e)=>e.slug));
55
86
  // Lazy promises prevent unhandled rejections and share failed reads as well as successful ones.
87
+ let pageTestsInUse;
56
88
  let blocks;
57
89
  let state;
58
90
  const busyPoints = new Map();
@@ -68,6 +100,22 @@ const metricIds = (experiment)=>[
68
100
  flagPoints: options.flagPoints,
69
101
  attributeKeys: options.attributeKeys
70
102
  };
103
+ const placementFailure = (cause)=>{
104
+ const collection = experiment.flagPoint ?? 'xp-experiments';
105
+ req.payload.logger.error({
106
+ err: safeError(cause),
107
+ experimentId: experiment.id,
108
+ collection,
109
+ id: experiment.id
110
+ }, 'Experiment placement lookup failed');
111
+ return failureDetail(cause);
112
+ };
113
+ if (experiment.kind === 'page') {
114
+ pageTestsInUse ??= loadPageTestsInUse(req);
115
+ input.pageTestsInUse = await pageTestsInUse;
116
+ input.pageTargetsUnchecked = options.pages === undefined;
117
+ if (options.pages) input.unpublishedPageTargets = await unpublishedPageTargets(req, options, experiment);
118
+ }
71
119
  if (experiment.kind === 'block') {
72
120
  try {
73
121
  blocks ??= scanBlockPlacements(req, {
@@ -76,7 +124,7 @@ const metricIds = (experiment)=>[
76
124
  });
77
125
  input.placements = (await blocks).get(experiment.slug) ?? [];
78
126
  } catch (cause) {
79
- input.placementError = failureDetail(cause);
127
+ input.placementError = placementFailure(cause);
80
128
  }
81
129
  }
82
130
  if (experiment.kind === 'flag') {
@@ -108,9 +156,9 @@ const metricIds = (experiment)=>[
108
156
  try {
109
157
  const placements = await flagPlacements(options, experiment, req);
110
158
  if (Array.isArray(placements) && placements.every((p)=>p && typeof p === 'object' && typeof p.arm === 'string')) input.flagPlacements = placements;
111
- else input.flagPlacementError = 'Placement lookup returned an invalid result.';
159
+ else input.flagPlacementError = placementFailure(new Error('Invalid placement result'));
112
160
  } catch (cause) {
113
- input.flagPlacementError = failureDetail(cause);
161
+ input.flagPlacementError = placementFailure(cause);
114
162
  }
115
163
  }
116
164
  if (experiment.flagPoint) {
@@ -210,16 +258,23 @@ const metricIds = (experiment)=>[
210
258
  }
211
259
  return inputs;
212
260
  }
213
- function placementUrls(options, placement) {
261
+ // Bound each optional host preview lookup so it cannot indefinitely hold preflight open.
262
+ const PREVIEW_URL_TIMEOUT_MS = 2000;
263
+ // Resolve host preview lookups in a bounded pool instead of launching one per slot.
264
+ const PREVIEW_URL_CONCURRENCY = 8;
265
+ async function placementUrls(options, placement, pendingPreviews, onFailure, previews) {
214
266
  const result = {};
215
267
  const { collection, id, published } = placement;
216
- try {
217
- const url = options.admin?.previewUrl?.({
218
- collection,
219
- id
220
- });
221
- if (typeof url === 'string') result.url = url;
222
- } catch {}
268
+ // One host lookup per page version: several slots on a page share it.
269
+ const key = JSON.stringify([
270
+ collection,
271
+ String(id),
272
+ published
273
+ ]);
274
+ let preview = previews.get(key);
275
+ if (!preview) previews.set(key, preview = previewLookup(options, placement, pendingPreviews, onFailure));
276
+ const url = await preview;
277
+ if (url !== undefined) result.url = url;
223
278
  try {
224
279
  const url = options.admin?.editorUrl?.({
225
280
  collection,
@@ -230,6 +285,43 @@ function placementUrls(options, placement) {
230
285
  } catch {}
231
286
  return result;
232
287
  }
288
+ async function previewLookup(options, placement, pendingPreviews, onFailure) {
289
+ const { collection, id, title, published, slug } = placement;
290
+ const previewUrl = options.admin?.previewUrl;
291
+ let found;
292
+ if (previewUrl) {
293
+ let timer;
294
+ try {
295
+ if (pendingPreviews.count >= PREVIEW_URL_CONCURRENCY) throw new Error('Preview URL lookups at capacity');
296
+ pendingPreviews.count++;
297
+ // A timeout cannot cancel host work: retain its permit until that work settles.
298
+ const lookup = Promise.resolve().then(()=>previewUrl({
299
+ collection,
300
+ id,
301
+ title,
302
+ published,
303
+ ...slug !== undefined ? {
304
+ slug
305
+ } : {}
306
+ })).finally(()=>{
307
+ pendingPreviews.count--;
308
+ });
309
+ const url = await Promise.race([
310
+ lookup,
311
+ new Promise((_, reject)=>{
312
+ timer = setTimeout(()=>reject(new Error('Preview URL lookup timed out')), PREVIEW_URL_TIMEOUT_MS);
313
+ })
314
+ ]);
315
+ if (typeof url === 'string') found = url;
316
+ else if (url !== null && url !== undefined) throw new TypeError('Invalid preview URL result');
317
+ } catch (cause) {
318
+ onFailure(cause, placement);
319
+ } finally{
320
+ clearTimeout(timer);
321
+ }
322
+ }
323
+ return found;
324
+ }
233
325
  async function primaryHistory(source, now) {
234
326
  if (!source) return null;
235
327
  const from = new Date(now.getTime() - 28 * 86400000);
@@ -340,15 +432,41 @@ export async function preflightData(req, { experimentId, options }, { now = new
340
432
  }
341
433
  }
342
434
  const requiredSlots = e.arms.filter((arm)=>arm.kind !== 'holdout').map((arm)=>arm.slot);
343
- const placements = (input.placements ?? []).map((placement)=>({
435
+ const blocks = input.placements ?? [];
436
+ const placements = new Array(blocks.length);
437
+ let nextPlacement = 0;
438
+ const pendingPreviews = {
439
+ count: 0
440
+ };
441
+ const previews = new Map();
442
+ let loggedPreviewFailure = false;
443
+ const onPreviewFailure = (cause, placement)=>{
444
+ if (loggedPreviewFailure) return;
445
+ loggedPreviewFailure = true;
446
+ req.payload.logger.warn({
447
+ err: safeError(cause),
448
+ experimentId: e.id,
344
449
  collection: placement.collection,
345
- id: placement.id,
346
- title: placement.title,
347
- slots: placement.slots,
348
- ...placementUrls(options, placement),
349
- ok: requiredSlots.every((slot)=>slot && placement.slots[slot] > 0),
350
- detail: placement.published ? 'Published' : 'Draft'
351
- }));
450
+ id: placement.id
451
+ }, 'Experiment preview URL lookup failed');
452
+ };
453
+ await Promise.all(Array.from({
454
+ length: Math.min(PREVIEW_URL_CONCURRENCY, blocks.length)
455
+ }, async ()=>{
456
+ while(nextPlacement < blocks.length){
457
+ const index = nextPlacement++;
458
+ const placement = blocks[index];
459
+ placements[index] = {
460
+ collection: placement.collection,
461
+ id: placement.id,
462
+ title: placement.title,
463
+ slots: placement.slots,
464
+ ...await placementUrls(options, placement, pendingPreviews, onPreviewFailure, previews),
465
+ ok: requiredSlots.every((slot)=>slot && placement.slots[slot] > 0),
466
+ detail: placement.published ? 'Published' : 'Draft'
467
+ };
468
+ }
469
+ }));
352
470
  for (const placement of input.flagPlacements ?? [])placements.push({
353
471
  collection: e.flagPoint ?? '',
354
472
  id: placement.arm,
@@ -0,0 +1,8 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import type { DocId } from '../types/contracts.js';
3
+ export declare function releasePages(req: PayloadRequest, { experimentId }: {
4
+ experimentId: DocId;
5
+ }): Promise<{
6
+ doc: import("payload").JsonObject & import("payload").TypeWithID;
7
+ revision: number;
8
+ }>;
@@ -0,0 +1,26 @@
1
+ import { relationshipId } from './checkStart.js';
2
+ import { withTransition } from './kernel.js';
3
+ import { readExperiment, refuse, updateExperiment } from './start.js';
4
+ export async function releasePages(req, { experimentId }) {
5
+ const before = await readExperiment(req, experimentId);
6
+ const { result: doc, revision } = await withTransition(req, {
7
+ experimentId,
8
+ layerId: relationshipId(before.layer)
9
+ }, async ({ req })=>{
10
+ const current = await readExperiment(req, experimentId);
11
+ const decision = current.decision;
12
+ if (current.kind !== 'page' || current.status !== 'archived' || !decision?.winnerArm || decision.discardedAt || decision.pagesReleasedAt) refuse('invalid-status');
13
+ const at = new Date().toISOString();
14
+ return updateExperiment(req, experimentId, {
15
+ decision: {
16
+ ...decision,
17
+ pagesReleasedAt: at,
18
+ pagesReleasedBy: req.user ? String(req.user.id) : null
19
+ }
20
+ });
21
+ });
22
+ return {
23
+ doc,
24
+ revision: revision
25
+ };
26
+ }
@@ -0,0 +1,8 @@
1
+ import type { PayloadRequest } from 'payload';
2
+ import type { DocId } from '../types/contracts.js';
3
+ import type { RestoreContentEntryBody } from '../types/endpoints.js';
4
+ export declare function restoreContentEntry(req: PayloadRequest, { experimentId, collection, id }: RestoreContentEntryBody & {
5
+ experimentId: DocId;
6
+ }): Promise<{
7
+ doc: import("payload").JsonObject & import("payload").TypeWithID;
8
+ }>;
@@ -0,0 +1,47 @@
1
+ import { relationshipId } from './checkStart.js';
2
+ import { withTransition } from './kernel.js';
3
+ import { readExperiment, refuse, updateExperiment } from './start.js';
4
+ export async function restoreContentEntry(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.dismissedAt) refuse('entry-not-dismissed');
21
+ const docs = report.docs.map((entry, i)=>{
22
+ if (i !== index) return entry;
23
+ const { dismissedAt: _at, dismissedBy: _by, ...restored } = entry;
24
+ return {
25
+ ...restored,
26
+ ok: false
27
+ };
28
+ });
29
+ return updateExperiment(req, experimentId, {
30
+ decision: {
31
+ ...decision,
32
+ contentReport: {
33
+ ...report,
34
+ docs
35
+ }
36
+ }
37
+ });
38
+ });
39
+ req.payload.logger.info({
40
+ experimentId,
41
+ collection,
42
+ id
43
+ }, 'Experiment content entry restored');
44
+ return {
45
+ doc
46
+ };
47
+ }
@@ -1,11 +1,15 @@
1
+ import { lockPagePaths } from '../db/locks.js';
2
+ import { lockMetricsForFreeze } from '../db/metricUsage.js';
3
+ import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
1
4
  import { claimSlices } from '../db/slices.js';
5
+ import { pageInUseCopy, unpublishedPageCopy } from '../admin/copy/readiness.js';
2
6
  import { XpRefusal } from '../errors.js';
3
7
  import { isOpValid } from '../targeting/ops.js';
4
8
  import { newSeed } from '../runtime/seed.js';
5
- import { checkStart, relationshipId } from './checkStart.js';
9
+ import { checkStart, pageTargetPaths, relationshipId } from './checkStart.js';
6
10
  import { withTransition } from './kernel.js';
7
11
  import { writePhaseRecord } from './phaseRecord.js';
8
- import { loadStartInputs } from './preflightData.js';
12
+ import { loadPageTestsInUse, loadStartInputs, unpublishedPageTargets } from './preflightData.js';
9
13
  import { resolveCaps } from './resolveCaps.js';
10
14
  export async function readExperiment(req, id) {
11
15
  // §7.8: internal lookup serves the authorised transition write. Preserve
@@ -27,21 +31,33 @@ export async function readExperiment(req, id) {
27
31
  }
28
32
  export function refuse(code, detail) {
29
33
  const messages = {
34
+ 'page-in-use': pageInUseCopy(detail?.paths?.[0] ?? 'this page'),
35
+ 'page-target-unpublished': unpublishedPageCopy(typeof detail === 'string' ? detail : 'This page'),
30
36
  'invalid-status': 'This action is not available in the current status.',
31
37
  'stale-draft': 'The experiment changed. Review it again before starting.',
32
38
  'flag-point-busy': 'Another experiment is running or decided on this flag point.',
33
39
  'layer-full': 'There is not enough room in this layer.',
34
40
  'allocation-lower': 'Allocation can only increase while running. Stop and start a new round to lower it.',
35
- 'invalid-body': 'Check the experiment settings and try again.'
41
+ 'invalid-body': 'Check the experiment settings and try again.',
42
+ 'not-decided': 'This test has no standing winner.',
43
+ 'entry-not-found': 'That page is not in the report.',
44
+ 'entry-ok': 'That page was already updated.',
45
+ 'entry-dismissed': 'That page was already dismissed.',
46
+ 'entry-not-dismissed': 'That page is not dismissed.'
36
47
  };
37
48
  const conflict = [
38
49
  'invalid-status',
39
50
  'stale-draft',
51
+ 'page-in-use',
40
52
  'flag-point-busy',
41
53
  'layer-full',
42
- 'allocation-lower'
54
+ 'allocation-lower',
55
+ 'not-decided',
56
+ 'entry-ok',
57
+ 'entry-dismissed',
58
+ 'entry-not-dismissed'
43
59
  ].includes(code);
44
- throw new XpRefusal(code, messages[code] ?? 'The experiment is not ready to start.', detail, conflict ? 409 : 422);
60
+ throw new XpRefusal(code, messages[code] ?? 'The experiment is not ready to start.', detail, code === 'entry-not-found' ? 404 : conflict ? 409 : 422);
45
61
  }
46
62
  export async function updateExperiment(req, id, data) {
47
63
  // §7.8: access-authorised transition writes locked plugin fields.
@@ -78,8 +94,10 @@ export async function start(req, { experimentId, options }) {
78
94
  kind: full.kind,
79
95
  analysis: full.analysis
80
96
  };
97
+ // Warning-only codes always have ok: true. These exclusions narrow
98
+ // ReadinessCode to RefusalCode, which intentionally excludes warnings.
81
99
  const failed = checkStart(input).find((check)=>!check.ok);
82
- if (failed) refuse(failed.code, failed.detail);
100
+ if (failed && failed.code !== 'page-targets-unchecked' && failed.code !== 'make-content-unsupported') refuse(failed.code, failed.detail);
83
101
  const before = input.experiment;
84
102
  const updatedAt = before.updatedAt;
85
103
  const at = new Date();
@@ -92,6 +110,33 @@ export async function start(req, { experimentId, options }) {
92
110
  const current = await readExperiment(req, experimentId);
93
111
  if (current.updatedAt !== updatedAt) refuse('stale-draft');
94
112
  if (current.status !== 'draft' && current.status !== 'stopped') refuse('invalid-status');
113
+ if (current.kind === 'page') {
114
+ // Independent experiment/layer locks do not serialize overlapping pages.
115
+ // Re-read occupancy after acquiring sorted path locks; hold through commit.
116
+ // A waiting Start then sees the first Start's live test on this transaction.
117
+ await lockPagePaths(exec, pageTargetPaths(current.arms));
118
+ const recheck = checkStart({
119
+ ...input,
120
+ experiment: current,
121
+ pageTestsInUse: await loadPageTestsInUse(req)
122
+ }).find((check)=>check.code === 'page-in-use' && !check.ok);
123
+ if (recheck) refuse('page-in-use', recheck.detail);
124
+ if (options.pages) {
125
+ // The page guard takes the same path locks, so a delete, unpublish or rename that committed
126
+ // while Start waited is visible now. Read it on a detached request (committed state, outside
127
+ // this transaction): a refused host read must not kill the transition (CORE-21).
128
+ // (the make-content pattern: drop the transaction id for the reads, restore it after).
129
+ const transactionID = req.transactionID;
130
+ delete req.transactionID;
131
+ let unpublished;
132
+ try {
133
+ unpublished = await unpublishedPageTargets(req, options, current);
134
+ } finally{
135
+ req.transactionID = transactionID;
136
+ }
137
+ if (unpublished.length) refuse('page-target-unpublished', unpublished[0]);
138
+ }
139
+ }
95
140
  if (current.kind === 'flag' && current.flagPoint) {
96
141
  const busy = await req.payload.find({
97
142
  collection: 'xp-experiments',
@@ -134,8 +179,17 @@ export async function start(req, { experimentId, options }) {
134
179
  ...input,
135
180
  experiment: current
136
181
  }).find((check)=>!check.ok);
137
- if (recheck) refuse(recheck.code, recheck.detail);
182
+ if (recheck && recheck.code !== 'page-targets-unchecked' && recheck.code !== 'make-content-unsupported') refuse(recheck.code, recheck.detail);
183
+ }
184
+ const lockedMetrics = await lockMetricsForFreeze(exec, caps.definitions.map((metric)=>metric.id));
185
+ if (!lockedMetrics) {
186
+ throw new XpRefusal('stale-draft', 'A goal in this test was deleted. Review the goals before starting.', undefined, 409);
138
187
  }
188
+ // Reachable: a slug/source change takes FOR NO KEY UPDATE, which a draft's FK check does not wait on.
189
+ if (caps.definitions.some((metric)=>{
190
+ const locked = lockedMetrics.find((row)=>String(row.id) === String(metric.id));
191
+ return locked.slug !== metric.slug || locked.source !== metric.source;
192
+ })) throw new XpRefusal('stale-draft', 'A goal changed. Review it again before starting.', undefined, 409);
139
193
  const targeting = (current.targeting ?? []).map((rule)=>{
140
194
  const type = Object.hasOwn(options.attributeKeys ?? {}, rule.attribute) ? options.attributeKeys[rule.attribute].type : undefined;
141
195
  const validValue = type === 'string' && [
@@ -183,6 +237,7 @@ export async function start(req, { experimentId, options }) {
183
237
  ...current.analysis,
184
238
  method: current.analysis?.method ?? 'sequential',
185
239
  alpha: 0.05,
240
+ sequentialTuningParameter: current.analysis?.sequentialTuningParameter ?? DEFAULT_SEQUENTIAL_TUNING_PARAMETER,
186
241
  cupedLookbackDays: 14,
187
242
  layerSlices,
188
243
  minSampleSizes: Object.fromEntries(definitions.map((metric)=>[
@@ -3,6 +3,14 @@ import type { ExperimentsServerRuntime } from './runtime.js';
3
3
  import type { ArmSlot } from './puck.js';
4
4
  import type { TargetingOp } from '../targeting/ops.js';
5
5
  export type DocId = string | number;
6
+ export interface PreviewPlacement {
7
+ collection: string;
8
+ id: DocId;
9
+ title: string;
10
+ published: boolean;
11
+ /** The published main row's string slug, including on draft placements; absent for unpublished documents, never a draft/version row's slug. */
12
+ slug?: string;
13
+ }
6
14
  export type Attributes = Record<string, string | number | boolean>;
7
15
  export type ExposurePolicy = 'render' | 'viewport' | 'pageview' | 'manual';
8
16
  export type ExperimentKind = 'block' | 'page' | 'flag' | 'code';
@@ -93,6 +101,15 @@ export type RateLimiter = (args: {
93
101
  export type ExperimentsAccess = (args: {
94
102
  req: PayloadRequest;
95
103
  }) => boolean | Promise<boolean>;
104
+ export interface ExperimentsPageCollection {
105
+ slug: string;
106
+ pathOf: (doc: Record<string, unknown>) => string | null;
107
+ /** Lookup with the supplied req and overrideAccess: false. Under an anonymous req (results checks) only published pages are visible, so a hit is { published: true } or null. Under a req that can read drafts (Start), a draft-only page may be reported as { published: false }. */
108
+ findByPath: (path: string, req: PayloadRequest) => Promise<{
109
+ id: string | number;
110
+ published: boolean;
111
+ } | null>;
112
+ }
96
113
  export interface ExperimentsPluginOptions<TUser extends {
97
114
  id: string | number;
98
115
  } = {
@@ -105,16 +122,16 @@ export interface ExperimentsPluginOptions<TUser extends {
105
122
  readResults?: ExperimentsAccess;
106
123
  readIdentities?: ExperimentsAccess;
107
124
  };
125
+ theme?: {
126
+ accent?: string;
127
+ };
108
128
  admin?: {
109
129
  editorUrl?: (args: {
110
130
  collection: string;
111
131
  id: DocId;
112
132
  draft?: boolean;
113
133
  }) => string;
114
- previewUrl?: (args: {
115
- collection: string;
116
- id: DocId;
117
- }) => string;
134
+ previewUrl?: (placement: PreviewPlacement) => string | null | undefined | Promise<string | null | undefined>;
118
135
  descriptions?: {
119
136
  block?: string;
120
137
  document?: string;
@@ -131,6 +148,9 @@ export interface ExperimentsPluginOptions<TUser extends {
131
148
  type: 'string' | 'number' | 'boolean';
132
149
  values?: string[];
133
150
  }>;
151
+ pages?: {
152
+ collections: ExperimentsPageCollection[];
153
+ };
134
154
  puck?: {
135
155
  enabled: boolean;
136
156
  collections: Array<string | {
@@ -1,10 +1,13 @@
1
- import type { ArmKind, DocId, ExposurePolicy, MetricSource, ResolvedOptions } from './contracts.js';
1
+ import type { ArmKind, DocId, ExperimentKind, ExposurePolicy, MetricSource, ResolvedOptions, Status } from './contracts.js';
2
+ import type { ContentReportReason } from './reasons.js';
2
3
  import type { ArmSlot } from './puck.js';
3
4
  import type { ReadinessCode } from '../transitions/checkStart.js';
4
5
  export type { ReadinessCode } from '../transitions/checkStart.js';
5
6
  import type { Snapshot, VerdictCode } from './snapshot.js';
6
7
  export type ArmIssueCode = 'weights-total' | 'control-count' | 'control-first' | 'holdout-last' | 'holdout-count' | 'variant-count' | 'duplicate-key' | 'duplicate-slot' | 'slot-required' | 'invalid-key';
7
- export type RefusalCode = ArmIssueCode | 'not-found' | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-no-targets' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'stale-edit' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'analysis-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'duplicate-metric';
8
+ /** Content-report dismiss/restore refusals (CORE-44). */
9
+ export type ContentEntryRefusal = 'not-decided' | 'entry-not-found' | 'entry-ok' | 'entry-dismissed' | 'entry-not-dismissed';
10
+ export type RefusalCode = ArmIssueCode | 'not-found' | ContentEntryRefusal | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-in-use' | 'page-no-targets' | 'page-target-unpublished' | 'page-user-unit' | 'version-page-unavailable' | 'version-page-unchecked' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'stale-edit' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'analysis-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'block-path-rule' | 'duplicate-metric';
8
11
  export type ErrorEnvelope = {
9
12
  errors: Array<{
10
13
  code: RefusalCode;
@@ -32,12 +35,25 @@ export type ArchiveBody = {
32
35
  mode: 'stopped' | 'after-content' | 'discard' | 'hard-coded';
33
36
  confirmation?: string;
34
37
  };
38
+ export type DismissContentEntryBody = {
39
+ collection: string;
40
+ id: DocId;
41
+ };
42
+ export type DismissContentEntryResult = Pick<TransitionSuccess, 'doc'>;
43
+ export type RestoreContentEntryBody = DismissContentEntryBody;
44
+ export type RestoreContentEntryResult = Pick<TransitionSuccess, 'doc'>;
45
+ export type ContentReport = {
46
+ docs: MakeContentResult['docs'];
47
+ } | MakeContentResult['docs'];
35
48
  export type MakeContentResult = TransitionSuccess & {
36
49
  docs: Array<{
37
50
  collection: string;
38
51
  id: DocId;
39
52
  ok: boolean;
40
- error?: string;
53
+ detail?: ContentReportReason | string;
54
+ error?: ContentReportReason | string;
55
+ dismissedAt?: string;
56
+ dismissedBy?: string | null;
41
57
  }>;
42
58
  };
43
59
  export type PreflightResult = {
@@ -45,6 +61,7 @@ export type PreflightResult = {
45
61
  code: ReadinessCode;
46
62
  ok: boolean;
47
63
  detail?: unknown;
64
+ warning?: boolean;
48
65
  }>;
49
66
  placements: Array<{
50
67
  collection: string;
@@ -93,6 +110,12 @@ export interface PhaseSummary {
93
110
  }>;
94
111
  }
95
112
  export type ResultsResponse = {
113
+ /** Filled by the results endpoint from CORE-48: what a results reader may see about the test. */
114
+ experiment?: {
115
+ name: string;
116
+ kind: ExperimentKind;
117
+ status: Status;
118
+ };
96
119
  phases: PhaseSummary[];
97
120
  snapshot: Snapshot | null;
98
121
  decisionSnapshot?: Snapshot;
@@ -110,6 +133,14 @@ export type ResultsResponse = {
110
133
  };
111
134
  export type SummaryResponse = Record<DocId, {
112
135
  ready?: boolean;
136
+ next?: {
137
+ code: ReadinessCode;
138
+ detail?: unknown;
139
+ };
140
+ health?: Array<{
141
+ code: 'version-page-unavailable' | 'version-page-unchecked';
142
+ path: string;
143
+ }>;
113
144
  exposures: number;
114
145
  days: number;
115
146
  verdict: {
@@ -6,3 +6,4 @@ export * from './puck.js';
6
6
  export * from './admin.js';
7
7
  export * from './endpoints.js';
8
8
  export * from './snapshot.js';
9
+ export * from './reasons.js';
@@ -6,3 +6,4 @@ export * from './puck.js';
6
6
  export * from './admin.js';
7
7
  export * from './endpoints.js';
8
8
  export * from './snapshot.js';
9
+ export * from './reasons.js';
@@ -0,0 +1,3 @@
1
+ /** Closed codes written by new results runs; stored snapshots may predate them. */
2
+ export type MetricUnavailableReason = 'source-failed' | 'source-unavailable' | 'unit-type-unavailable' | 'non-finite-values' | 'non-finite-covariates' | 'budget' | 'not-supplied' | 'analysis-failed';
3
+ export type ContentReportReason = 'localized' | 'locked' | 'forbidden' | 'not-found' | 'validation' | 'update-failed';
@@ -0,0 +1 @@
1
+ /** Closed codes written by new results runs; stored snapshots may predate them. */ export { };
@@ -1,5 +1,6 @@
1
1
  import type { GaussianPrior } from '@delmaredigital/experiments-stats';
2
2
  import type { DocId, MetricSource } from './contracts.js';
3
+ import type { MetricUnavailableReason } from './reasons.js';
3
4
  export type VerdictCode = 'unavailable' | 'data-problem' | 'guardrail-failing' | 'collecting' | 'original-better' | 'worse-none-better' | 'winner' | 'mixed' | 'keep-running' | 'no-difference' | 'rollout-collecting' | 'rollout-readout';
4
5
  export interface Snapshot {
5
6
  experiment: DocId;
@@ -54,7 +55,7 @@ export interface Snapshot {
54
55
  kind: MetricSource['kind'];
55
56
  role: 'primary' | 'secondary' | 'guardrail';
56
57
  cupedApplied: boolean;
57
- unavailable?: string;
58
+ unavailable?: MetricUnavailableReason | string;
58
59
  perArm: Array<{
59
60
  arm: string;
60
61
  n: number;
@@ -107,6 +108,10 @@ export interface Snapshot {
107
108
  guardrailLine?: string;
108
109
  worstArm?: string;
109
110
  };
111
+ health?: Array<{
112
+ code: 'version-page-unavailable' | 'version-page-unchecked';
113
+ path: string;
114
+ }>;
110
115
  contentChanges: Array<{
111
116
  at: string;
112
117
  doc: {
@@ -0,0 +1,2 @@
1
+ import './ui.css';
2
+ export { Banner } from './Feedback.js';
@@ -0,0 +1,2 @@
1
+ import './ui.css';
2
+ export { Banner } from './Feedback.js';
@@ -0,0 +1,7 @@
1
+ import './ui.css';
2
+ export declare function Breadcrumb({ items }: {
3
+ items: Array<{
4
+ label: string;
5
+ href?: string;
6
+ }>;
7
+ }): import("react").JSX.Element;