@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,216 @@
1
+ /**
2
+ * Decisions (§9.6, §9.9, Appendix B).
3
+ *
4
+ * "A published decision is never edited, only superseded." No parse can see a
5
+ * second write, so schema-level immutability would be theatre — and a shallow
6
+ * `Object.freeze` on the parsed result would be worse than theatre, since it
7
+ * would leave `findings` mutable while looking like a guarantee. Immutability
8
+ * is enforced where it is real: the store never updates a published revision.
9
+ *
10
+ * What the schema CAN enforce is the shape of the supersession chain, and it
11
+ * does: revision 1 supersedes nothing, and every later revision names what it
12
+ * replaced. §9.9's worked example is exactly that, and a revision 2 that
13
+ * supersedes nothing would be an edit wearing a new number.
14
+ *
15
+ * Decisions travel outbound, to tenants and to the reviewer app, so they are
16
+ * `.loose()`: §10.11 requires unknown fields not to break clients, and passing
17
+ * them through rather than stripping them means a client that persists a
18
+ * decision keeps whatever a newer CrowdSource added. This is the opposite of
19
+ * the inbound rule in `case-envelope.ts`, for the opposite reason.
20
+ */
21
+
22
+ import { z } from 'zod';
23
+
24
+ import { CONTRACT_LIMITS, IdentifierSchema, TimestampSchema, UnitIntervalSchema } from './primitives';
25
+ import { DecisionPolicyVersionsSchema, PolicyRuleIdSchema } from './policies';
26
+ import { ResourceIdSchema } from './resources';
27
+ import { ContextSufficiencySchema } from './reviews';
28
+ import {
29
+ FindingAttributionSchema,
30
+ FindingContextSchema,
31
+ FindingScopeSchema,
32
+ RecommendedActionSchema,
33
+ SeveritySchema,
34
+ TaxonomyCodeSchema,
35
+ } from './taxonomy';
36
+
37
+ /** §3.2 decision states. */
38
+ export const DECISION_STATUSES = ['provisional', 'final', 'superseded', 'corrected'] as const;
39
+ export const DecisionStatusSchema = z.enum(DECISION_STATUSES);
40
+ export type DecisionStatus = z.infer<typeof DecisionStatusSchema>;
41
+
42
+ /**
43
+ * §9.6 outcomes.
44
+ *
45
+ * `inconclusive` is its own outcome and must never collapse into
46
+ * `no_violation`: a jury that reviewed the case and did not reach the threshold
47
+ * has said something different from a jury that agreed nothing was wrong. Both
48
+ * are here, distinctly, and no code in this package maps one to the other.
49
+ */
50
+ export const DECISION_OUTCOMES = [
51
+ 'violation',
52
+ 'no_violation',
53
+ 'insufficient_context',
54
+ 'inconclusive',
55
+ 'content_unavailable',
56
+ 'duplicate',
57
+ 'escalated',
58
+ ] as const;
59
+ export const DecisionOutcomeSchema = z.enum(DECISION_OUTCOMES);
60
+ export type DecisionOutcome = z.infer<typeof DecisionOutcomeSchema>;
61
+
62
+ /**
63
+ * A confirmed finding (Appendix B).
64
+ *
65
+ * Carries two fields a `ReviewFinding` does not. `scope` says how far the
66
+ * finding reaches and is what §11.7.5 gates an Oxy Trust effect on;
67
+ * `attribution` names whose conduct it is. Neither belongs on an individual
68
+ * review: a reviewer classifies material, and it is the consensus process that
69
+ * decides the classification is confirmed and therefore attributable.
70
+ *
71
+ * `attribution` is optional because plenty of confirmed findings attribute
72
+ * nothing to anybody — material can violate a rule without any principal having
73
+ * behaved badly.
74
+ *
75
+ * `context` carries §6.2's exception through unchanged from the reviews that
76
+ * agreed on it. It is one of §9.4's six consensus dimensions, so a published
77
+ * finding that dropped it would be a decision nobody could reproduce from the
78
+ * ballots.
79
+ */
80
+ export const DecisionFindingSchema = z.looseObject({
81
+ code: TaxonomyCodeSchema,
82
+ resourceIds: z.array(ResourceIdSchema).min(1).max(CONTRACT_LIMITS.RESOURCE_REFS_PER_FINDING_MAX),
83
+ severity: SeveritySchema,
84
+ context: FindingContextSchema.optional(),
85
+ scope: FindingScopeSchema,
86
+ attribution: FindingAttributionSchema.optional(),
87
+ policyRuleIds: z.array(PolicyRuleIdSchema).max(CONTRACT_LIMITS.POLICY_RULE_IDS_MAX).optional(),
88
+ });
89
+ export type DecisionFinding = z.infer<typeof DecisionFindingSchema>;
90
+
91
+ /**
92
+ * A recommended action bound to what it applies to (Appendix B).
93
+ *
94
+ * §10.7's webhook example writes recommended actions as bare strings, the way
95
+ * §9.3 writes them for a single review. Appendix B — the reference Decision —
96
+ * writes them as objects. The object form wins for decisions: a decision that
97
+ * recommends removal without saying what to remove is not actionable, and an
98
+ * application that acts on it is guessing. The string form survives where the
99
+ * plan uses it, on a review.
100
+ *
101
+ * `targetResourceIds` is optional because some actions have no target —
102
+ * `escalate`, `no_global_effect` and `no_action` are about the case, not about
103
+ * a resource.
104
+ */
105
+ export const DecisionRecommendedActionSchema = z.looseObject({
106
+ action: RecommendedActionSchema,
107
+ targetResourceIds: z
108
+ .array(ResourceIdSchema)
109
+ .max(CONTRACT_LIMITS.RESOURCE_REFS_PER_FINDING_MAX)
110
+ .optional(),
111
+ });
112
+ export type DecisionRecommendedAction = z.infer<typeof DecisionRecommendedActionSchema>;
113
+
114
+ /** Floating point: `winningVotes / decisiveVotes` will not be exact. */
115
+ const AGREEMENT_TOLERANCE = 1e-6;
116
+
117
+ /**
118
+ * The panel that produced the decision (Appendix B).
119
+ *
120
+ * The arithmetic is checked here because it is the auditable trace of "one
121
+ * qualified person, one vote": `agreement` must equal
122
+ * `winningVotes / decisiveVotes` (§9.5), and the counts must nest. If a
123
+ * weighting ever crept into the engine, `agreement` would stop matching the
124
+ * count of people, and this is where that shows up.
125
+ *
126
+ * `size` is not restricted to {3, 5, 7}. Those are §9.4's community panel
127
+ * sizes, but §9.4 also routes critical categories to specialist pools that do
128
+ * not use the standard jury at all, and an appeal panel is only bounded below.
129
+ */
130
+ export const DecisionJurySchema = z
131
+ .looseObject({
132
+ size: z.number().int().positive().max(CONTRACT_LIMITS.JURY_SIZE_MAX),
133
+ /** Reviews that expressed a decisive opinion — the denominator of §9.5. */
134
+ decisiveVotes: z.number().int().positive().max(CONTRACT_LIMITS.JURY_SIZE_MAX),
135
+ winningVotes: z.number().int().nonnegative().max(CONTRACT_LIMITS.JURY_SIZE_MAX),
136
+ agreement: UnitIntervalSchema,
137
+ specialistPresent: z.boolean(),
138
+ })
139
+ .superRefine((jury, ctx) => {
140
+ if (jury.decisiveVotes > jury.size) {
141
+ ctx.addIssue({
142
+ code: 'custom',
143
+ path: ['decisiveVotes'],
144
+ message: 'decisiveVotes cannot exceed the panel size',
145
+ });
146
+ return;
147
+ }
148
+ if (jury.winningVotes > jury.decisiveVotes) {
149
+ ctx.addIssue({
150
+ code: 'custom',
151
+ path: ['winningVotes'],
152
+ message: 'winningVotes cannot exceed decisiveVotes',
153
+ });
154
+ return;
155
+ }
156
+ const expected = jury.winningVotes / jury.decisiveVotes;
157
+ if (Math.abs(jury.agreement - expected) > AGREEMENT_TOLERANCE) {
158
+ ctx.addIssue({
159
+ code: 'custom',
160
+ path: ['agreement'],
161
+ message: `agreement must equal winningVotes / decisiveVotes (${expected})`,
162
+ });
163
+ }
164
+ });
165
+ export type DecisionJury = z.infer<typeof DecisionJurySchema>;
166
+
167
+ /**
168
+ * One immutable revision of a case's outcome (Appendix B).
169
+ *
170
+ * Every field except `supersedesDecisionId` is required, following §12.8, which
171
+ * marks `supersedes_decision_id` as the only nullable column on the decisions
172
+ * table. The DTO uses that column's name; §9.9's prose sketch writes the same
173
+ * link as `supersedes`.
174
+ */
175
+ export const DecisionSchema = z
176
+ .looseObject({
177
+ id: IdentifierSchema,
178
+ caseId: IdentifierSchema,
179
+ revision: z.number().int().positive(),
180
+ status: DecisionStatusSchema,
181
+ outcome: DecisionOutcomeSchema,
182
+ contextSufficiency: ContextSufficiencySchema,
183
+ confidence: UnitIntervalSchema,
184
+ findings: z.array(DecisionFindingSchema).max(CONTRACT_LIMITS.FINDINGS_MAX),
185
+ recommendedActions: z
186
+ .array(DecisionRecommendedActionSchema)
187
+ .max(CONTRACT_LIMITS.RECOMMENDED_ACTIONS_MAX),
188
+ jury: DecisionJurySchema,
189
+ policyVersions: DecisionPolicyVersionsSchema,
190
+ supersedesDecisionId: IdentifierSchema.optional(),
191
+ publishedAt: TimestampSchema,
192
+ })
193
+ .superRefine((decision, ctx) => {
194
+ if (decision.revision === 1 && decision.supersedesDecisionId !== undefined) {
195
+ ctx.addIssue({
196
+ code: 'custom',
197
+ path: ['supersedesDecisionId'],
198
+ message: 'the first revision of a case supersedes nothing',
199
+ });
200
+ }
201
+ if (decision.revision > 1 && decision.supersedesDecisionId === undefined) {
202
+ ctx.addIssue({
203
+ code: 'custom',
204
+ path: ['supersedesDecisionId'],
205
+ message: 'a revision after the first must name the decision it supersedes',
206
+ });
207
+ }
208
+ if (decision.outcome === 'violation' && decision.findings.length === 0) {
209
+ ctx.addIssue({
210
+ code: 'custom',
211
+ path: ['findings'],
212
+ message: 'a violation outcome requires at least one finding',
213
+ });
214
+ }
215
+ });
216
+ export type Decision = z.infer<typeof DecisionSchema>;
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Public entry point for `@oxyhq/crowdsource-contracts`.
3
+ *
4
+ * The contracts every CrowdSource surface agrees on: the backend, the reviewer
5
+ * and console clients, the published SDKs, and third-party integrators. One
6
+ * entry point because the package publishes one `exports` path — this is the
7
+ * package boundary, not a convenience barrel over an internal tree.
8
+ *
9
+ * Two version numbers live here and mean different things. `/v1` is the route
10
+ * prefix and is not this package's business. `CASE_ENVELOPE_SCHEMA_VERSION`
11
+ * travels inside the payload and is validated separately (§10.11). Additive
12
+ * changes bump neither.
13
+ *
14
+ * Where strictness lands, and why, since it is the one thing a reader will want
15
+ * to look up:
16
+ *
17
+ * * **Inbound from a tenant or a reviewer — strict.** The Case Envelope tree,
18
+ * the review submission, the recusal, the policy set, the resource schema
19
+ * registration. A dropped field here is context a jury never sees or an
20
+ * input nobody reviewed, and §10.11 makes the exception explicitly for
21
+ * fields "the schema forbids for safety".
22
+ * * **Outbound to a tenant — loose.** Decisions, webhook envelopes and event
23
+ * payloads pass unknown fields through, so a newer CrowdSource never breaks
24
+ * an older client and a receiver that persists `event.data` keeps all of it.
25
+ * * **Internal, to Oxy Trust — strict.** The reputation event carries no
26
+ * resource ids and no free text on purpose; an unrecognised field is how
27
+ * content reaches a reputation ledger. The `.v1` in the event type is what
28
+ * handles evolution there.
29
+ *
30
+ * Open bags (`metadata`, custom payloads, registered JSON Schemas) are the
31
+ * deliberate exception in both directions: open by definition, but flat or
32
+ * depth-bounded, scalar-typed, key-restricted and free of prototype-bearing
33
+ * names.
34
+ */
35
+
36
+ export * from './primitives';
37
+ export * from './taxonomy';
38
+ export * from './policies';
39
+ export * from './resources';
40
+ export * from './case-envelope';
41
+ export * from './reviews';
42
+ export * from './decisions';
43
+ export * from './webhooks';
44
+ export * from './reputation-events';
45
+ export * from './json-schema';
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The same contracts as JSON Schema, for integrators who are not on TypeScript.
3
+ *
4
+ * §15.2 asks for "Zod and exportable JSON Schema". Conversion is deliberately
5
+ * lazy — a function, not a frozen constant — so importing the package to
6
+ * validate one report does not pay for thirteen schema conversions.
7
+ *
8
+ * **What JSON Schema does NOT carry.** Zod refinements have no JSON Schema
9
+ * equivalent and are dropped silently by the conversion. Everything structural
10
+ * survives — types, enums, patterns, bounds, required keys, `additionalProperties`
11
+ * — and every cross-field and cross-reference rule does not:
12
+ *
13
+ * * §5.5 reference resolution (relations, `primaryResourceId`, allegation
14
+ * resource ids, author and seller refs, conversation members, avatars)
15
+ * * the `oxy_user` binding-proof requirement
16
+ * * exactly-one-of `uploadId`/`url`, media type agreement, coarse coordinates,
17
+ * price-with-currency
18
+ * * `agreement = winningVotes / decisiveVotes` and the vote count nesting
19
+ * * revision 1 supersedes nothing / later revisions must
20
+ * * `externalReportId` matching between request and envelope
21
+ *
22
+ * So a payload that passes the JSON Schema is well-FORMED, not accepted. The
23
+ * server validates with Zod, and that is the authority. This is stated here
24
+ * because an integrator who assumes otherwise will build against a contract
25
+ * that is looser than the one they will meet in production, and will discover
26
+ * the difference as a 422.
27
+ */
28
+
29
+ import { z } from 'zod';
30
+
31
+ import { CaseEnvelopeSchema, CreateReportRequestSchema, CreateReportResponseSchema } from './case-envelope';
32
+ import { DecisionSchema } from './decisions';
33
+ import { PolicySetVersionSchema } from './policies';
34
+ import { ReputationEventSchema } from './reputation-events';
35
+ import { RelationSchema, ResourceSchema, ResourceSchemaRegistrationSchema } from './resources';
36
+ import { RecusalSubmissionSchema, ReviewSubmissionSchema } from './reviews';
37
+ import { KnownWebhookEventSchema, WebhookEventEnvelopeSchema } from './webhooks';
38
+
39
+ /** A JSON Schema document, as produced by the conversion. */
40
+ export type JsonSchemaDocument = z.core.JSONSchema.BaseSchema;
41
+
42
+ export const CONTRACT_JSON_SCHEMA_NAMES = [
43
+ 'case-envelope',
44
+ 'create-report-request',
45
+ 'create-report-response',
46
+ 'resource',
47
+ 'relation',
48
+ 'resource-schema-registration',
49
+ 'policy-set-version',
50
+ 'review-submission',
51
+ 'recusal-submission',
52
+ 'decision',
53
+ 'webhook-event-envelope',
54
+ 'known-webhook-event',
55
+ 'reputation-event',
56
+ ] as const;
57
+
58
+ export type ContractJsonSchemaName = (typeof CONTRACT_JSON_SCHEMA_NAMES)[number];
59
+
60
+ /**
61
+ * The Zod schema behind each published name.
62
+ *
63
+ * Typed as an exhaustive `Record` so adding a name without a schema — or a
64
+ * schema without a name — is a compile error rather than a runtime hole in the
65
+ * exported contract set.
66
+ */
67
+ export const CONTRACT_SCHEMAS: Record<ContractJsonSchemaName, z.ZodType> = {
68
+ 'case-envelope': CaseEnvelopeSchema,
69
+ 'create-report-request': CreateReportRequestSchema,
70
+ 'create-report-response': CreateReportResponseSchema,
71
+ resource: ResourceSchema,
72
+ relation: RelationSchema,
73
+ 'resource-schema-registration': ResourceSchemaRegistrationSchema,
74
+ 'policy-set-version': PolicySetVersionSchema,
75
+ 'review-submission': ReviewSubmissionSchema,
76
+ 'recusal-submission': RecusalSubmissionSchema,
77
+ decision: DecisionSchema,
78
+ 'webhook-event-envelope': WebhookEventEnvelopeSchema,
79
+ 'known-webhook-event': KnownWebhookEventSchema,
80
+ 'reputation-event': ReputationEventSchema,
81
+ };
82
+
83
+ /** JSON Schema draft 2020-12, so `$defs` and `unevaluatedProperties` mean what integrators expect. */
84
+ const JSON_SCHEMA_TARGET = 'draft-2020-12';
85
+
86
+ /** The JSON Schema for one published contract. */
87
+ export function crowdSourceJsonSchema(name: ContractJsonSchemaName): JsonSchemaDocument {
88
+ return z.toJSONSchema(CONTRACT_SCHEMAS[name], { target: JSON_SCHEMA_TARGET });
89
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The application-policy layer (§6.1 layer 2) and policy versioning (§6.4).
3
+ *
4
+ * The taxonomy says what the material contains; a policy set says whether that
5
+ * violates THIS application's rules. The two are versioned independently and
6
+ * every decision records both (plus the Oxy conduct policy version), because
7
+ * §6.4 forbids a policy update from silently changing what a past decision
8
+ * meant.
9
+ *
10
+ * The security boundary of this module is §6.4's last line: "rules must be
11
+ * expressed as data, not as arbitrary code supplied by the tenant". A rule here
12
+ * therefore has an id, a title, the taxonomy codes it responds to, a default
13
+ * severity and recommended actions — and no expression, predicate, script,
14
+ * template or callback field of any kind. That absence is the control. It is
15
+ * enforced by `.strict()`: a policy set carrying an unrecognised key is
16
+ * rejected outright rather than accepted with the key quietly dropped, because
17
+ * "dropped" and "never evaluated" look identical right up until something
18
+ * decides to evaluate it.
19
+ */
20
+
21
+ import { z } from 'zod';
22
+
23
+ import { CONTRACT_LIMITS, LanguageTagSchema, TimestampSchema } from './primitives';
24
+ import { RecommendedActionSchema, SeveritySchema, TaxonomyCodeSchema } from './taxonomy';
25
+
26
+ /** A namespaced policy set id, e.g. `mention.community`. */
27
+ export const PolicySetIdSchema = z
28
+ .string()
29
+ .min(3)
30
+ .max(CONTRACT_LIMITS.IDENTIFIER_MAX_LENGTH)
31
+ .regex(
32
+ /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/,
33
+ 'must be a dotted lowercase namespace, e.g. "mention.community"',
34
+ );
35
+
36
+ /** A rule id within a policy set, e.g. `mention.harassment.2`. */
37
+ export const PolicyRuleIdSchema = z
38
+ .string()
39
+ .min(3)
40
+ .max(CONTRACT_LIMITS.IDENTIFIER_MAX_LENGTH)
41
+ .regex(
42
+ /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/,
43
+ 'must be a dotted lowercase namespace, e.g. "mention.harassment.2"',
44
+ );
45
+
46
+ /**
47
+ * An immutable policy version token, e.g. `2026.07`, `mention.2026.07`,
48
+ * `oxy.2026.1`.
49
+ *
50
+ * Deliberately not semver: the plan's own tokens are calendar-shaped and
51
+ * tenant-prefixed, and a policy version is an opaque label pointing at a frozen
52
+ * document — nothing compares two of them for ordering.
53
+ */
54
+ export const PolicyVersionSchema = z
55
+ .string()
56
+ .min(1)
57
+ .max(64)
58
+ .regex(
59
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/,
60
+ 'must start with a letter or digit and contain only letters, digits, ".", "_" or "-"',
61
+ );
62
+
63
+ /**
64
+ * The version of the Oxy Conduct Policy every decision is stamped with (§6.4).
65
+ *
66
+ * §6.1 assigns the third layer — "should this conduct affect global Oxy trust?"
67
+ * — to the Oxy Conduct Policy, evaluated by the Reputation Bridge. That bridge
68
+ * does not exist yet, and this constant is deliberately NOT a promise that it
69
+ * does. What it is: the label a decision published today records, so that when
70
+ * the bridge arrives it can tell which conduct policy a historical decision was
71
+ * decided under and refuse to re-interpret it under a newer one. §6.4's "a
72
+ * policy update never silently rewrites historical decisions" is unenforceable
73
+ * without a version on every decision from the first one.
74
+ *
75
+ * The value is Appendix B's. It is pinned here, in the package both the
76
+ * decision DTO and the reputation event import, so the two cannot drift into
77
+ * describing the same decision under two different conduct policies.
78
+ */
79
+ export const OXY_CONDUCT_POLICY_VERSION = 'oxy.2026.1';
80
+
81
+ /**
82
+ * The policy an envelope asks to be evaluated under (§5.1 `policy`).
83
+ *
84
+ * `locale` selects the language the policy text is shown to the reviewer in
85
+ * (Appendix A), and is not the language of the reported material — that is
86
+ * `resource.language`.
87
+ */
88
+ export const CasePolicyRefSchema = z.strictObject({
89
+ policySetId: PolicySetIdSchema,
90
+ version: PolicyVersionSchema,
91
+ locale: LanguageTagSchema.optional(),
92
+ });
93
+ export type CasePolicyRef = z.infer<typeof CasePolicyRefSchema>;
94
+
95
+ /**
96
+ * The three policy versions a decision is decided under (§6.4).
97
+ *
98
+ * Field names follow Appendix B (`taxonomy`), which is the reference Decision.
99
+ * §6.4's prose calls the same field `universalTaxonomyVersion` and §11.6's
100
+ * internal event calls it `universal`; see `ReputationPolicyVersionsSchema` for
101
+ * why both spellings survive.
102
+ */
103
+ export const DecisionPolicyVersionsSchema = z.looseObject({
104
+ taxonomy: PolicyVersionSchema,
105
+ application: PolicyVersionSchema,
106
+ oxyConduct: PolicyVersionSchema,
107
+ });
108
+ export type DecisionPolicyVersions = z.infer<typeof DecisionPolicyVersionsSchema>;
109
+
110
+ /**
111
+ * The same three versions as carried by the internal reputation event (§11.6).
112
+ *
113
+ * §11.6 spells the first key `universal` where Appendix B spells it `taxonomy`.
114
+ * Both are reference payloads in the approved plan and both are load-bearing
115
+ * for a different consumer, so each surface keeps the spelling its own
116
+ * reference document uses rather than one of the two documents being quietly
117
+ * rewritten here. Unifying them is a one-line change and worth doing when the
118
+ * event contract is agreed with OxyHQServices — it is called out in the package
119
+ * README as the single naming inconsistency the contract preserves on purpose.
120
+ */
121
+ export const ReputationPolicyVersionsSchema = z.strictObject({
122
+ universal: PolicyVersionSchema,
123
+ application: PolicyVersionSchema,
124
+ oxyConduct: PolicyVersionSchema,
125
+ });
126
+ export type ReputationPolicyVersions = z.infer<typeof ReputationPolicyVersionsSchema>;
127
+
128
+ /** §6.4: a draft may be edited, a published version may not. */
129
+ export const POLICY_SET_STATUSES = ['draft', 'published'] as const;
130
+ export const PolicySetStatusSchema = z.enum(POLICY_SET_STATUSES);
131
+ export type PolicySetStatus = z.infer<typeof PolicySetStatusSchema>;
132
+
133
+ /**
134
+ * One rule inside a policy version — data, never code.
135
+ *
136
+ * `taxonomyCodes` is what binds layer 2 back to layer 1: the rule declares
137
+ * which universal findings it responds to, so a jury classifies once (§9.2 step
138
+ * one) and any number of applications evaluate that same classification against
139
+ * their own rules (§6.2).
140
+ */
141
+ export const PolicyRuleSchema = z.strictObject({
142
+ id: PolicyRuleIdSchema,
143
+ title: z.string().min(1).max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH),
144
+ description: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
145
+ taxonomyCodes: z.array(TaxonomyCodeSchema).min(1).max(CONTRACT_LIMITS.FINDINGS_MAX),
146
+ defaultSeverity: SeveritySchema.optional(),
147
+ recommendedActions: z
148
+ .array(RecommendedActionSchema)
149
+ .max(CONTRACT_LIMITS.RECOMMENDED_ACTIONS_MAX)
150
+ .optional(),
151
+ });
152
+ export type PolicyRule = z.infer<typeof PolicyRuleSchema>;
153
+
154
+ const POLICY_RULES_MAX = 500;
155
+
156
+ /**
157
+ * A policy set at one version (§6.4).
158
+ *
159
+ * The `status`/`publishedAt` pairing is the immutability rule expressed where a
160
+ * schema can express it: a published version must record when it was published,
161
+ * and a draft must not claim to have been. Actual immutability — never
162
+ * rewriting a published version's rules — is a storage rule the registry
163
+ * enforces; no parse can see a second write.
164
+ */
165
+ export const PolicySetVersionSchema = z
166
+ .strictObject({
167
+ policySetId: PolicySetIdSchema,
168
+ version: PolicyVersionSchema,
169
+ status: PolicySetStatusSchema,
170
+ title: z.string().min(1).max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH),
171
+ locale: LanguageTagSchema.optional(),
172
+ publishedAt: TimestampSchema.optional(),
173
+ rules: z.array(PolicyRuleSchema).min(1).max(POLICY_RULES_MAX),
174
+ })
175
+ .superRefine((policySet, ctx) => {
176
+ if (policySet.status === 'published' && policySet.publishedAt === undefined) {
177
+ ctx.addIssue({
178
+ code: 'custom',
179
+ path: ['publishedAt'],
180
+ message: 'a published policy version must record publishedAt',
181
+ });
182
+ }
183
+ if (policySet.status === 'draft' && policySet.publishedAt !== undefined) {
184
+ ctx.addIssue({
185
+ code: 'custom',
186
+ path: ['publishedAt'],
187
+ message: 'a draft policy version must not record publishedAt',
188
+ });
189
+ }
190
+
191
+ const seen = new Set<string>();
192
+ policySet.rules.forEach((rule, index) => {
193
+ if (seen.has(rule.id)) {
194
+ ctx.addIssue({
195
+ code: 'custom',
196
+ path: ['rules', index, 'id'],
197
+ message: `duplicate rule id "${rule.id}"`,
198
+ });
199
+ }
200
+ seen.add(rule.id);
201
+ });
202
+ });
203
+ export type PolicySetVersion = z.infer<typeof PolicySetVersionSchema>;