@oxyhq/crowdsource-contracts 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 (57) hide show
  1. package/README.md +129 -0
  2. package/dist/case-envelope.d.ts +1130 -0
  3. package/dist/case-envelope.d.ts.map +1 -0
  4. package/dist/case-envelope.js +383 -0
  5. package/dist/case-envelope.js.map +1 -0
  6. package/dist/decisions.d.ts +353 -0
  7. package/dist/decisions.d.ts.map +1 -0
  8. package/dist/decisions.js +198 -0
  9. package/dist/decisions.js.map +1 -0
  10. package/dist/index.d.ts +45 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +61 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/json-schema.d.ts +43 -0
  15. package/dist/json-schema.d.ts.map +1 -0
  16. package/dist/json-schema.js +83 -0
  17. package/dist/json-schema.js.map +1 -0
  18. package/dist/policies.d.ts +286 -0
  19. package/dist/policies.d.ts.map +1 -0
  20. package/dist/policies.js +178 -0
  21. package/dist/policies.js.map +1 -0
  22. package/dist/primitives.d.ts +185 -0
  23. package/dist/primitives.d.ts.map +1 -0
  24. package/dist/primitives.js +231 -0
  25. package/dist/primitives.js.map +1 -0
  26. package/dist/reputation-events.d.ts +349 -0
  27. package/dist/reputation-events.d.ts.map +1 -0
  28. package/dist/reputation-events.js +128 -0
  29. package/dist/reputation-events.js.map +1 -0
  30. package/dist/resources.d.ts +484 -0
  31. package/dist/resources.d.ts.map +1 -0
  32. package/dist/resources.js +436 -0
  33. package/dist/resources.js.map +1 -0
  34. package/dist/reviews.d.ts +276 -0
  35. package/dist/reviews.d.ts.map +1 -0
  36. package/dist/reviews.js +144 -0
  37. package/dist/reviews.js.map +1 -0
  38. package/dist/taxonomy.d.ts +266 -0
  39. package/dist/taxonomy.d.ts.map +1 -0
  40. package/dist/taxonomy.js +282 -0
  41. package/dist/taxonomy.js.map +1 -0
  42. package/dist/webhooks.d.ts +604 -0
  43. package/dist/webhooks.d.ts.map +1 -0
  44. package/dist/webhooks.js +192 -0
  45. package/dist/webhooks.js.map +1 -0
  46. package/package.json +56 -0
  47. package/src/case-envelope.ts +433 -0
  48. package/src/decisions.ts +216 -0
  49. package/src/index.ts +45 -0
  50. package/src/json-schema.ts +89 -0
  51. package/src/policies.ts +203 -0
  52. package/src/primitives.ts +283 -0
  53. package/src/reputation-events.ts +144 -0
  54. package/src/resources.ts +489 -0
  55. package/src/reviews.ts +159 -0
  56. package/src/taxonomy.ts +313 -0
  57. package/src/webhooks.ts +215 -0
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Resources and relations — the universal content unit (§5.2–§5.7).
3
+ *
4
+ * "Applications send data, never executable code" (§5.2). Everything in this
5
+ * module follows from that one sentence:
6
+ *
7
+ * * The resource union is CLOSED and discriminated on `type`, and each
8
+ * variant declares exactly which fields it may carry. There is no free-form
9
+ * branch, so there is no place to put a rendering instruction.
10
+ * * Every object is `.strict()`. In the outbound direction an unknown field
11
+ * is harmless forward compatibility (§10.11); inbound it is not. A field
12
+ * that is silently dropped is context an application believes it sent and a
13
+ * jury never sees — which is how a case gets decided on incomplete material
14
+ * — and a field that is silently kept is a rendering input nobody reviewed.
15
+ * §10.11 makes exactly this exception: unknown fields must not break
16
+ * clients "except where the schema forbids them for safety".
17
+ * * A media type must agree with its resource type, so an `image` cannot
18
+ * declare `text/html` and be handed to something that trusts the label.
19
+ * * §5.7 custom payloads are bounded JSON with no `$`-prefixed keys, which
20
+ * removes every JSON Schema reference mechanism at once — `$ref` to a
21
+ * remote document IS the "remote component" §5.7 forbids, and resolving one
22
+ * is an SSRF against CrowdSource itself.
23
+ *
24
+ * What is deliberately NOT here: any filter on the CONTENT of text fields. The
25
+ * material under review is hostile by definition — a harassment report quotes
26
+ * the harassment, a phishing report quotes the phishing link. A lexical
27
+ * blocklist would reject the evidence and protect nothing, because the contract
28
+ * has no field whose value is ever interpreted as markup or code.
29
+ */
30
+
31
+ import { z } from 'zod';
32
+
33
+ import {
34
+ CONTRACT_LIMITS,
35
+ CustomPayloadSchema,
36
+ HttpUrlSchema,
37
+ IdentifierSchema,
38
+ LanguageTagSchema,
39
+ MetadataBagSchema,
40
+ MimeTypeSchema,
41
+ Sha256DigestSchema,
42
+ TimestampSchema,
43
+ } from './primitives';
44
+ import { PolicyVersionSchema } from './policies';
45
+ import { SensitivityHintSchema } from './taxonomy';
46
+
47
+ /** A resource id, unique within one envelope (§5.2 `id`). */
48
+ export const ResourceIdSchema = IdentifierSchema;
49
+ export type ResourceId = z.infer<typeof ResourceIdSchema>;
50
+
51
+ /**
52
+ * A pseudonymous reference to an actor, resolved within one envelope.
53
+ *
54
+ * Never a real identity: §13.5 requires a pseudonymous principal wherever one
55
+ * suffices. `principalBindings` is the only place a ref is tied to anything,
56
+ * and even there it is tied to a binding proof id, not to a name.
57
+ */
58
+ export const PrincipalRefSchema = IdentifierSchema;
59
+ export type PrincipalRef = z.infer<typeof PrincipalRefSchema>;
60
+
61
+ /** §5.2 `role`: why this resource is in the case. */
62
+ export const RESOURCE_ROLES = [
63
+ 'subject',
64
+ 'attachment',
65
+ 'context',
66
+ 'parent',
67
+ 'quoted',
68
+ 'evidence',
69
+ 'metadata',
70
+ 'policy_context',
71
+ ] as const;
72
+ export const ResourceRoleSchema = z.enum(RESOURCE_ROLES);
73
+ export type ResourceRole = z.infer<typeof ResourceRoleSchema>;
74
+
75
+ /** §5.3 standard resource types. */
76
+ export const RESOURCE_TYPES = [
77
+ 'text',
78
+ 'image',
79
+ 'video',
80
+ 'audio',
81
+ 'document',
82
+ 'link',
83
+ 'profile',
84
+ 'conversation',
85
+ 'listing',
86
+ 'location',
87
+ 'metadata',
88
+ 'custom',
89
+ ] as const;
90
+ export const ResourceTypeSchema = z.enum(RESOURCE_TYPES);
91
+ export type ResourceType = z.infer<typeof ResourceTypeSchema>;
92
+
93
+ /** §5.3 text formatting. Never HTML: `markdown_subset` is a subset for a reason. */
94
+ export const TEXT_FORMATTINGS = ['plain', 'markdown_subset'] as const;
95
+ export const TextFormattingSchema = z.enum(TEXT_FORMATTINGS);
96
+ export type TextFormatting = z.infer<typeof TextFormattingSchema>;
97
+
98
+ /**
99
+ * A reference to bytes held outside the envelope (§5.2 `asset`).
100
+ *
101
+ * Exactly one of `uploadId` and `url` — never both, never neither. Two
102
+ * locations for one piece of evidence means two answers to "what exactly did
103
+ * the jury look at", and §5.6 requires the case to pin the exact version that
104
+ * was reported. `sha256` is required for the same reason: it is what makes the
105
+ * answer verifiable after the application has deleted the original.
106
+ */
107
+ export const AssetRefSchema = z
108
+ .strictObject({
109
+ uploadId: IdentifierSchema.optional(),
110
+ url: HttpUrlSchema.optional(),
111
+ mimeType: MimeTypeSchema,
112
+ sha256: Sha256DigestSchema,
113
+ sizeBytes: z.number().int().positive().optional(),
114
+ width: z.number().int().positive().optional(),
115
+ height: z.number().int().positive().optional(),
116
+ durationSeconds: z.number().positive().optional(),
117
+ })
118
+ .superRefine((asset, ctx) => {
119
+ const hasUpload = asset.uploadId !== undefined;
120
+ const hasUrl = asset.url !== undefined;
121
+ if (hasUpload === hasUrl) {
122
+ ctx.addIssue({
123
+ code: 'custom',
124
+ path: ['uploadId'],
125
+ message: 'an asset must reference exactly one of uploadId or url',
126
+ });
127
+ }
128
+ });
129
+ export type AssetRef = z.infer<typeof AssetRefSchema>;
130
+
131
+ const mediaTypeMismatch = (
132
+ ctx: z.RefinementCtx,
133
+ mimeType: string,
134
+ allowedPrefixes: readonly string[],
135
+ ): void => {
136
+ if (!allowedPrefixes.some((prefix) => mimeType.startsWith(prefix))) {
137
+ ctx.addIssue({
138
+ code: 'custom',
139
+ path: ['asset', 'mimeType'],
140
+ message: `must be one of ${allowedPrefixes.join(', ')}* for this resource type`,
141
+ });
142
+ }
143
+ };
144
+
145
+ const resourceBaseShape = {
146
+ id: ResourceIdSchema,
147
+ role: ResourceRoleSchema,
148
+ /** §5.2: BCP 47 "where applicable" — a text of unknown language is real. */
149
+ language: LanguageTagSchema.optional(),
150
+ /** §5.2: the moment of the ORIGINAL object, not of the report. */
151
+ createdAt: TimestampSchema.optional(),
152
+ authorPrincipalRef: PrincipalRefSchema.optional(),
153
+ /** §5.2: a prior classification of exposure, never shown as a verdict. */
154
+ sensitivity: SensitivityHintSchema.optional(),
155
+ };
156
+
157
+ /**
158
+ * `sha256` is required on every resource whose content travels inline, and
159
+ * optional on asset-backed ones where `asset.sha256` carries it instead.
160
+ *
161
+ * That split is not a preference; it is what both reference envelopes do.
162
+ * Appendix A's `res_post` and `res_parent` carry a top-level digest and its
163
+ * `res_image` carries the digest on the asset only. §5.6 asks for "SHA 256 of
164
+ * each resource", so one of the two is always present.
165
+ */
166
+ const inlineResourceShape = { ...resourceBaseShape, sha256: Sha256DigestSchema };
167
+ const assetResourceShape = { ...resourceBaseShape, sha256: Sha256DigestSchema.optional() };
168
+
169
+ const TextResourceSchema = z.strictObject({
170
+ ...inlineResourceShape,
171
+ type: z.literal('text'),
172
+ data: z.strictObject({
173
+ text: z.string().min(1).max(CONTRACT_LIMITS.TEXT_RESOURCE_MAX_LENGTH),
174
+ formatting: TextFormattingSchema.optional(),
175
+ }),
176
+ });
177
+
178
+ const ImageResourceSchema = z
179
+ .strictObject({
180
+ ...assetResourceShape,
181
+ type: z.literal('image'),
182
+ asset: AssetRefSchema,
183
+ })
184
+ .superRefine((resource, ctx) => mediaTypeMismatch(ctx, resource.asset.mimeType, ['image/']));
185
+
186
+ const VideoResourceSchema = z
187
+ .strictObject({
188
+ ...assetResourceShape,
189
+ type: z.literal('video'),
190
+ asset: AssetRefSchema,
191
+ data: z
192
+ .strictObject({
193
+ timeRange: z
194
+ .strictObject({
195
+ startSeconds: z.number().nonnegative(),
196
+ endSeconds: z.number().positive(),
197
+ })
198
+ .refine((range) => range.endSeconds > range.startSeconds, {
199
+ message: 'endSeconds must be greater than startSeconds',
200
+ path: ['endSeconds'],
201
+ })
202
+ .optional(),
203
+ })
204
+ .optional(),
205
+ })
206
+ .superRefine((resource, ctx) => {
207
+ mediaTypeMismatch(ctx, resource.asset.mimeType, ['video/']);
208
+ if (resource.asset.durationSeconds === undefined) {
209
+ ctx.addIssue({
210
+ code: 'custom',
211
+ path: ['asset', 'durationSeconds'],
212
+ message: 'a video asset must declare durationSeconds',
213
+ });
214
+ }
215
+ });
216
+
217
+ const AudioResourceSchema = z
218
+ .strictObject({
219
+ ...assetResourceShape,
220
+ type: z.literal('audio'),
221
+ asset: AssetRefSchema,
222
+ data: z
223
+ .strictObject({
224
+ transcript: z.string().max(CONTRACT_LIMITS.EXTRACTED_TEXT_MAX_LENGTH).optional(),
225
+ })
226
+ .optional(),
227
+ })
228
+ .superRefine((resource, ctx) => {
229
+ mediaTypeMismatch(ctx, resource.asset.mimeType, ['audio/']);
230
+ if (resource.asset.durationSeconds === undefined) {
231
+ ctx.addIssue({
232
+ code: 'custom',
233
+ path: ['asset', 'durationSeconds'],
234
+ message: 'an audio asset must declare durationSeconds',
235
+ });
236
+ }
237
+ });
238
+
239
+ const DocumentResourceSchema = z
240
+ .strictObject({
241
+ ...assetResourceShape,
242
+ type: z.literal('document'),
243
+ asset: AssetRefSchema,
244
+ data: z.strictObject({
245
+ title: z.string().min(1).max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH),
246
+ extractedText: z.string().max(CONTRACT_LIMITS.EXTRACTED_TEXT_MAX_LENGTH).optional(),
247
+ }),
248
+ })
249
+ .superRefine((resource, ctx) =>
250
+ mediaTypeMismatch(ctx, resource.asset.mimeType, ['application/', 'text/']),
251
+ );
252
+
253
+ const LinkResourceSchema = z.strictObject({
254
+ ...inlineResourceShape,
255
+ type: z.literal('link'),
256
+ data: z.strictObject({
257
+ url: HttpUrlSchema,
258
+ title: z.string().max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH).optional(),
259
+ resolvedHost: z
260
+ .string()
261
+ .max(253)
262
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/, 'must be a hostname')
263
+ .optional(),
264
+ snapshot: z.string().max(CONTRACT_LIMITS.EXTRACTED_TEXT_MAX_LENGTH).optional(),
265
+ }),
266
+ });
267
+
268
+ const ProfileResourceSchema = z.strictObject({
269
+ ...inlineResourceShape,
270
+ type: z.literal('profile'),
271
+ data: z.strictObject({
272
+ /**
273
+ * Every field is optional on purpose. A federated or unresolved actor
274
+ * routinely has no display name, and requiring one would push applications
275
+ * into synthesising a name — the opposite of what the profile-identity rule
276
+ * in the ecosystem instructions asks for.
277
+ */
278
+ displayName: z.string().max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH).optional(),
279
+ bio: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
280
+ /** An `image` resource in the same envelope. */
281
+ avatarRef: ResourceIdSchema.optional(),
282
+ claims: MetadataBagSchema.optional(),
283
+ }),
284
+ });
285
+
286
+ const ConversationResourceSchema = z.strictObject({
287
+ ...inlineResourceShape,
288
+ type: z.literal('conversation'),
289
+ data: z.strictObject({
290
+ /**
291
+ * Ordered (§5.3). §13.5 asks for five messages around the incident rather
292
+ * than the whole thread, which is why this is a bounded list of ids the
293
+ * application chose, not a pointer to a conversation.
294
+ */
295
+ messageResourceIds: z
296
+ .array(ResourceIdSchema)
297
+ .min(1)
298
+ .max(CONTRACT_LIMITS.CONVERSATION_MESSAGES_MAX),
299
+ }),
300
+ });
301
+
302
+ const ListingResourceSchema = z
303
+ .strictObject({
304
+ ...inlineResourceShape,
305
+ type: z.literal('listing'),
306
+ data: z.strictObject({
307
+ title: z.string().min(1).max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH),
308
+ description: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
309
+ price: z.number().finite().nonnegative().optional(),
310
+ currency: z
311
+ .string()
312
+ .regex(/^[A-Z]{3}$/, 'must be an ISO 4217 alphabetic code')
313
+ .optional(),
314
+ sellerRef: PrincipalRefSchema.optional(),
315
+ mediaRefs: z.array(ResourceIdSchema).max(CONTRACT_LIMITS.LISTING_MEDIA_REFS_MAX).optional(),
316
+ }),
317
+ })
318
+ .superRefine((resource, ctx) => {
319
+ const hasPrice = resource.data.price !== undefined;
320
+ const hasCurrency = resource.data.currency !== undefined;
321
+ if (hasPrice !== hasCurrency) {
322
+ ctx.addIssue({
323
+ code: 'custom',
324
+ path: ['data', hasPrice ? 'currency' : 'price'],
325
+ message: 'price and currency must travel together',
326
+ });
327
+ }
328
+ });
329
+
330
+ /** Two decimal places ≈ 1.1 km — coarse enough not to locate a person. */
331
+ const COARSE_COORDINATE_DECIMALS = 2;
332
+
333
+ const isCoarse = (value: number): boolean =>
334
+ Number.isInteger(value * 10 ** COARSE_COORDINATE_DECIMALS);
335
+
336
+ const LocationResourceSchema = z
337
+ .strictObject({
338
+ ...inlineResourceShape,
339
+ type: z.literal('location'),
340
+ data: z.strictObject({
341
+ label: z.string().max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH).optional(),
342
+ latitude: z.number().min(-90).max(90).optional(),
343
+ longitude: z.number().min(-180).max(180).optional(),
344
+ }),
345
+ })
346
+ .superRefine((resource, ctx) => {
347
+ const { label, latitude, longitude } = resource.data;
348
+ if (label === undefined && latitude === undefined && longitude === undefined) {
349
+ ctx.addIssue({
350
+ code: 'custom',
351
+ path: ['data'],
352
+ message: 'a location must carry a label, coordinates, or both',
353
+ });
354
+ }
355
+ if ((latitude === undefined) !== (longitude === undefined)) {
356
+ ctx.addIssue({
357
+ code: 'custom',
358
+ path: ['data', latitude === undefined ? 'latitude' : 'longitude'],
359
+ message: 'latitude and longitude must travel together',
360
+ });
361
+ }
362
+ /**
363
+ * §5.3 says "coarse location or place label" and §13.5 requires precise
364
+ * location to be redacted before community review. Precision is a property
365
+ * of the number itself, so the contract can refuse the precise value rather
366
+ * than trusting every downstream renderer to round it.
367
+ */
368
+ if (latitude !== undefined && !isCoarse(latitude)) {
369
+ ctx.addIssue({
370
+ code: 'custom',
371
+ path: ['data', 'latitude'],
372
+ message: `must be coarse: at most ${COARSE_COORDINATE_DECIMALS} decimal places`,
373
+ });
374
+ }
375
+ if (longitude !== undefined && !isCoarse(longitude)) {
376
+ ctx.addIssue({
377
+ code: 'custom',
378
+ path: ['data', 'longitude'],
379
+ message: `must be coarse: at most ${COARSE_COORDINATE_DECIMALS} decimal places`,
380
+ });
381
+ }
382
+ });
383
+
384
+ const MetadataResourceSchema = z.strictObject({
385
+ ...inlineResourceShape,
386
+ type: z.literal('metadata'),
387
+ data: MetadataBagSchema.refine((bag) => Object.keys(bag).length >= 1, {
388
+ message: 'a metadata resource must carry at least one field',
389
+ }),
390
+ });
391
+
392
+ const CustomResourceSchema = z.strictObject({
393
+ ...inlineResourceShape,
394
+ type: z.literal('custom'),
395
+ /** The schema the application registered for this shape (§5.7). */
396
+ schemaId: IdentifierSchema,
397
+ /**
398
+ * Validated twice: structurally here, then against the registered JSON Schema
399
+ * at ingress. The registered schema decides what the fields MEAN; this decides
400
+ * that they are fields at all.
401
+ */
402
+ payload: CustomPayloadSchema,
403
+ });
404
+
405
+ /**
406
+ * One resource (§5.2).
407
+ *
408
+ * A discriminated union rather than one object with everything optional: the
409
+ * latter would accept an `image` with inline text and no asset, which is not a
410
+ * resource anybody can review.
411
+ */
412
+ export const ResourceSchema = z.discriminatedUnion('type', [
413
+ TextResourceSchema,
414
+ ImageResourceSchema,
415
+ VideoResourceSchema,
416
+ AudioResourceSchema,
417
+ DocumentResourceSchema,
418
+ LinkResourceSchema,
419
+ ProfileResourceSchema,
420
+ ConversationResourceSchema,
421
+ ListingResourceSchema,
422
+ LocationResourceSchema,
423
+ MetadataResourceSchema,
424
+ CustomResourceSchema,
425
+ ]);
426
+ export type Resource = z.infer<typeof ResourceSchema>;
427
+
428
+ /** §5.5 relation types. */
429
+ export const RELATION_TYPES = [
430
+ 'has_attachment',
431
+ 'replies_to',
432
+ 'quotes',
433
+ 'authored_by',
434
+ 'contextualizes',
435
+ 'previous_version',
436
+ 'contains',
437
+ 'refers_to',
438
+ ] as const;
439
+ export const RelationTypeSchema = z.enum(RELATION_TYPES);
440
+ export type RelationType = z.infer<typeof RelationTypeSchema>;
441
+
442
+ /**
443
+ * Relation types whose `to` names a principal rather than a resource.
444
+ *
445
+ * §5.5 defines `authored_by` as "a resource was created by a principal", so its
446
+ * target is a `principalRef`. Every other relation in the table joins two
447
+ * resources. No example in the plan exercises `authored_by`, so this reading is
448
+ * recorded here rather than assumed: it is what makes §5.5's "the backend must
449
+ * validate that every referenced id exists in the envelope" checkable at all,
450
+ * since the two id spaces are separate.
451
+ */
452
+ export const PRINCIPAL_TARGETED_RELATION_TYPES = ['authored_by'] as const;
453
+
454
+ /**
455
+ * A relation (§5.5).
456
+ *
457
+ * `from` and `to` are only shape-checked here. Whether they RESOLVE is decided
458
+ * by `CaseEnvelopeSchema`, which is the smallest scope that can see both the
459
+ * resource list and the principal list.
460
+ */
461
+ export const RelationSchema = z.strictObject({
462
+ from: ResourceIdSchema,
463
+ type: RelationTypeSchema,
464
+ to: IdentifierSchema,
465
+ });
466
+ export type Relation = z.infer<typeof RelationSchema>;
467
+
468
+ /**
469
+ * A custom resource schema an application registers (§5.7).
470
+ *
471
+ * `jsonSchema` reuses the custom-payload grammar, which forbids `$`-prefixed
472
+ * keys. That single restriction removes `$ref`, `$dynamicRef`, `$recursiveRef`
473
+ * and `$id` in one move — every mechanism by which a registered schema could
474
+ * point at a document CrowdSource would then have to fetch. §5.7's "no remote
475
+ * components" is not a rendering rule alone; a remote `$ref` is also an SSRF
476
+ * with a tenant holding the pen.
477
+ *
478
+ * The cost is `$defs`, `$schema` and `$comment`, which flat custom-field
479
+ * schemas do not need. Recursive tenant schemas are not supported and are not
480
+ * meant to be.
481
+ */
482
+ export const ResourceSchemaRegistrationSchema = z.strictObject({
483
+ schemaId: IdentifierSchema,
484
+ version: PolicyVersionSchema,
485
+ title: z.string().min(1).max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH),
486
+ description: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
487
+ jsonSchema: CustomPayloadSchema,
488
+ });
489
+ export type ResourceSchemaRegistration = z.infer<typeof ResourceSchemaRegistrationSchema>;
package/src/reviews.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * What one reviewer submits (§9.2, §9.3) and how they step away (§4.1).
3
+ *
4
+ * §9.2 splits the form in two — describe the material, then evaluate it against
5
+ * a rule — to reduce anchoring on the reporter's category and to let the same
6
+ * description be reused under different policies. §9.3 then gives one flat
7
+ * result object. Both are true at once: a `ReviewFinding` carries the step-one
8
+ * fields (`code`, `resourceIds`, `severity`, `confidence`) and the step-two
9
+ * fields (`policyRuleIds`), and the enclosing submission carries the step-two
10
+ * verdict (`outcome`, `recommendedActions`). The split is a property of the
11
+ * interface, not of the payload, so it is documented here rather than nested.
12
+ *
13
+ * The submission is `.strict()`, and that is a safety decision, not tidiness.
14
+ * A review belongs to an assignment the server issued; the assignment id comes
15
+ * from the route, the case and the reviewer come from the assignment. If this
16
+ * object tolerated a `caseId`, an `assignmentId` or a `reviewerId`, the day
17
+ * somebody read one of them would be the day a reviewer could vote on a case
18
+ * they were never drawn for — against "nobody chooses the case they review".
19
+ * Strict makes that field impossible rather than merely unused.
20
+ */
21
+
22
+ import { z } from 'zod';
23
+
24
+ import { CONTRACT_LIMITS, UnitIntervalSchema } from './primitives';
25
+ import { PolicyRuleIdSchema } from './policies';
26
+ import { ResourceIdSchema } from './resources';
27
+ import {
28
+ FindingContextSchema,
29
+ RecommendedActionSchema,
30
+ SeveritySchema,
31
+ TaxonomyCodeSchema,
32
+ } from './taxonomy';
33
+
34
+ /**
35
+ * What a single reviewer can conclude.
36
+ *
37
+ * Narrower than §9.6's decision outcomes on purpose. `inconclusive` is what the
38
+ * consensus engine reports when a panel does not agree — a single reviewer
39
+ * cannot fail to agree with themselves, and "the absence of consensus is
40
+ * neither guilt nor innocence" only holds if `inconclusive` is produced by the
41
+ * engine and never voted for. `duplicate`, `escalated` and `superseded` are
42
+ * likewise case-level states, not opinions about material. The plan never
43
+ * enumerates review outcomes; this is that gap resolved toward the invariant.
44
+ */
45
+ export const REVIEW_OUTCOMES = [
46
+ 'violation',
47
+ 'no_violation',
48
+ 'insufficient_context',
49
+ 'content_unavailable',
50
+ ] as const;
51
+ export const ReviewOutcomeSchema = z.enum(REVIEW_OUTCOMES);
52
+ export type ReviewOutcome = z.infer<typeof ReviewOutcomeSchema>;
53
+
54
+ /** §9.5 feeds this into `contextFactor`: 1.0 if sufficient, 0.5 otherwise. */
55
+ export const CONTEXT_SUFFICIENCIES = ['sufficient', 'insufficient'] as const;
56
+ export const ContextSufficiencySchema = z.enum(CONTEXT_SUFFICIENCIES);
57
+ export type ContextSufficiency = z.infer<typeof ContextSufficiencySchema>;
58
+
59
+ /**
60
+ * One structured finding from one reviewer (§9.3).
61
+ *
62
+ * `resourceIds` is required and non-empty: §9.4 makes the affected resource one
63
+ * of the dimensions consensus is measured on, so a finding that does not say
64
+ * what it is about cannot be agreed with or disagreed with.
65
+ *
66
+ * `policyRuleIds` is optional: step one of the form can complete without step
67
+ * two — a reviewer may classify material accurately and find no rule that
68
+ * covers it, which is exactly the `no_violation`-with-findings case §6.2
69
+ * describes.
70
+ *
71
+ * `context` is §9.2's and §9.4's "excepción", written as §6.2 writes it: beside
72
+ * the code and the severity, because "artistic nudity" is a different
73
+ * description of the material from "nudity" rather than a different verdict
74
+ * about it. Optional, and absence means no exception applies.
75
+ *
76
+ * `confidence` communicates quality and triggers escalation (§9.5). It never
77
+ * weights the vote.
78
+ */
79
+ export const ReviewFindingSchema = z.strictObject({
80
+ code: TaxonomyCodeSchema,
81
+ resourceIds: z.array(ResourceIdSchema).min(1).max(CONTRACT_LIMITS.RESOURCE_REFS_PER_FINDING_MAX),
82
+ severity: SeveritySchema,
83
+ context: FindingContextSchema.optional(),
84
+ confidence: UnitIntervalSchema,
85
+ policyRuleIds: z.array(PolicyRuleIdSchema).max(CONTRACT_LIMITS.POLICY_RULE_IDS_MAX).optional(),
86
+ });
87
+ export type ReviewFinding = z.infer<typeof ReviewFindingSchema>;
88
+
89
+ /**
90
+ * The body of `POST /v1/reviewer/assignments/{id}/reviews` — §9.3's "result of
91
+ * a review".
92
+ *
93
+ * `recommendedActions` is a list of action tokens here, where a decision's
94
+ * `recommendedActions` are objects that name their target resources. That
95
+ * difference is in the plan (§9.3 versus Appendix B) and is kept: a reviewer
96
+ * recommends a course of action, and the consensus process is what binds an
97
+ * agreed recommendation to the specific resources it applies to.
98
+ */
99
+ export const ReviewSubmissionSchema = z
100
+ .strictObject({
101
+ outcome: ReviewOutcomeSchema,
102
+ contextSufficiency: ContextSufficiencySchema,
103
+ findings: z.array(ReviewFindingSchema).max(CONTRACT_LIMITS.FINDINGS_MAX),
104
+ recommendedActions: z
105
+ .array(RecommendedActionSchema)
106
+ .max(CONTRACT_LIMITS.RECOMMENDED_ACTIONS_MAX),
107
+ /**
108
+ * §13.5 and the "sensitive content never reaches logs" invariant apply to
109
+ * this field as much as to the evidence: a note is free text a human wrote
110
+ * while looking at the material, so it is bounded here and must be treated
111
+ * as case content everywhere downstream — never logged, never attested.
112
+ */
113
+ notes: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
114
+ })
115
+ .superRefine((review, ctx) => {
116
+ if (review.outcome === 'insufficient_context' && review.contextSufficiency !== 'insufficient') {
117
+ ctx.addIssue({
118
+ code: 'custom',
119
+ path: ['contextSufficiency'],
120
+ message: 'an outcome of insufficient_context requires contextSufficiency "insufficient"',
121
+ });
122
+ }
123
+ if (review.outcome === 'violation' && review.findings.length === 0) {
124
+ ctx.addIssue({
125
+ code: 'custom',
126
+ path: ['findings'],
127
+ message: 'a violation outcome requires at least one finding',
128
+ });
129
+ }
130
+ });
131
+ export type ReviewSubmission = z.infer<typeof ReviewSubmissionSchema>;
132
+
133
+ /**
134
+ * §4.1: a reviewer may declare a conflict, a language they do not have,
135
+ * material too sensitive for them, or context they cannot obtain — "without
136
+ * penalty". These four are the plan's own list, made structured as §10.3 asks.
137
+ */
138
+ export const RECUSAL_REASONS = [
139
+ 'conflict_of_interest',
140
+ 'language',
141
+ 'too_sensitive',
142
+ 'insufficient_context',
143
+ ] as const;
144
+ export const RecusalReasonSchema = z.enum(RECUSAL_REASONS);
145
+ export type RecusalReason = z.infer<typeof RecusalReasonSchema>;
146
+
147
+ /**
148
+ * The body of `POST /v1/reviewer/assignments/{id}/recuse`.
149
+ *
150
+ * Strict for the same reason as the submission, and with no free-text field
151
+ * beyond a short optional note: a recusal is routing information, and the more
152
+ * it can say about the case, the more it becomes a channel for case content to
153
+ * leak into operational data.
154
+ */
155
+ export const RecusalSubmissionSchema = z.strictObject({
156
+ reason: RecusalReasonSchema,
157
+ note: z.string().max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH).optional(),
158
+ });
159
+ export type RecusalSubmission = z.infer<typeof RecusalSubmissionSchema>;