@oxyhq/crowdsource-contracts 0.2.0 → 0.4.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 (87) hide show
  1. package/dist/appeals.d.ts +323 -0
  2. package/dist/appeals.d.ts.map +1 -0
  3. package/dist/appeals.js +203 -0
  4. package/dist/appeals.js.map +1 -0
  5. package/dist/case-envelope.d.ts +59 -59
  6. package/dist/case-envelope.d.ts.map +1 -1
  7. package/dist/case-envelope.js +32 -32
  8. package/dist/case-envelope.js.map +1 -1
  9. package/dist/decisions.d.ts +3 -3
  10. package/dist/decisions.d.ts.map +1 -1
  11. package/dist/decisions.js +28 -28
  12. package/dist/decisions.js.map +1 -1
  13. package/dist/esm/appeals.js +200 -0
  14. package/dist/esm/appeals.js.map +1 -0
  15. package/dist/esm/case-envelope.js +380 -0
  16. package/dist/esm/case-envelope.js.map +1 -0
  17. package/dist/esm/closed.js +32 -0
  18. package/dist/esm/closed.js.map +1 -0
  19. package/dist/esm/decisions.js +195 -0
  20. package/dist/esm/decisions.js.map +1 -0
  21. package/dist/esm/index.js +48 -0
  22. package/dist/esm/index.js.map +1 -0
  23. package/dist/esm/json-schema.js +84 -0
  24. package/dist/esm/json-schema.js.map +1 -0
  25. package/dist/esm/package.json +1 -0
  26. package/dist/esm/policies.js +175 -0
  27. package/dist/esm/policies.js.map +1 -0
  28. package/dist/esm/primitives.js +249 -0
  29. package/dist/esm/primitives.js.map +1 -0
  30. package/dist/esm/reputation-events.js +125 -0
  31. package/dist/esm/reputation-events.js.map +1 -0
  32. package/dist/esm/resources.js +455 -0
  33. package/dist/esm/resources.js.map +1 -0
  34. package/dist/esm/reviewer-surface.js +612 -0
  35. package/dist/esm/reviewer-surface.js.map +1 -0
  36. package/dist/esm/reviews.js +141 -0
  37. package/dist/esm/reviews.js.map +1 -0
  38. package/dist/esm/taxonomy.js +278 -0
  39. package/dist/esm/taxonomy.js.map +1 -0
  40. package/dist/esm/webhooks.js +188 -0
  41. package/dist/esm/webhooks.js.map +1 -0
  42. package/dist/index.d.ts +13 -11
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +13 -11
  45. package/dist/index.js.map +1 -1
  46. package/dist/json-schema.d.ts +2 -2
  47. package/dist/json-schema.d.ts.map +1 -1
  48. package/dist/json-schema.js +26 -21
  49. package/dist/json-schema.js.map +1 -1
  50. package/dist/policies.d.ts +1 -1
  51. package/dist/policies.d.ts.map +1 -1
  52. package/dist/policies.js +14 -14
  53. package/dist/policies.js.map +1 -1
  54. package/dist/primitives.d.ts +17 -0
  55. package/dist/primitives.d.ts.map +1 -1
  56. package/dist/primitives.js +22 -1
  57. package/dist/primitives.js.map +1 -1
  58. package/dist/reputation-events.js +19 -19
  59. package/dist/reputation-events.js.map +1 -1
  60. package/dist/resources.d.ts +116 -39
  61. package/dist/resources.d.ts.map +1 -1
  62. package/dist/resources.js +143 -121
  63. package/dist/resources.js.map +1 -1
  64. package/dist/reviewer-surface.d.ts +1956 -0
  65. package/dist/reviewer-surface.d.ts.map +1 -0
  66. package/dist/reviewer-surface.js +615 -0
  67. package/dist/reviewer-surface.js.map +1 -0
  68. package/dist/reviews.d.ts +2 -2
  69. package/dist/reviews.js +15 -15
  70. package/dist/reviews.js.map +1 -1
  71. package/dist/webhooks.d.ts +4 -4
  72. package/dist/webhooks.d.ts.map +1 -1
  73. package/dist/webhooks.js +18 -18
  74. package/dist/webhooks.js.map +1 -1
  75. package/package.json +4 -4
  76. package/src/appeals.ts +229 -0
  77. package/src/case-envelope.ts +5 -5
  78. package/src/decisions.ts +6 -6
  79. package/src/index.ts +13 -11
  80. package/src/json-schema.ts +13 -8
  81. package/src/policies.ts +3 -3
  82. package/src/primitives.ts +25 -0
  83. package/src/reputation-events.ts +4 -4
  84. package/src/resources.ts +136 -100
  85. package/src/reviewer-surface.ts +715 -0
  86. package/src/reviews.ts +4 -4
  87. package/src/webhooks.ts +4 -4
