@pithy-sh/testers 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/docs/store-apis.md +107 -0
  4. package/package.json +62 -0
  5. package/pithy.manifest.json +52 -0
  6. package/src/activity/resolve.ts +273 -0
  7. package/src/audit/actions.ts +56 -0
  8. package/src/capability.ts +128 -0
  9. package/src/clock/days.ts +70 -0
  10. package/src/clock/replay.ts +190 -0
  11. package/src/cloudflare-test.d.ts +13 -0
  12. package/src/config/config.ts +518 -0
  13. package/src/crypto/token.ts +60 -0
  14. package/src/data/cohort.ts +83 -0
  15. package/src/data/enums.ts +134 -0
  16. package/src/data/event.ts +81 -0
  17. package/src/data/member.ts +79 -0
  18. package/src/data/snapshot.ts +280 -0
  19. package/src/data/tables.ts +49 -0
  20. package/src/error/errors.ts +229 -0
  21. package/src/health/score.ts +225 -0
  22. package/src/http/guards.ts +37 -0
  23. package/src/http/pages.ts +66 -0
  24. package/src/http/responses.ts +634 -0
  25. package/src/http/routes.ts +933 -0
  26. package/src/http/schemas.ts +210 -0
  27. package/src/http/scopes.ts +79 -0
  28. package/src/http/view.ts +304 -0
  29. package/src/index.ts +80 -0
  30. package/src/migrations/0001_cohorts.ts +202 -0
  31. package/src/nudge/cooldown.ts +104 -0
  32. package/src/nudge/copy.ts +179 -0
  33. package/src/nudge/enqueueSeam.ts +95 -0
  34. package/src/nudge/send.ts +89 -0
  35. package/src/projection/build.ts +285 -0
  36. package/src/projection/forecast.ts +348 -0
  37. package/src/projection/inputs.ts +63 -0
  38. package/src/projection/poissonBinomial.ts +91 -0
  39. package/src/projection/trend.ts +185 -0
  40. package/src/provision/provisionTesters.ts +109 -0
  41. package/src/provision/resolveTestersConfig.ts +155 -0
  42. package/src/roster/read.ts +227 -0
  43. package/src/roster/write.ts +511 -0
  44. package/src/seeds/example.ts +219 -0
  45. package/src/version.generated.ts +16 -0
  46. package/src/workflows/daily.ts +513 -0
  47. package/src/workflows/pass.ts +100 -0
  48. package/src/workflows/report.ts +52 -0
  49. package/src/workflows/retryPolicy.ts +48 -0
  50. package/src/workflows/specs.ts +73 -0
  51. package/src/workflows/worker.ts +132 -0
  52. package/src/workflows/wrangler.jsonc +66 -0