@@ -0,0 +1,715 @@
1
+ /**
2
+ * The Reviewer API wire contract (§10.3) — what the backend sends and the
3
+ * reviewer app receives, declared once.
4
+ *
5
+ * ## Why this module exists at all
6
+ *
7
+ * It exists because the alternative was tried and failed silently. The backend
8
+ * and the reviewer app each wrote their own version of these shapes, and they
9
+ * agreed on almost nothing: the state vocabularies differed outright
10
+ * (`community` against `community_reviewer`), so did the sensitivity classes
11
+ * (`standard`/`sensitive`/`restricted` against an invented
12
+ * `none`/`low`/`high`/`critical`); the assignment package disagreed on
13
+ * `allegation` against `allegations`, on `policyVersion` against `version`, and
14
+ * on five fields that existed on one side only. Nothing caught it, because there
15
+ * was no shared declaration for anything to be checked against.
16
+ *
17
+ * So the rule this module encodes: **a field that crosses the reviewer boundary
18
+ * is declared here and nowhere else.**
19
+ *
20
+ * ## Schemas, and types inferred from them
21
+ *
22
+ * Every shape is a Zod schema, and its TypeScript type is `z.infer` of that
23
+ * schema. One declaration, so the two cannot disagree — which is a property worth
24
+ * more here than the hand-written interfaces it replaces, since "two declarations
25
+ * of one shape drifted apart" is the exact bug this file was written to end.
26
+ *
27
+ * Both halves are load-bearing and they catch different failures:
28
+ *
29
+ * - The **type** is what the backend's builders return and what the app's
30
+ * projections return, so a renamed field is a compile error on BOTH sides at
31
+ * once — the check that would have caught every drift listed above on the day
32
+ * it appeared.
33
+ * - The **schema** is what the app parses the response WITH, and it is
34
+ * `.strict()` at every level. §9.1's hidden column has no field on any shape
35
+ * here, so a server that starts sending a report count or an author's
36
+ * reputation is REFUSED rather than having it quietly reach a screen. A type
37
+ * cannot do that: types are gone at runtime, and the wire is where an
38
+ * unexpected field actually arrives.
39
+ *
40
+ * Strict is the right direction for this surface specifically. A reviewer seeing
41
+ * a blank screen is a bug somebody fixes today; a reviewer seeing an author's
42
+ * reputation is a bug nobody notices.
43
+ *
44
+ * ## What is deliberately NOT here
45
+ *
46
+ * No case id, on any shape. §8.7 pseudonymises what a reviewer does not need and
47
+ * a case id is not needed to judge material; more to the point, a case id on the
48
+ * wire is a case id in a URL, and "nobody chooses the case they review" holds
49
+ * because the assignment is the only handle. No reporter, no report count, no
50
+ * reputation, no juror identity, no partial tally.
51
+ */
52
+
53
+ import { z } from 'zod';
54
+
55
+ import { DecisionOutcomeSchema } from './decisions.js';
56
+ import { PolicyRuleSchema, PolicySetIdSchema, PolicyVersionSchema } from './policies.js';
57
+ import {
58
+ CustomPayloadSchema,
59
+ IdentifierSchema,
60
+ LanguageTagSchema,
61
+ MetadataBagSchema,
62
+ MimeTypeSchema,
63
+ TimestampSchema,
64
+ UnitIntervalSchema,
65
+ } from './primitives.js';
66
+ import {
67
+ AudioResourceDataSchema,
68
+ ConversationResourceDataSchema,
69
+ DocumentResourceDataSchema,
70
+ LinkResourceDataSchema,
71
+ ListingResourceDataSchema,
72
+ LocationResourceDataSchema,
73
+ MetadataResourceDataSchema,
74
+ PrincipalRefSchema,
75
+ ProfileResourceDataSchema,
76
+ RelationSchema,
77
+ ResourceIdSchema,
78
+ ResourceRoleSchema,
79
+ TextResourceDataSchema,
80
+ VideoResourceDataSchema,
81
+ type Resource,
82
+ } from './resources.js';
83
+ import { ReviewOutcomeSchema } from './reviews.js';
84
+ import {
85
+ SensitivityHintSchema,
86
+ TAXONOMY_FAMILIES,
87
+ TaxonomyCodeSchema,
88
+ TaxonomyFamilySchema,
89
+ } from './taxonomy.js';
90
+
91
+ // --- vocabularies ------------------------------------------------------------
92
+
93
+ /**
94
+ * §8.1's seven onboarding states, ordered from least to most capable.
95
+ *
96
+ * §8.1 writes them as English prose labels ("Community Reviewer", "Category
97
+ * Specialist"). These are the identifiers, and they are the SHORT forms on
98
+ * purpose: the `_reviewer` suffix says nothing a `ReviewerState` type does not
99
+ * already say, and `specialist` pairs with a separate `specialistCategories`
100
+ * field, so `category_specialist` would name the category twice.
101
+ *
102
+ * `suspended` is apart from the ordering rather than at the bottom of it — see
103
+ * the backend's `REVIEWER_STATE_RANK`, which gives it -1 so every capability
104
+ * comparison fails for it, including one against `applicant`.
105
+ */
106
+ export const REVIEWER_STATES = [
107
+ 'applicant',
108
+ 'calibrating',
109
+ 'community',
110
+ 'trusted',
111
+ 'specialist',
112
+ 'appeals',
113
+ 'suspended',
114
+ ] as const;
115
+ export const ReviewerStateSchema = z.enum(REVIEWER_STATES);
116
+ export type ReviewerState = z.infer<typeof ReviewerStateSchema>;
117
+
118
+ /**
119
+ * The sensitivity classes a jury can be shown, ranked (§7.5, §13.7).
120
+ *
121
+ * Triage computes a fourth, `prohibited`, and it is absent here on purpose: §7.5
122
+ * routes that material to a specialist team under legal protocol and never to a
123
+ * jury, so a reviewer-facing type that could express it would eventually be
124
+ * handed it. The backend's `sensitivityRank` throws rather than returning a
125
+ * number for it, for the same reason.
126
+ *
127
+ * This is also the vocabulary a reviewer's own consent is stored in, as a rank —
128
+ * which is why it could never have been the app's invented
129
+ * `none`/`low`/`high`/`critical`: those four map onto nothing the server computes
130
+ * or the reviewer consented to.
131
+ */
132
+ export const REVIEWER_SENSITIVITY_CLASSES = ['standard', 'sensitive', 'restricted'] as const;
133
+ export const ReviewerSensitivityClassSchema = z.enum(REVIEWER_SENSITIVITY_CLASSES);
134
+ export type ReviewerSensitivityClass = z.infer<typeof ReviewerSensitivityClassSchema>;
135
+
136
+ /**
137
+ * The eligibility requirements a reviewer may be TOLD about (§8.2).
138
+ *
139
+ * §8.2 lists ten conditions and this list is eight, which is the interesting
140
+ * part. Two groups are missing and neither is an oversight:
141
+ *
142
+ * - **Relative to a case.** Conflict with a party, prior participation in the
143
+ * case or incident, and coordination-cluster overlap are properties of a
144
+ * candidate against a specific case's parties — they live in
145
+ * `sortition/exclusions.ts` where the parties are known, and outside a draw
146
+ * there is no answer to give.
147
+ * - **Anti-abuse signals.** A sock-puppet suspicion is deliberately not
148
+ * disclosed. Telling somebody they are flagged tells them what to change,
149
+ * which is the same reasoning that keeps `suspectedSockPuppet` and
150
+ * `riskClusterId` out of the profile projection entirely.
151
+ *
152
+ * Exposure is absent too, but only because it has a richer home of its own:
153
+ * `ReviewerExposureView` gives the numbers rather than a boolean.
154
+ *
155
+ * Every id here maps to a check the server actually performs, so the app can
156
+ * never display a requirement nobody enforces. There is no server-authored
157
+ * explanation string: the app is translated (`locales/*.json`) and a sentence
158
+ * composed by a backend with no locale would arrive in the wrong language.
159
+ */
160
+ export const REVIEWER_ELIGIBILITY_REQUIREMENTS = [
161
+ /** §8.2: "an active and authenticated Oxy account". */
162
+ 'oxy_account',
163
+ /** §8.2: personhood sufficient for real decisions. */
164
+ 'personhood',
165
+ /** §8.2: an age compatible with the chosen categories. */
166
+ 'age',
167
+ /** §13.7: the reviewing rules were accepted. */
168
+ 'rules_accepted',
169
+ /** §8.2: at least one language, or no case can ever match. */
170
+ 'languages_selected',
171
+ /** §8.2: at least one category, likewise. */
172
+ 'categories_selected',
173
+ /** §8.1: every training module complete. */
174
+ 'training_current',
175
+ /** §8.2: calibration passed and not lapsed. */
176
+ 'calibration_current',
177
+ ] as const;
178
+ export const ReviewerEligibilityRequirementIdSchema = z.enum(REVIEWER_ELIGIBILITY_REQUIREMENTS);
179
+ export type ReviewerEligibilityRequirementId = z.infer<
180
+ typeof ReviewerEligibilityRequirementIdSchema
181
+ >;
182
+
183
+ /**
184
+ * §13.7's ceiling on a self-chosen daily limit.
185
+ *
186
+ * Declared here because the request schema below bounds the body with it and the
187
+ * app's control has to offer the same range — two numbers that must agree, so
188
+ * there is one.
189
+ */
190
+ export const REVIEWER_DAILY_LIMIT_MAX = 40;
191
+
192
+ /** The header §8.7's assignment token travels in. One spelling, both sides. */
193
+ export const ASSIGNMENT_TOKEN_HEADER = 'x-assignment-token';
194
+
195
+ // --- case material -----------------------------------------------------------
196
+
197
+ /**
198
+ * A resource asset as a reviewer receives it (§8.7, §9.1).
199
+ *
200
+ * The envelope's `AssetRef` carries `uploadId` OR `url`, plus a `sha256`. None of
201
+ * the three reaches a reviewer:
202
+ *
203
+ * - `url` is a location on the REPORTING APPLICATION's own host, so sending it
204
+ * puts that application's brand in front of the jury — §9.1's last hidden row
205
+ * — and it bypasses the media chokepoint every Oxy surface resolves files
206
+ * through.
207
+ * - `sha256` is what pins a case to the exact bytes reported. A reviewer does
208
+ * not verify that; the case does.
209
+ *
210
+ * What replaces them is `fileId`, a bare Oxy file id resolved through
211
+ * `getFileDownloadUrl` at the app root, and `retrievable`, which is the honest
212
+ * answer when there is nothing a reviewer can be shown. A reviewer facing an
213
+ * unretrievable resource has an outcome for exactly that (`content_unavailable`),
214
+ * which is why this is a flag rather than an omission: dropping the resource
215
+ * would hide from the jury that material existed at all.
216
+ */
217
+ export const ReviewerAssetSchema = z.strictObject({
218
+ mediaType: MimeTypeSchema,
219
+ /** Bare Oxy file id. Absent whenever `retrievable` is false. */
220
+ fileId: IdentifierSchema.optional(),
221
+ /** False when CrowdSource cannot serve these bytes to a reviewer. */
222
+ retrievable: z.boolean(),
223
+ sizeBytes: z.number().int().positive().optional(),
224
+ width: z.number().int().positive().optional(),
225
+ height: z.number().int().positive().optional(),
226
+ durationSeconds: z.number().positive().optional(),
227
+ });
228
+ export type ReviewerAssetView = z.infer<typeof ReviewerAssetSchema>;
229
+
230
+ /**
231
+ * The envelope fields every reviewer-facing resource carries.
232
+ *
233
+ * `sha256` is absent, as above. `authorPrincipalRef` is PRESENT, and that is a
234
+ * decision rather than an oversight: §9.1 hides the author's IDENTITY and
235
+ * REPUTATION, and an envelope-scoped pseudonym (§13.5's "a pseudonymous
236
+ * principal wherever one suffices") is neither. It resolves to nothing outside
237
+ * this one case, and which resources share an author is exactly the context a
238
+ * harassment allegation cannot be judged without.
239
+ */
240
+ const reviewerResourceShape = {
241
+ id: ResourceIdSchema,
242
+ role: ResourceRoleSchema,
243
+ language: LanguageTagSchema.optional(),
244
+ createdAt: TimestampSchema.optional(),
245
+ authorPrincipalRef: PrincipalRefSchema.optional(),
246
+ sensitivity: SensitivityHintSchema.optional(),
247
+ };
248
+
249
+ /**
250
+ * One resource of case material, as a reviewer receives it.
251
+ *
252
+ * Built from the same `*DataSchema` exports the envelope's own resources are
253
+ * built from, so the twelve content shapes exist once. What differs is exactly
254
+ * the two things the reviewer projection changes: no `sha256`, and `asset` is a
255
+ * media handle rather than a locator.
256
+ *
257
+ * A strict parse rather than a hand-written field-by-field copy, and the reason
258
+ * is the direction each fails in. A copy can only drop what somebody thought to
259
+ * drop, and it can also DROP MATERIAL by accident — a jury shown less than the
260
+ * application sent is the worse failure of the two. A strict parse against a
261
+ * closed union refuses an unknown field outright and cannot silently lose a known
262
+ * one.
263
+ *
264
+ * `__tests__/reviewer-surface.test.ts` asserts this union still covers every
265
+ * `RESOURCE_TYPES` member and every base field of an envelope resource, so a new
266
+ * resource type or field in `resources.ts` cannot quietly stop reaching a jury.
267
+ */
268
+ export const ReviewerResourceSchema = z.discriminatedUnion('type', [
269
+ z.strictObject({
270
+ ...reviewerResourceShape,
271
+ type: z.literal('text'),
272
+ data: TextResourceDataSchema,
273
+ }),
274
+ z.strictObject({
275
+ ...reviewerResourceShape,
276
+ type: z.literal('link'),
277
+ data: LinkResourceDataSchema,
278
+ }),
279
+ z.strictObject({
280
+ ...reviewerResourceShape,
281
+ type: z.literal('profile'),
282
+ data: ProfileResourceDataSchema,
283
+ }),
284
+ z.strictObject({
285
+ ...reviewerResourceShape,
286
+ type: z.literal('conversation'),
287
+ data: ConversationResourceDataSchema,
288
+ }),
289
+ z.strictObject({
290
+ ...reviewerResourceShape,
291
+ type: z.literal('listing'),
292
+ data: ListingResourceDataSchema,
293
+ }),
294
+ z.strictObject({
295
+ ...reviewerResourceShape,
296
+ type: z.literal('location'),
297
+ data: LocationResourceDataSchema,
298
+ }),
299
+ z.strictObject({
300
+ ...reviewerResourceShape,
301
+ type: z.literal('metadata'),
302
+ data: MetadataResourceDataSchema,
303
+ }),
304
+ z.strictObject({
305
+ ...reviewerResourceShape,
306
+ type: z.literal('custom'),
307
+ schemaId: IdentifierSchema,
308
+ payload: CustomPayloadSchema,
309
+ }),
310
+ z.strictObject({
311
+ ...reviewerResourceShape,
312
+ type: z.literal('image'),
313
+ asset: ReviewerAssetSchema,
314
+ }),
315
+ z.strictObject({
316
+ ...reviewerResourceShape,
317
+ type: z.literal('video'),
318
+ asset: ReviewerAssetSchema,
319
+ data: VideoResourceDataSchema.optional(),
320
+ }),
321
+ z.strictObject({
322
+ ...reviewerResourceShape,
323
+ type: z.literal('audio'),
324
+ asset: ReviewerAssetSchema,
325
+ data: AudioResourceDataSchema.optional(),
326
+ }),
327
+ z.strictObject({
328
+ ...reviewerResourceShape,
329
+ type: z.literal('document'),
330
+ asset: ReviewerAssetSchema,
331
+ data: DocumentResourceDataSchema,
332
+ }),
333
+ ]);
334
+ export type ReviewerResource = z.infer<typeof ReviewerResourceSchema>;
335
+
336
+ /**
337
+ * The reviewer view of an envelope resource, derived rather than written.
338
+ *
339
+ * Not the type the two sides use — `ReviewerResource` above is. This exists so
340
+ * the test can compare the WRITTEN union against what the envelope's own
341
+ * `Resource` union implies, which is the only way a new resource type in
342
+ * `resources.ts` produces a failure rather than a resource that silently never
343
+ * reaches a jury.
344
+ */
345
+ export type ReviewerResourceOf<R = Resource> = R extends { asset: unknown }
346
+ ? Omit<R, 'sha256' | 'asset'> & { asset: ReviewerAssetView }
347
+ : Omit<R, 'sha256'>;
348
+
349
+ // --- the assignment package --------------------------------------------------
350
+
351
+ /**
352
+ * The renderable package for ONE assignment (§8.7, §9.1).
353
+ *
354
+ * `language` is nullable because an envelope's resources may declare none, and
355
+ * `families` is plural because a case can allege several: a reviewer is only
356
+ * drawn when they accept ALL of them (§8.2), so naming one would misdescribe what
357
+ * they were asked to judge.
358
+ *
359
+ * `allegations.unverified` is a constant `true` on the wire. It is redundant to a
360
+ * reader of this file and it is not redundant to a screen: §9.1's requirement is
361
+ * that the claim is presented AS a claim, and a flag that must be read to render
362
+ * the label cannot be forgotten the way a comment can. `codes` is plural because
363
+ * a case is the union of every report about the same material (§7.3) and
364
+ * reporters do not all choose the same code — a singular field would silently
365
+ * show a jury one allegation out of several.
366
+ *
367
+ * `watermark` is §13.8's pseudonymous per-assignment mark. Server-issued so a
368
+ * leaked screenshot is traceable to one assignment; never synthesised on the
369
+ * device, because a watermark the client composes is one the client can also omit
370
+ * or forge, leaving a reviewer with the impression of a sealed screen and no
371
+ * actual trace.
372
+ */
373
+ export const AssignmentPackageSchema = z.strictObject({
374
+ assignmentId: IdentifierSchema,
375
+ /** The case revision this assignment is bound to (§8.7, §9.9). */
376
+ caseRevision: z.number().int().positive(),
377
+ /** ISO instant. After this the assignment is gone and a replacement is drawn. */
378
+ expiresAt: TimestampSchema,
379
+ language: LanguageTagSchema.nullable(),
380
+ families: z.array(TaxonomyFamilySchema).max(TAXONOMY_FAMILIES.length),
381
+ allegations: z.strictObject({
382
+ unverified: z.literal(true),
383
+ codes: z.array(TaxonomyCodeSchema),
384
+ }),
385
+ /** §9.1's "applicable policy and its rules", in full and at one version (§6.4). */
386
+ policy: z.strictObject({
387
+ policySetId: PolicySetIdSchema,
388
+ version: PolicyVersionSchema,
389
+ taxonomyVersion: z.string().min(1).max(32),
390
+ rules: z.array(PolicyRuleSchema),
391
+ }),
392
+ /** §9.1's warnings and sensitivity, plus §13.7's blur decision. */
393
+ presentation: z.strictObject({
394
+ sensitivityClass: ReviewerSensitivityClassSchema,
395
+ requiresRedaction: z.boolean(),
396
+ blurBeforeReveal: z.boolean(),
397
+ }),
398
+ resources: z.array(ReviewerResourceSchema),
399
+ /** §5.5 — which resource replies to, quotes or contextualises which. */
400
+ relations: z.array(RelationSchema),
401
+ watermark: z.string().min(1).max(64).nullable(),
402
+ /**
403
+ * §9.8's "contexto adicional" — the author's own words, present ONLY when this
404
+ * assignment belongs to an appeal revision and the author supplied any. Optional
405
+ * rather than nullable so a first-instance panel receives no such key at all: a
406
+ * `null` would let a screen infer "this is an appeal" from the field existing.
407
+ *
408
+ * `unverified` is a constant `true` for the same reason it is on an allegation —
409
+ * this is a claim by an interested party, not a finding, and the flag is what
410
+ * makes a screen say so.
411
+ *
412
+ * What this shape deliberately CANNOT carry is everything else about the appeal.
413
+ * Not the reason code, which is an argument about the verdict and would anchor
414
+ * the panel against §9.1's list. Not the superseded decision, its outcome, its
415
+ * findings or its jury — that is §9.8's blindness rule. Not the threshold this
416
+ * panel is held to, which is a property of the count and not of the material. A
417
+ * reviewer can tell they are looking at a contested case, because somebody is
418
+ * contesting it in their own words; they cannot tell what anybody concluded. The
419
+ * absence of those fields is the enforcement, and `.strict()` is what makes a
420
+ * server that starts sending one a refusal rather than a screen.
421
+ */
422
+ authorContext: z
423
+ .strictObject({
424
+ unverified: z.literal(true),
425
+ statement: z.string().min(1),
426
+ /**
427
+ * The same shapes `AppealAuthorContextSchema` stores, reused rather than
428
+ * restated: `resourceIds` point at material already in the case snapshot,
429
+ * and `fields` is §9.8's "evidencia estructurada" as a flat bag of scalars.
430
+ * Restating them as some reviewer-shaped variant would need a translation
431
+ * step, and a translation step between two declarations of one shape is how
432
+ * the drift this file exists to end got started.
433
+ */
434
+ resourceIds: z.array(ResourceIdSchema).optional(),
435
+ fields: MetadataBagSchema.optional(),
436
+ })
437
+ .optional(),
438
+ });
439
+ export type AssignmentPackage = z.infer<typeof AssignmentPackageSchema>;
440
+
441
+ /**
442
+ * What `POST /v1/reviewer/assignments/next` returns.
443
+ *
444
+ * The token is here and on no other response, because it is handed over exactly
445
+ * once and only its hash is stored (§8.7). Every later call on this assignment
446
+ * presents it in `x-assignment-token`.
447
+ */
448
+ export const IssuedAssignmentPackageSchema = AssignmentPackageSchema.extend({
449
+ token: z.string().min(1).max(128),
450
+ });
451
+ export type IssuedAssignmentPackage = z.infer<typeof IssuedAssignmentPackageSchema>;
452
+
453
+ // --- the reviewer's own profile ----------------------------------------------
454
+
455
+ export const ReviewerEligibilityRequirementSchema = z.strictObject({
456
+ id: ReviewerEligibilityRequirementIdSchema,
457
+ met: z.boolean(),
458
+ });
459
+ export type ReviewerEligibilityRequirement = z.infer<typeof ReviewerEligibilityRequirementSchema>;
460
+
461
+ /**
462
+ * §4.1's "Fiabilidad" — the reviewer's OWN standing in one category.
463
+ *
464
+ * Per category and not per (category, language): reliability is seeded by
465
+ * calibration and moved by gold cases and audits, none of which are measured per
466
+ * language, so a per-language figure would be the same number repeated. §4.1 asks
467
+ * for both axes and this is the honest half of it.
468
+ *
469
+ * It is never anybody else's figure. §9.1 forbids a reviewer seeing another
470
+ * person's reputation, and no shape on this surface could carry one.
471
+ */
472
+ export const ReviewerCategoryStandingSchema = z.strictObject({
473
+ category: TaxonomyFamilySchema,
474
+ reliability: UnitIntervalSchema,
475
+ specialist: z.boolean(),
476
+ });
477
+ export type ReviewerCategoryStanding = z.infer<typeof ReviewerCategoryStandingSchema>;
478
+
479
+ /**
480
+ * §13.7's exposure and rest, as numbers rather than as a verdict.
481
+ *
482
+ * `breakRequiredUntil` is the sensitive-material rest of the backend's
483
+ * `eligibility.ts`: after `SENSITIVE_EXPOSURE_MAX` sensitive cases inside
484
+ * `SENSITIVE_EXPOSURE_WINDOW_HOURS`, the oldest of them leaving the window is
485
+ * when the reviewer may be drawn for sensitive material again. It rests only the
486
+ * SENSITIVE route — somebody who has worked through several distressing cases can
487
+ * still judge a spam report — so it is not a blanket block, and a screen showing
488
+ * it has to say which route it applies to.
489
+ */
490
+ export const ReviewerExposureViewSchema = z.strictObject({
491
+ reviewedToday: z.number().int().nonnegative(),
492
+ dailyLimit: z.number().int().positive(),
493
+ openAssignments: z.number().int().nonnegative(),
494
+ maxOpenAssignments: z.number().int().positive(),
495
+ /** ISO instant, or null when no rest is being enforced. */
496
+ breakRequiredUntil: TimestampSchema.nullable(),
497
+ });
498
+ export type ReviewerExposureView = z.infer<typeof ReviewerExposureViewSchema>;
499
+
500
+ /**
501
+ * §13.7 — consent, per category and revocable at any moment.
502
+ *
503
+ * `maxSensitivity` lives here rather than in preferences because it IS consent:
504
+ * the ceiling of what this person agreed to be shown. Keeping a second copy of
505
+ * `sensitiveCategories` under preferences — which is what the app used to declare
506
+ * — guarantees two fields that can disagree about the same fact.
507
+ */
508
+ export const ReviewerConsentViewSchema = z.strictObject({
509
+ /** ISO instant the reviewer accepted the reviewing rules, or null. */
510
+ rulesAcceptedAt: TimestampSchema.nullable(),
511
+ /** §8.2's age compatibility, as the one bit the routing needs (§13.5). */
512
+ ageConfirmed: z.boolean(),
513
+ maxSensitivity: ReviewerSensitivityClassSchema,
514
+ sensitiveCategories: z.array(TaxonomyFamilySchema),
515
+ });
516
+ export type ReviewerConsentView = z.infer<typeof ReviewerConsentViewSchema>;
517
+
518
+ export const ReviewerPreferencesViewSchema = z.strictObject({
519
+ /** BCP-47 primary subtags the reviewer reads. */
520
+ languages: z.array(z.string().min(2).max(16)),
521
+ categories: z.array(TaxonomyFamilySchema),
522
+ dailyLimit: z.number().int().positive(),
523
+ /** False means this reviewer is in no draw at all — §4.1's immediate exit. */
524
+ availableForAssignment: z.boolean(),
525
+ });
526
+ export type ReviewerPreferencesView = z.infer<typeof ReviewerPreferencesViewSchema>;
527
+
528
+ /**
529
+ * `GET /v1/reviewer/profile` (§10.3: "eligibility, categories and PRIVATE
530
+ * reliability").
531
+ *
532
+ * Private meaning shown to its owner and to nobody else. What the document holds
533
+ * and this does not: `oxyUserId` (§8.7 keeps the identity and the reviewer id
534
+ * apart), `samplingKey` (publishing it would let somebody reason about when they
535
+ * are likely to be considered), `personhoodConfidence` (the threshold is what
536
+ * matters; a bare number invites optimising a figure whose inputs are invisible),
537
+ * and the anti-abuse signals.
538
+ */
539
+ export const ReviewerProfileViewSchema = z.strictObject({
540
+ reviewerId: IdentifierSchema,
541
+ state: ReviewerStateSchema,
542
+ eligibility: z.array(ReviewerEligibilityRequirementSchema),
543
+ standings: z.array(ReviewerCategoryStandingSchema),
544
+ completedReviewCount: z.number().int().nonnegative(),
545
+ preferences: ReviewerPreferencesViewSchema,
546
+ consent: ReviewerConsentViewSchema,
547
+ exposure: ReviewerExposureViewSchema,
548
+ });
549
+ export type ReviewerProfileView = z.infer<typeof ReviewerProfileViewSchema>;
550
+
551
+ /**
552
+ * The body of `POST /v1/reviewer/preferences` (§10.3).
553
+ *
554
+ * This is also §4.1's onboarding submission. There is no
555
+ * `POST /v1/reviewer/onboarding`: §10.3's route table has no such endpoint, and
556
+ * everything the onboarding screen collects — languages, categories, sensitive
557
+ * consent, age — is precisely what §10.3 says this route updates. Rules
558
+ * acceptance is the one thing that had no home, so it is a field here.
559
+ *
560
+ * `.strict()` matters more here than anywhere else on this surface. `state`,
561
+ * `personhoodConfidence`, `reliabilityByCategory` and `completedReviewCount` are
562
+ * the fields that decide who sits on a jury, and a lenient schema plus a
563
+ * spread-based update is exactly how a reviewer would promote themselves. The
564
+ * schema refuses unknown keys and the service takes named arguments, so there are
565
+ * two independent reasons it cannot happen.
566
+ *
567
+ * Every field is optional and at least one must be present: a partial update is
568
+ * the normal case (the wellbeing screen changes one toggle), and an empty body is
569
+ * a mistake worth naming rather than a no-op worth accepting.
570
+ */
571
+ export const ReviewerPreferencesUpdateSchema = z
572
+ .strictObject({
573
+ languages: z.array(z.string().min(2).max(16)).max(20).optional(),
574
+ categories: z.array(TaxonomyFamilySchema).max(TAXONOMY_FAMILIES.length).optional(),
575
+ /** §13.7: acceptance is recorded once and is never revoked to `false` here. */
576
+ rulesAccepted: z.literal(true).optional(),
577
+ isAdult: z.boolean().optional(),
578
+ available: z.boolean().optional(),
579
+ dailyReviewLimit: z.number().int().min(1).max(REVIEWER_DAILY_LIMIT_MAX).optional(),
580
+ maxSensitivity: ReviewerSensitivityClassSchema.optional(),
581
+ consentedSensitiveCategories: z
582
+ .array(TaxonomyFamilySchema)
583
+ .max(TAXONOMY_FAMILIES.length)
584
+ .optional(),
585
+ declaredConflictApplications: z.array(z.string().min(1).max(64)).max(50).optional(),
586
+ principalLinks: z
587
+ .array(
588
+ z.strictObject({
589
+ applicationId: z.string().min(1).max(64),
590
+ externalPrincipalId: z.string().min(1).max(256),
591
+ }),
592
+ )
593
+ .max(50)
594
+ .optional(),
595
+ })
596
+ .refine((value) => Object.keys(value).length > 0, {
597
+ message: 'a preferences update must change at least one field',
598
+ });
599
+ export type ReviewerPreferencesUpdate = z.infer<typeof ReviewerPreferencesUpdateSchema>;
600
+
601
+ // --- training and calibration ------------------------------------------------
602
+
603
+ /** `GET /v1/reviewer/training` (§10.3: "list modules and calibration"). */
604
+ export const ReviewerTrainingViewSchema = z.strictObject({
605
+ modules: z.array(
606
+ z.strictObject({
607
+ moduleId: z.string().min(1).max(64),
608
+ title: z.string().min(1).max(200),
609
+ families: z.array(TaxonomyFamilySchema),
610
+ completed: z.boolean(),
611
+ }),
612
+ ),
613
+ trainingComplete: z.boolean(),
614
+ /**
615
+ * The items to answer: `itemId` and `text`, and nothing else.
616
+ *
617
+ * A calibration that hands back `expectedViolation` or `expectedCode` is one
618
+ * everybody passes on the second attempt, which measures attendance rather than
619
+ * judgement.
620
+ */
621
+ calibrationItems: z.array(
622
+ z.strictObject({ itemId: z.string().min(1).max(64), text: z.string().min(1) }),
623
+ ),
624
+ calibrationOpen: z.boolean(),
625
+ /** ISO instant, or null when never passed. */
626
+ calibrationPassedAt: TimestampSchema.nullable(),
627
+ /** ISO instant after which calibration lapses (§8.2), or null. */
628
+ calibrationCurrentUntil: TimestampSchema.nullable(),
629
+ calibrationScore: UnitIntervalSchema.nullable(),
630
+ calibrationAttempts: z.number().int().nonnegative(),
631
+ /** The score a pass requires, so the app does not restate it. */
632
+ calibrationPassScore: UnitIntervalSchema,
633
+ });
634
+ export type ReviewerTrainingView = z.infer<typeof ReviewerTrainingViewSchema>;
635
+
636
+ /** The body of `POST /v1/reviewer/training/calibration`. */
637
+ export const ReviewerCalibrationSubmissionSchema = z.strictObject({
638
+ answers: z
639
+ .array(
640
+ z.strictObject({
641
+ itemId: z.string().min(1).max(64),
642
+ violation: z.boolean(),
643
+ code: TaxonomyCodeSchema.optional(),
644
+ }),
645
+ )
646
+ .min(1)
647
+ .max(64),
648
+ });
649
+ export type ReviewerCalibrationSubmission = z.infer<typeof ReviewerCalibrationSubmissionSchema>;
650
+
651
+ /**
652
+ * What a graded attempt reports back.
653
+ *
654
+ * The score and which items were wrong, never which answer was right — see
655
+ * `calibrationItems` above.
656
+ */
657
+ export const ReviewerCalibrationResultViewSchema = z.strictObject({
658
+ passed: z.boolean(),
659
+ score: UnitIntervalSchema,
660
+ incorrectItemIds: z.array(z.string().min(1).max(64)),
661
+ state: ReviewerStateSchema,
662
+ });
663
+ export type ReviewerCalibrationResultView = z.infer<typeof ReviewerCalibrationResultViewSchema>;
664
+
665
+ // --- history -----------------------------------------------------------------
666
+
667
+ export const REVIEW_HISTORY_PAGE_SIZE_MAX = 50;
668
+ export const REVIEW_HISTORY_PAGE_SIZE_DEFAULT = 20;
669
+
670
+ /**
671
+ * §4.1's "Historial" — one completed review as its author may see it back.
672
+ *
673
+ * `outcome` is the reviewer's OWN submitted outcome. `decision` is populated only
674
+ * once a decision has been PUBLISHED for the revision this reviewer judged, and
675
+ * carries the outcome and the moment and nothing else — no agreement figure, no
676
+ * jury size, no vote count, no findings. §4.1 asks the history to show "results
677
+ * that may already be revealed"; §9.1 forbids previous votes and partial results.
678
+ * Both hold: a published decision is not partial, and an agreement ratio IS a
679
+ * tally, so it has no field here rather than merely being left unset.
680
+ */
681
+ export const ReviewHistoryEntrySchema = z.strictObject({
682
+ reviewId: IdentifierSchema,
683
+ /** ISO instant. */
684
+ submittedAt: TimestampSchema,
685
+ families: z.array(TaxonomyFamilySchema),
686
+ language: LanguageTagSchema.nullable(),
687
+ outcome: ReviewOutcomeSchema,
688
+ decision: z
689
+ .strictObject({ outcome: DecisionOutcomeSchema, publishedAt: TimestampSchema })
690
+ .nullable(),
691
+ });
692
+ export type ReviewHistoryEntry = z.infer<typeof ReviewHistoryEntrySchema>;
693
+
694
+ /** `GET /v1/reviewer/reviews`. */
695
+ export const ReviewHistoryPageSchema = z.strictObject({
696
+ entries: z.array(ReviewHistoryEntrySchema),
697
+ /** Opaque cursor for the next page, or null when the list is exhausted. */
698
+ nextCursor: z.string().min(1).max(64).nullable(),
699
+ });
700
+ export type ReviewHistoryPage = z.infer<typeof ReviewHistoryPageSchema>;
701
+
702
+ /**
703
+ * The query of `GET /v1/reviewer/reviews`.
704
+ *
705
+ * The cursor is opaque to the client and meaningful to the server, which is what
706
+ * lets the pagination change without a client change. It is not an offset: a
707
+ * reviewer submitting a review between two pages would shift every offset by one
708
+ * and silently skip an entry — in a list whose only purpose is somebody checking
709
+ * their own record.
710
+ */
711
+ export const ReviewHistoryQuerySchema = z.strictObject({
712
+ cursor: z.string().min(1).max(64).optional(),
713
+ limit: z.coerce.number().int().min(1).max(REVIEW_HISTORY_PAGE_SIZE_MAX).optional(),
714
+ });
715
+ export type ReviewHistoryQuery = z.infer<typeof ReviewHistoryQuerySchema>;