@@ -0,0 +1,634 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import {
6
+ ActivityState,
7
+ MemberState,
8
+ Observability,
9
+ ProjectionBasis,
10
+ ProjectionConfidence,
11
+ RiskBand,
12
+ TrendDirection,
13
+ } from "../data/enums";
14
+
15
+ /**
16
+ * What a dashboard receives — and the structure that keeps it honest.
17
+ *
18
+ * **The honesty guardrails here are structural rather than advisory, and that is the whole point.** It
19
+ * is not enough to write "this is an estimate" in the docs, because the person who reads the docs and
20
+ * the person who writes `cohort.heldDays >= 14 && applyForProduction()` are the same person on
21
+ * different days. So:
22
+ *
23
+ * - Every figure derived from opt-in records is named `estimated*`. `estimatedHeldDays: 9` reads as a
24
+ * claim about our own records; `optInStreak: 9` reads as a fact about Google, and the difference is
25
+ * a refund.
26
+ * - The estimated half and the observed half are **separate objects with literal `source`
27
+ * discriminators**, so a developer destructuring `cohort.estimatedClock` cannot reach the number
28
+ * without typing the word `estimated`.
29
+ * - `disclaimer` is **required and non-nullable**, on the cohort and on the envelope, so the common
30
+ * "spread the object into a card component" path still carries it.
31
+ * - `successProbability` is **nullable**, so "we do not know" is representable and never has to be
32
+ * faked as a plausible 0.5.
33
+ * - `reconciliation` is present, `supported: false`, with the reason — because "we checked and cannot"
34
+ * and "we never thought about it" are different facts, and an absent field says the second.
35
+ *
36
+ * **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
37
+ * version boundary — a management client validates a response with this schema against a customer's
38
+ * Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
39
+ * below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
40
+ * say*, which is a different fact from `null`.
41
+ */
42
+
43
+ /** The sentence, verbatim, that must appear wherever an opt-in figure appears. */
44
+ export const ESTIMATE_STATEMENT =
45
+ "Pithy's count is an estimate from your own invite records. Google's count is authoritative and no API exposes it.";
46
+
47
+ /** The named ways Pithy's count can drift from Google's. */
48
+ export const DivergenceRisk = z
49
+ .enum(["silent_opt_out", "opt_in_outside_pithy", "uninstall", "day_boundary_mismatch", "reset_policy_assumed"])
50
+ .describe(
51
+ "A named, enumerated reason Pithy's estimate can differ from Google's authoritative count. Enumerated rather than prose so a dashboard can render each one and none can be quietly dropped.",
52
+ );
53
+ export type DivergenceRisk = z.output<typeof DivergenceRisk>;
54
+
55
+ /** The disclosure that travels with every opt-in figure. Required everywhere, always. */
56
+ export const EstimateDisclaimer = z
57
+ .object({
58
+ authority: z.literal("google_play_console").describe("Who owns the number that actually decides this. Not us."),
59
+ readable: z
60
+ .literal(false)
61
+ .describe(
62
+ "Always false. No Google API exposes the opt-in count or the continuous-day streak, so Pithy cannot read it.",
63
+ ),
64
+ source: z
65
+ .literal("pithy_invite_records")
66
+ .describe("What Pithy's figure is derived from: our own invitation and confirmation records, and nothing else."),
67
+ statement: z.string().min(1).describe("The sentence a dashboard must render beside any opt-in figure."),
68
+ divergenceRisks: z
69
+ .array(DivergenceRisk)
70
+ .min(1)
71
+ .describe(
72
+ "Why the two can differ. Never empty — there is always at least a silent opt-out and an assumed reset policy.",
73
+ ),
74
+ })
75
+ .describe(
76
+ "Required on every cohort and on the envelope. A dashboard that spreads the cohort object cannot accidentally drop it.",
77
+ );
78
+ export type EstimateDisclaimer = z.output<typeof EstimateDisclaimer>;
79
+
80
+ /** The disclaimer value, built once. */
81
+ export const DISCLAIMER: EstimateDisclaimer = {
82
+ authority: "google_play_console",
83
+ readable: false,
84
+ source: "pithy_invite_records",
85
+ statement: ESTIMATE_STATEMENT,
86
+ divergenceRisks: [
87
+ "silent_opt_out",
88
+ "opt_in_outside_pithy",
89
+ "uninstall",
90
+ "day_boundary_mismatch",
91
+ "reset_policy_assumed",
92
+ ],
93
+ };
94
+
95
+ /** One term of a health score, so the number is auditable rather than asserted. */
96
+ export const HealthFactorView = z
97
+ .object({
98
+ code: z.string().describe("A stable identifier for this term, e.g. `dark_8_10`. Safe for a UI to key off."),
99
+ points: z
100
+ .number()
101
+ .int()
102
+ .describe("The signed contribution to the score. Penalties are negative, credits positive."),
103
+ reason: z.string().describe("One sentence explaining this factor to a developer."),
104
+ })
105
+ .describe("One term of a tester's health score.");
106
+ export type HealthFactorView = z.output<typeof HealthFactorView>;
107
+
108
+ /** One registered device, as the roster reports it. */
109
+ export const DeviceView = z
110
+ .object({
111
+ platform: z.enum(["ios", "android", "web"]).describe("The device platform, from the auth device registry."),
112
+ lastSeenAt: z.iso.datetime().describe("When this device was last seen signing in."),
113
+ appVersion: z.string().nullable().describe("The client app version at that sign-in, or null."),
114
+ })
115
+ .describe("One device registered to a tester.");
116
+ export type DeviceView = z.output<typeof DeviceView>;
117
+
118
+ /** The observed half of a tester: fact, read from `@pithy-sh/auth`. */
119
+ export const TesterActivityView = z
120
+ .object({
121
+ source: z.literal("observed").describe("This block is fact read from the auth tables. It is not an estimate."),
122
+ observability: Observability.describe("Whether we can see this tester at all, and why not when we cannot."),
123
+ state: ActivityState.describe(
124
+ "What the activity says. `never_linked` must render differently from `inactive`: one is a tester drifting away, the other may simply be an app that never asks anyone to sign in.",
125
+ ),
126
+ lastAuthenticatedAt: z.iso
127
+ .datetime()
128
+ .nullable()
129
+ .describe("The most recent sign of life, or null when never linked."),
130
+ inactiveSince: z.iso
131
+ .datetime()
132
+ .nullable()
133
+ .describe(
134
+ "Set only when the state is `inactive`. Null for `never_linked`, because there is no 'since' to report.",
135
+ ),
136
+ daysDark: z
137
+ .number()
138
+ .int()
139
+ .nullable()
140
+ .describe("Days since the last sign of life, floored at their opt-in date. Null when never linked."),
141
+ sessionsInWindow: z
142
+ .number()
143
+ .int()
144
+ .describe("Sessions inside the cohort's window. Zero for a tester we cannot see."),
145
+ devices: z.array(DeviceView).describe("Registered devices, most recently seen first."),
146
+ })
147
+ .describe("The observed half of a tester — kept a separate object from the estimated half, by design.");
148
+ export type TesterActivityView = z.output<typeof TesterActivityView>;
149
+
150
+ /** One tester on a roster. */
151
+ export const MemberView = z
152
+ .object({
153
+ id: z.string().describe("The member id. Opaque and server-generated."),
154
+ email: z.string().describe("The invited address — the join key to the user record if they ever sign in."),
155
+ name: z.string().nullable().describe("The display name the developer supplied, or null."),
156
+ state: MemberState.describe("Roster state, replayed from the event log. Never written by inactivity."),
157
+ invitedAt: z.iso.datetime().describe("When the first invitation was sent."),
158
+ acceptedAt: z.iso
159
+ .datetime()
160
+ .nullable()
161
+ .describe("When they answered the first email agreeing to test, or null. A tap on a link, needing no account."),
162
+ estimatedOptedInAt: z.iso
163
+ .datetime()
164
+ .nullable()
165
+ .describe(
166
+ "When they followed Pithy's confirmation link. OUR record that they clicked our link — not evidence Google recorded an opt-in.",
167
+ ),
168
+ lapsedAt: z.iso
169
+ .datetime()
170
+ .nullable()
171
+ .describe("When they opted out or were removed, or null. Written only by an explicit act."),
172
+ estimatedOptInDays: z
173
+ .number()
174
+ .int()
175
+ .describe("Days since they confirmed, on Pithy's record. Zero when they have not."),
176
+ activity: TesterActivityView.describe("The observed half."),
177
+ health: z
178
+ .number()
179
+ .int()
180
+ .min(0)
181
+ .max(100)
182
+ .nullable()
183
+ .describe(
184
+ "0–100, or NULL for a tester we cannot observe. Null is not zero — absence of evidence is not evidence of risk.",
185
+ ),
186
+ healthBasis: z
187
+ .enum(["scored", "unobservable", "unreachable"])
188
+ .describe("Why the health is null when it is null. A UI must render `unobservable` gray, never red."),
189
+ riskBand: RiskBand.describe("The band the score falls in, which selects the survival prior."),
190
+ dailySurvival: z.number().describe("The published daily-survival prior used for this tester in the forecast."),
191
+ factors: z
192
+ .array(HealthFactorView)
193
+ .describe("Every term that produced the score, so it can be audited line by line."),
194
+ lastNudgedAt: z.iso.datetime().nullable().describe("When a nudge was last enqueued for them, or null."),
195
+ nudgeCooldownUntil: z.iso
196
+ .datetime()
197
+ .nullable()
198
+ .describe(
199
+ "When they may be nudged again, or null if they may be now. Enforced server-side; this is a preview of that decision.",
200
+ ),
201
+ unreachable: z.boolean().describe("Whether their address bounced or is suppressed. We cannot nudge them at all."),
202
+ })
203
+ .describe("One tester, with the estimated half and the observed half kept visibly separate.");
204
+ export type MemberView = z.output<typeof MemberView>;
205
+
206
+ /** Pithy's forecast for one cohort. Every field an estimate; none of it reads Google. */
207
+ export const CohortProjection = z
208
+ .object({
209
+ basis: ProjectionBasis.describe("Why the forecast reads as it does, and why any null here is null."),
210
+ calibration: z
211
+ .literal("default")
212
+ .describe(
213
+ "`default` says out loud that the survival priors are numbers Pithy chose, not values fitted to your data.",
214
+ ),
215
+ method: z
216
+ .literal("poisson_binomial_v1")
217
+ .describe("The named, versioned method. A chart must not splice two methods into one line."),
218
+ confidence: ProjectionConfidence.nullable().describe(
219
+ "How much to trust this. Null when nothing is observable at all.",
220
+ ),
221
+ observedCoverage: z
222
+ .number()
223
+ .min(0)
224
+ .max(1)
225
+ .describe("Share of opted-in testers we can see. The honest denominator behind every number here."),
226
+ probabilityReachTarget: z
227
+ .number()
228
+ .min(0)
229
+ .max(1)
230
+ .describe("Chance of reaching the target at all. One when already there."),
231
+ probabilityHoldWindow: z
232
+ .number()
233
+ .min(0)
234
+ .max(1)
235
+ .nullable()
236
+ .describe("Chance at least `targetSize` testers hold for the remaining days. Null when nothing is observable."),
237
+ successProbability: z
238
+ .number()
239
+ .min(0)
240
+ .max(0.99)
241
+ .nullable()
242
+ .describe(
243
+ "PITHY'S ESTIMATE of completing the window. Capped below one — nothing is certain until Google says so. Null when there is no observable signal, rather than a plausible-looking guess.",
244
+ ),
245
+ successProbabilityRange: z
246
+ .object({
247
+ low: z.number().min(0).max(1).describe("The pessimistic bound, treating every unobservable tester as fragile."),
248
+ high: z.number().min(0).max(1).describe("The optimistic bound, treating every unobservable tester as solid."),
249
+ })
250
+ .nullable()
251
+ .describe(
252
+ "The band widens exactly in proportion to how blind Pithy is. Render the band, not the point — the width IS the disclosure.",
253
+ ),
254
+ expectedSurvivors: z.number().describe("How many testers we expect to still be opted in when the window closes."),
255
+ projectedTargetMetOn: z
256
+ .string()
257
+ .nullable()
258
+ .describe("Projected UTC day the cohort first reaches target, or null with a basis."),
259
+ projectedCompleteOn: z.string().nullable().describe("Projected UTC day the window completes, on Pithy's estimate."),
260
+ invitesNeeded: z
261
+ .number()
262
+ .int()
263
+ .describe(
264
+ "How many more people to invite to close the gap at this cohort's own conversion rate. Usually worth more than the probability beside it.",
265
+ ),
266
+ recommendedRosterSize: z
267
+ .number()
268
+ .int()
269
+ .describe("The roster size that survives the window at this cohort's own conversion and drop-off rates."),
270
+ })
271
+ .describe("Pithy's forecast for a cohort. Every field is an estimate; none of it reads Google.");
272
+ export type CohortProjection = z.output<typeof CohortProjection>;
273
+
274
+ /** One daily snapshot, as the chart consumes it. */
275
+ export const SnapshotView = z
276
+ .object({
277
+ snapshotOn: z.string().describe("The UTC day this point covers, `YYYY-MM-DD`."),
278
+ dayIndex: z
279
+ .number()
280
+ .int()
281
+ .describe("Days since the cohort was created — a zero-based x-axis so cohorts can be overlaid."),
282
+ backfilled: z
283
+ .boolean()
284
+ .describe(
285
+ "Written after its day had passed. Render dashed: the activity figures were reconstructed, not measured.",
286
+ ),
287
+ modelVersion: z
288
+ .string()
289
+ .describe("The constant set behind this point's forecast. Annotate the chart where it changes."),
290
+ rosterSize: z.number().int().describe("Members on the roster that day."),
291
+ invitedCount: z.number().int().describe("Invited, and have not yet answered."),
292
+ acceptedCount: z.number().int().describe("Have agreed to test, and are waiting for the store link."),
293
+ estimatedOptedInCount: z.number().int().describe("Pithy's estimate of the opted-in count that day."),
294
+ lapsedCount: z.number().int().describe("Opted out or removed."),
295
+ targetSize: z.number().int().describe("The target in force that day."),
296
+ meetsTarget: z.boolean().describe("Whether the estimate reached the target that day."),
297
+ headroom: z.number().int().describe("Estimated count minus target. Zero is the danger line; it can go negative."),
298
+ estimatedHeldDays: z.number().int().describe("The estimated unbroken at-target run ending that day."),
299
+ estimatedDaysRemaining: z.number().int().describe("Window days still to hold, on Pithy's estimate."),
300
+ resetToday: z
301
+ .boolean()
302
+ .describe("Whether the run broke that day — the single most important annotation on the chart."),
303
+ resetCount: z.number().int().describe("How many times the run had broken by that day."),
304
+ activeCount: z.number().int().describe("Observed testers who authenticated inside the active window."),
305
+ darkThreeToSevenCount: z.number().int().describe("Observed testers quiet 3–7 days. The first leading indicator."),
306
+ darkEightToThirteenCount: z
307
+ .number()
308
+ .int()
309
+ .describe("Observed testers quiet 8–13 days. The strongest silent-uninstall signal."),
310
+ darkFourteenPlusCount: z.number().int().describe("Observed testers quiet 14 days or more."),
311
+ neverLinkedCount: z
312
+ .number()
313
+ .int()
314
+ .describe("Opted-in testers who never signed in. Counted toward the target; invisible to activity."),
315
+ observedCoverage: z.number().describe("Share of opted-in testers visible that day."),
316
+ medianHealth: z.number().int().nullable().describe("Median health across observed testers, or null."),
317
+ minHealth: z
318
+ .number()
319
+ .int()
320
+ .nullable()
321
+ .describe("The weakest observed tester. A cohort breaks at its weakest link."),
322
+ successProbability: z.number().nullable().describe("Pithy's estimate of completing the window, as of that day."),
323
+ successProbabilityLow: z.number().nullable().describe("The pessimistic bound that day."),
324
+ successProbabilityHigh: z.number().nullable().describe("The optimistic bound that day."),
325
+ expectedSurvivors: z.number().describe("Expected survivors as of that day."),
326
+ invitesNeeded: z.number().int().describe("How many more to invite, as of that day."),
327
+ trendDirection: TrendDirection.describe("The direction as of that day."),
328
+ trendReason: z.string().describe("The one-sentence explanation as of that day."),
329
+ fragile: z.boolean().describe("At target, no headroom, at least one weak tester."),
330
+ nudgesSent: z
331
+ .object({
332
+ confirm: z.number().int().describe("Will-you-test nudges enqueued that day."),
333
+ store: z
334
+ .number()
335
+ .int()
336
+ .describe("Store-link nudges enqueued that day — the only kind that can move the opt-in count."),
337
+ inactive: z.number().int().describe("Inactivity nudges enqueued that day."),
338
+ closing: z.number().int().describe("Window-closing nudges enqueued that day."),
339
+ })
340
+ .describe("Nudges enqueued that day, so an intervention can be read against its effect."),
341
+ })
342
+ .describe("One point on the trend chart — a day of a cohort's position, as it was believed on that day.");
343
+ export type SnapshotView = z.output<typeof SnapshotView>;
344
+
345
+ /** Direction of travel, plus the series behind it. */
346
+ export const TrendView = z
347
+ .object({
348
+ direction: TrendDirection.describe("Improving, steady, declining, or unknown — by a published rule, not a fit."),
349
+ reason: z.string().describe("One sentence explaining the direction, for rendering beside the arrow."),
350
+ fragile: z.boolean().describe("At target with no headroom and at least one weak tester: one lapse from a reset."),
351
+ optedInDelta1d: z
352
+ .number()
353
+ .int()
354
+ .nullable()
355
+ .describe("Opt-in change since yesterday. Null without a prior snapshot."),
356
+ optedInDelta7d: z.number().int().nullable().describe("Opt-in change over seven days."),
357
+ activeDelta7d: z
358
+ .number()
359
+ .int()
360
+ .nullable()
361
+ .describe("Active-count change over seven days — engagement's direction."),
362
+ successProbabilityDelta7d: z
363
+ .number()
364
+ .nullable()
365
+ .describe("Forecast change over seven days. The rule's primary input."),
366
+ series: z
367
+ .array(SnapshotView)
368
+ .describe("Trailing daily snapshots, oldest first. Present only when `trend` was requested."),
369
+ })
370
+ .describe("Direction of travel, plus the chartable series behind it.");
371
+ export type TrendView = z.output<typeof TrendView>;
372
+
373
+ /** One cohort: state, the estimated clock, observed activity, the forecast, and the trend. */
374
+ export const CohortView = z
375
+ .object({
376
+ id: z.string().describe("The cohort id."),
377
+ name: z.string().describe("The cohort's human label."),
378
+ targetPlatform: z.enum(["android", "ios"]).describe("Which store's program this cohort serves."),
379
+ targetSize: z.number().int().describe("Testers required simultaneously. Twelve for Google Play."),
380
+ windowDays: z.number().int().describe("Continuous days required. Fourteen for Google Play."),
381
+ maxRosterSize: z.number().int().describe("The cohort's roster cap."),
382
+ resetPolicy: z
383
+ .enum(["reset", "pause"])
384
+ .describe(
385
+ "Pithy's ASSUMPTION about what a dip below target does to the streak. Google documents neither behavior.",
386
+ ),
387
+ createdAt: z.iso.datetime().describe("When the cohort was created."),
388
+ closedAt: z.iso.datetime().nullable().describe("When it was closed, or null while running."),
389
+
390
+ roster: z
391
+ .object({
392
+ size: z.number().int().describe("Members on the roster, in every state."),
393
+ headroomToMax: z.number().int().describe("How many more may be added before the cap."),
394
+ invited: z.number().int().describe("Invited, and have not yet answered."),
395
+ accepted: z.number().int().describe("Have agreed to test, and are waiting for the store link."),
396
+ optedIn: z.number().int().describe("Pithy's estimate of currently opted-in testers."),
397
+ lapsed: z.number().int().describe("Opted out or removed."),
398
+ unreachable: z.number().int().describe("Bounced or suppressed — we cannot nudge them."),
399
+ neverLinked: z
400
+ .number()
401
+ .int()
402
+ .describe("Confirmed but never signed in. Counted toward the target; invisible to activity."),
403
+ })
404
+ .describe("Current roster composition."),
405
+
406
+ estimatedClock: z
407
+ .object({
408
+ source: z
409
+ .literal("pithy_estimate")
410
+ .describe(
411
+ "This block is Pithy's estimate, replayed from our own invite records. Google's equivalent is not readable.",
412
+ ),
413
+ meetsTarget: z.boolean().describe("Whether the estimated opted-in count is at or above target right now."),
414
+ headroom: z.number().int().describe("Estimated count minus target. Zero means one lapse from a reset."),
415
+ estimatedHeldDays: z.number().int().describe("The estimated unbroken at-target run ending today."),
416
+ estimatedDaysRemaining: z.number().int().describe("Days still to hold, on Pithy's estimate."),
417
+ estimatedWindowStartOn: z.string().nullable().describe("The UTC day the current run began, or null."),
418
+ resetCount: z.number().int().describe("How many times the run has broken since the cohort started."),
419
+ dayBoundary: z
420
+ .literal("UTC")
421
+ .describe(
422
+ "Pithy counts days in UTC. Google's boundary is undocumented and may differ, which is one more reason not to trust day fourteen.",
423
+ ),
424
+ })
425
+ .describe("Pithy's clock, named `estimated*` field by field so it cannot be mistaken for Google's."),
426
+
427
+ activity: z
428
+ .object({
429
+ source: z.literal("observed").describe("This block is fact from the auth tables, not an estimate."),
430
+ active: z.number().int().describe("Authenticated inside the active window."),
431
+ darkThreeToSeven: z.number().int().describe("Quiet 3–7 days."),
432
+ darkEightToThirteen: z.number().int().describe("Quiet 8–13 days — the strongest silent-uninstall signal."),
433
+ darkFourteenPlus: z.number().int().describe("Quiet 14 days or more."),
434
+ neverLinked: z.number().int().describe("No activity data exists for these testers at all."),
435
+ observedCoverage: z.number().describe("Share of opted-in testers with any activity signal."),
436
+ })
437
+ .describe("The observed half."),
438
+
439
+ projection: CohortProjection.describe("Pithy's forecast for this cohort."),
440
+ trend: TrendView.describe("Direction of travel, plus the series when requested."),
441
+
442
+ reconciliation: z
443
+ .object({
444
+ supported: z.literal(false).describe("Always false today. No store API reconciliation exists."),
445
+ lastReconciledAt: z
446
+ .null()
447
+ .describe(
448
+ "Always null today. The field exists so a future version has a home and this one cannot be misread.",
449
+ ),
450
+ reason: z
451
+ .string()
452
+ .describe(
453
+ "Why not, in one sentence — so 'we checked and cannot' is distinguishable from 'we never thought about it'.",
454
+ ),
455
+ })
456
+ .describe("Store reconciliation status. Honestly empty rather than absent."),
457
+
458
+ disclaimer: EstimateDisclaimer.describe("Required. Render it wherever an opt-in figure is rendered."),
459
+ members: z
460
+ .array(MemberView)
461
+ .optional()
462
+ .describe("The full roster with per-tester health. Present only when requested."),
463
+ })
464
+ .describe("One cohort: current state, the estimated clock, observed activity, the forecast, and the trend.");
465
+ export type CohortView = z.output<typeof CohortView>;
466
+
467
+ /** The control-plane cohort read. */
468
+ export const CohortsResponse = z
469
+ .object({
470
+ cohorts: z.array(CohortView).describe("Every cohort in this Worker, or the one requested."),
471
+ modelVersion: z.string().describe("The constant set behind every forecast in this response."),
472
+ generatedAt: z.iso.datetime().describe("When this response was computed."),
473
+ disclaimer: EstimateDisclaimer.describe(
474
+ "Repeated at the envelope, so a dashboard rendering a list still carries it.",
475
+ ),
476
+ })
477
+ .describe("What a dashboard needs to render cohort state, per-tester health, the trend, and the forecast.");
478
+ export type CohortsResponse = z.output<typeof CohortsResponse>;
479
+
480
+ /**
481
+ * ## The write routes, and the tester's own read
482
+ *
483
+ * Everything above describes the control-plane cohort read. What follows is the rest of the wire
484
+ * contract — the roster writes, the nudge, and the one route a tester calls about themselves. They
485
+ * were object literals with no schema, which meant a management client had nothing to validate them
486
+ * with and hand-wrote a mirror of each.
487
+ *
488
+ * **The three write responses carry an id and never an address.** The scopes are separated so a
489
+ * credential may mail or manage a roster it was never granted permission to read; echoing the address
490
+ * back on every write would turn a list of ids into a list of real people's email addresses, which is
491
+ * exactly what `testers:roster:read` exists to gate. `invite` is the one exception and it is not one:
492
+ * the caller sent the address in the request body, so returning it discloses nothing new.
493
+ */
494
+
495
+ /** One membership, as the tester themselves sees it. */
496
+ export const MembershipView = z
497
+ .object({
498
+ cohortName: z.string().describe("The cohort's human label — the only thing about it a tester is shown."),
499
+ state: MemberState.describe("Their own roster state."),
500
+ estimatedOptedInAt: z.iso
501
+ .datetime()
502
+ .nullable()
503
+ .describe("When they followed Pithy's confirmation link, or null. OUR record, not Google's."),
504
+ estimatedDaysRemaining: z.number().int().describe("Window days still to hold, on Pithy's estimate."),
505
+ windowDays: z.number().int().describe("Continuous days the program requires."),
506
+ })
507
+ .describe("One cohort a tester belongs to, as that tester sees it — never the roster, never the forecast.");
508
+ export type MembershipView = z.output<typeof MembershipView>;
509
+
510
+ /**
511
+ * `GET {base}/status` — a tester's own view.
512
+ *
513
+ * A tester sees their memberships and nothing else. An address this Worker cannot resolve to a user
514
+ * gets an empty list rather than a 404, so the route is not an oracle for who is on a roster.
515
+ */
516
+ export const MembershipsResponse = z
517
+ .object({
518
+ memberships: z
519
+ .array(MembershipView)
520
+ .describe("Every cohort the caller belongs to. Empty when they belong to none."),
521
+ disclaimer: EstimateDisclaimer.describe("Required here too — a tester reading days remaining is reading a guess."),
522
+ })
523
+ .describe("What a tester is told about their own participation.");
524
+ export type MembershipsResponse = z.output<typeof MembershipsResponse>;
525
+
526
+ /** `POST {base}/invite`. */
527
+ export const InviteResponse = z
528
+ .object({
529
+ member: z
530
+ .object({
531
+ id: z.string().describe("The member id — the handle every other write route takes."),
532
+ email: z.string().describe("The invited address. Returned because the caller just sent it."),
533
+ state: MemberState.describe("Their roster state after the invitation."),
534
+ })
535
+ .describe("The roster row, as the invitation left it."),
536
+ created: z
537
+ .boolean()
538
+ .describe(
539
+ "False when the address was already on this roster. Re-inviting is not an error, so this is how a caller tells the two apart.",
540
+ ),
541
+ jobId: z
542
+ .string()
543
+ .nullable()
544
+ .describe("The email job the invitation was enqueued as, or null when the caller asked for no mail."),
545
+ })
546
+ .describe("The invited tester, whether the roster row is new, and the mail it enqueued.");
547
+ export type InviteResponse = z.output<typeof InviteResponse>;
548
+
549
+ /** `POST {base}/resend`. */
550
+ export const ResendResponse = z
551
+ .object({
552
+ member: z
553
+ .object({ id: z.string().describe("The member the invitation went to. An id, never the address.") })
554
+ .describe("Who was re-invited."),
555
+ jobId: z.string().describe("The email job the re-invitation was enqueued as."),
556
+ })
557
+ .describe("Which tester was re-invited, and the mail it enqueued.");
558
+ export type ResendResponse = z.output<typeof ResendResponse>;
559
+
560
+ /** `POST {base}/remove`. */
561
+ export const RemoveResponse = z
562
+ .object({
563
+ member: z
564
+ .object({
565
+ id: z.string().describe("The member removed. An id, never the address."),
566
+ state: MemberState.describe("Their roster state after the removal."),
567
+ })
568
+ .describe("The roster row, as the removal left it."),
569
+ })
570
+ .describe("The removed tester's row, as it now stands.");
571
+ export type RemoveResponse = z.output<typeof RemoveResponse>;
572
+
573
+ /** Why an eligible tester was not mailed. Counts, so a caller can tell a full send from a partial one. */
574
+ const NudgeSkipped = z
575
+ .object({
576
+ cooling: z.number().int().describe("Inside the cooldown window, so they were not mailed again."),
577
+ unreachable: z.number().int().describe("Bounced or suppressed — we cannot mail them at all."),
578
+ chasedOut: z.number().int().describe("Already chased as far as the policy allows."),
579
+ truncated: z
580
+ .number()
581
+ .int()
582
+ .describe(
583
+ "Eligible testers dropped by the batch cap. Reported rather than silent: without it a caller cannot tell 'everyone eligible was mailed' from 'the first two hundred were'.",
584
+ ),
585
+ })
586
+ .describe("Who was eligible and still not mailed, by reason.");
587
+
588
+ /**
589
+ * `POST {base}/nudge` with `dryRun: true`.
590
+ *
591
+ * Ids, not addresses. A caller holding only `testers:nudge:send` can mail the roster but was never
592
+ * granted permission to read it, and a preview returning every eligible tester's email would hand
593
+ * them exactly what `testers:roster:read` exists to gate.
594
+ */
595
+ export const NudgeDryRunResponse = z
596
+ .object({
597
+ dryRun: z.literal(true).describe("Always true. The discriminator that keeps a preview from being read as a send."),
598
+ wouldSend: z
599
+ .array(
600
+ z
601
+ .object({ id: z.string().describe("A member who would be mailed. An id, never the address.") })
602
+ .describe("One recipient of the send this preview describes."),
603
+ )
604
+ .describe("Who this send would reach, capped at the batch limit."),
605
+ cooling: z.number().int().describe("Inside the cooldown window."),
606
+ unreachable: z.number().int().describe("Bounced or suppressed."),
607
+ chasedOut: z.number().int().describe("Already chased as far as the policy allows."),
608
+ truncated: z.number().int().describe("Eligible testers dropped by the batch cap."),
609
+ })
610
+ .describe("What a nudge would do, without doing it.");
611
+ export type NudgeDryRunResponse = z.output<typeof NudgeDryRunResponse>;
612
+
613
+ /** `POST {base}/nudge`. */
614
+ export const NudgeResponse = z
615
+ .object({
616
+ sent: z
617
+ .array(
618
+ z
619
+ .object({
620
+ memberId: z.string().describe("The tester mailed. An id, never the address."),
621
+ jobId: z.string().describe("The email job it was enqueued as."),
622
+ })
623
+ .describe("One enqueued nudge."),
624
+ )
625
+ .describe("Every nudge actually enqueued. Never empty — an empty send is refused, not reported as success."),
626
+ skipped: NudgeSkipped.describe("Who was eligible and still not mailed, by reason."),
627
+ copySource: z
628
+ .enum(["supplied", "default"])
629
+ .describe(
630
+ "Whether the caller supplied the copy or the capability's own was used. The provenance, never the words.",
631
+ ),
632
+ })
633
+ .describe("What a nudge send actually did, and who it left out.");
634
+ export type NudgeResponse = z.output<typeof NudgeResponse>;