@reventlessdev/reventless-spec 3.0.0-alpha.100

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 (139) hide show
  1. package/CHANGELOG.md +931 -0
  2. package/LICENSE +202 -0
  3. package/README.md +109 -0
  4. package/package.json +49 -0
  5. package/rescript.json +32 -0
  6. package/run-generator.mjs +2 -0
  7. package/run-platform-generator.mjs +2 -0
  8. package/scripts/generate-currency.mjs +215 -0
  9. package/scripts/iso-4217-list-one.xml +1956 -0
  10. package/src/AnsiStyle.res +40 -0
  11. package/src/AnsiStyle.res.mjs +54 -0
  12. package/src/LogPrefix.res +192 -0
  13. package/src/LogPrefix.res.mjs +159 -0
  14. package/src/PackageVersion.res +67 -0
  15. package/src/PackageVersion.res.mjs +81 -0
  16. package/src/components/Aggregate.res +64 -0
  17. package/src/components/Aggregate.res.mjs +2 -0
  18. package/src/components/AutomationSlice.res +279 -0
  19. package/src/components/AutomationSlice.res.mjs +30 -0
  20. package/src/components/CapabilityManifest.res +74 -0
  21. package/src/components/CapabilityManifest.res.mjs +61 -0
  22. package/src/components/ComponentKind.res +99 -0
  23. package/src/components/ComponentKind.res.mjs +125 -0
  24. package/src/components/Counter.res +24 -0
  25. package/src/components/Counter.res.mjs +2 -0
  26. package/src/components/DcbDecode.res +118 -0
  27. package/src/components/DcbDecode.res.mjs +100 -0
  28. package/src/components/DcbScopeInference.res +244 -0
  29. package/src/components/DcbScopeInference.res.mjs +177 -0
  30. package/src/components/DcbTag.res +1335 -0
  31. package/src/components/DcbTag.res.mjs +898 -0
  32. package/src/components/DcbValidation.res +427 -0
  33. package/src/components/DcbValidation.res.mjs +423 -0
  34. package/src/components/DisplayName.res +40 -0
  35. package/src/components/DisplayName.res.mjs +26 -0
  36. package/src/components/ExtensionPoint.res +27 -0
  37. package/src/components/ExtensionPoint.res.mjs +2 -0
  38. package/src/components/InboundTranslationSlice.res +85 -0
  39. package/src/components/InboundTranslationSlice.res.mjs +2 -0
  40. package/src/components/OutboundTranslationSlice.res +153 -0
  41. package/src/components/OutboundTranslationSlice.res.mjs +2 -0
  42. package/src/components/Plugin.res +538 -0
  43. package/src/components/Plugin.res.mjs +264 -0
  44. package/src/components/PluginName.res +39 -0
  45. package/src/components/PluginName.res.mjs +45 -0
  46. package/src/components/ReadModel.res +199 -0
  47. package/src/components/ReadModel.res.mjs +18 -0
  48. package/src/components/Reference.res +55 -0
  49. package/src/components/Reference.res.mjs +50 -0
  50. package/src/components/Snapshot.res +26 -0
  51. package/src/components/Snapshot.res.mjs +2 -0
  52. package/src/components/StateAnnotations.res +97 -0
  53. package/src/components/StateAnnotations.res.mjs +15 -0
  54. package/src/components/StateChangeSlice.res +131 -0
  55. package/src/components/StateChangeSlice.res.mjs +2 -0
  56. package/src/components/StateViewSlice.res +123 -0
  57. package/src/components/StateViewSlice.res.mjs +2 -0
  58. package/src/components/Task.res +62 -0
  59. package/src/components/Task.res.mjs +2 -0
  60. package/src/generator/Codegen.res +842 -0
  61. package/src/generator/Codegen.res.mjs +565 -0
  62. package/src/generator/Config.res +106 -0
  63. package/src/generator/Config.res.mjs +69 -0
  64. package/src/generator/Discovery.res +230 -0
  65. package/src/generator/Discovery.res.mjs +198 -0
  66. package/src/generator/Generator_Node.res +14 -0
  67. package/src/generator/Generator_Node.res.mjs +18 -0
  68. package/src/generator/Pairing.res +460 -0
  69. package/src/generator/Pairing.res.mjs +415 -0
  70. package/src/generator/PlatformCodegen.res +207 -0
  71. package/src/generator/PlatformCodegen.res.mjs +154 -0
  72. package/src/generator/PlatformGenerator.res +126 -0
  73. package/src/generator/PlatformGenerator.res.mjs +114 -0
  74. package/src/generator/PlatformManifests.res +203 -0
  75. package/src/generator/PlatformManifests.res.mjs +212 -0
  76. package/src/generator/PluginGenerator.res +57 -0
  77. package/src/generator/PluginGenerator.res.mjs +73 -0
  78. package/src/semantic/Bytes.res +54 -0
  79. package/src/semantic/Bytes.res.mjs +38 -0
  80. package/src/semantic/Capabilities.res +43 -0
  81. package/src/semantic/Capabilities.res.mjs +17 -0
  82. package/src/semantic/Color.res +51 -0
  83. package/src/semantic/Color.res.mjs +29 -0
  84. package/src/semantic/Currency.res +598 -0
  85. package/src/semantic/Currency.res.mjs +743 -0
  86. package/src/semantic/DateRange.res +148 -0
  87. package/src/semantic/DateRange.res.mjs +74 -0
  88. package/src/semantic/Duration.res +53 -0
  89. package/src/semantic/Duration.res.mjs +26 -0
  90. package/src/semantic/Email.res +51 -0
  91. package/src/semantic/Email.res.mjs +31 -0
  92. package/src/semantic/GeoPoint.res +226 -0
  93. package/src/semantic/GeoPoint.res.mjs +190 -0
  94. package/src/semantic/Geocoding.res +127 -0
  95. package/src/semantic/Geocoding.res.mjs +36 -0
  96. package/src/semantic/Money.res +196 -0
  97. package/src/semantic/Money.res.mjs +138 -0
  98. package/src/semantic/Offload.res +294 -0
  99. package/src/semantic/Offload.res.mjs +191 -0
  100. package/src/semantic/Percent.res +53 -0
  101. package/src/semantic/Percent.res.mjs +33 -0
  102. package/src/semantic/Phone.res +55 -0
  103. package/src/semantic/Phone.res.mjs +29 -0
  104. package/src/semantic/Semantic.res +162 -0
  105. package/src/semantic/Semantic.res.mjs +95 -0
  106. package/src/semantic/StorageRef.res +164 -0
  107. package/src/semantic/StorageRef.res.mjs +111 -0
  108. package/src/semantic/Url.res +66 -0
  109. package/src/semantic/Url.res.mjs +48 -0
  110. package/src/types/Authorization.res +23 -0
  111. package/src/types/Authorization.res.mjs +33 -0
  112. package/src/types/Behavior.res +86 -0
  113. package/src/types/Behavior.res.mjs +2 -0
  114. package/src/types/DateTime.res +29 -0
  115. package/src/types/DateTime.res.mjs +16 -0
  116. package/src/types/EventMapping.res +100 -0
  117. package/src/types/EventMapping.res.mjs +15 -0
  118. package/src/types/Handler.res +30 -0
  119. package/src/types/Handler.res.mjs +2 -0
  120. package/src/types/Id.res +75 -0
  121. package/src/types/Id.res.mjs +37 -0
  122. package/src/types/Identity.res +46 -0
  123. package/src/types/Identity.res.mjs +51 -0
  124. package/src/types/Message.res +326 -0
  125. package/src/types/Message.res.mjs +186 -0
  126. package/src/types/Projection.res +220 -0
  127. package/src/types/Projection.res.mjs +44 -0
  128. package/src/types/QueryEngine.res +123 -0
  129. package/src/types/QueryEngine.res.mjs +12 -0
  130. package/src/types/ReadConsistency.res +38 -0
  131. package/src/types/ReadConsistency.res.mjs +29 -0
  132. package/src/types/Schedule.res +65 -0
  133. package/src/types/Schedule.res.mjs +68 -0
  134. package/src/types/SideEffect.res +45 -0
  135. package/src/types/SideEffect.res.mjs +2 -0
  136. package/src/types/StoredEvent.res +46 -0
  137. package/src/types/StoredEvent.res.mjs +32 -0
  138. package/src/types/Visibility.res +24 -0
  139. package/src/types/Visibility.res.mjs +25 -0
@@ -0,0 +1,427 @@
1
+ type validationError = {
2
+ sliceName: string,
3
+ message: string,
4
+ }
5
+
6
+ type variantInfo = {
7
+ tagName: string,
8
+ fields: dict<S.t<unknown>>,
9
+ taggedFields: array<string>,
10
+ }
11
+
12
+ // Extract variant info (TAG name, fields, tagged fields) from a single variant schema
13
+ let extractVariantInfo = (variantSchema: S.t<unknown>): option<variantInfo> =>
14
+ switch variantSchema {
15
+ | Object({items, properties}) =>
16
+ DcbTag.variantTagName(items)->Option.map(tagName => {
17
+ let taggedFields =
18
+ properties
19
+ ->Dict.toArray
20
+ ->Array.filterMap(((fieldName, fieldSchema)) =>
21
+ if DcbTag.isTagged(fieldSchema) {
22
+ Some(fieldName)
23
+ } else {
24
+ None
25
+ }
26
+ )
27
+ {tagName, fields: properties, taggedFields}
28
+ })
29
+ | _ => None
30
+ }
31
+
32
+ // Extract all variant infos from a schema (handles Union and single Object)
33
+ let extractAllVariants = (schema: S.t<unknown>): array<variantInfo> =>
34
+ switch schema {
35
+ | Union({anyOf}) => anyOf->Array.filterMap(extractVariantInfo)
36
+ | Object(_) =>
37
+ switch extractVariantInfo(schema) {
38
+ | Some(info) => [info]
39
+ | None => []
40
+ }
41
+ | String({const: ?Some(name)}) =>
42
+ // Single payload-less variant — sury compiles `type t = Foo` to String({const: "Foo"})
43
+ [{tagName: name, fields: Dict.make(), taggedFields: []}]
44
+ | _ => []
45
+ }
46
+
47
+ // Check if a variant has no payload fields (only TAG)
48
+ let isPayloadLess = (info: variantInfo): bool => info.fields->Dict.toArray->Array.length == 0
49
+
50
+ // Compare two sury schema types for structural compatibility. Objects and
51
+ // unions recurse: previously any (Object, Object) / (Union, Union) pair was
52
+ // declared compatible, so nested payload drift (a renamed/retyped nested field,
53
+ // a changed variant) passed validation undetected.
54
+ let rec schemasAreCompatible = (a: S.t<unknown>, b: S.t<unknown>): bool =>
55
+ switch (a, b) {
56
+ | (String(_), String(_)) => true
57
+ | (Number(_), Number(_)) => true
58
+ | (Boolean(_), Boolean(_)) => true
59
+ | (BigInt(_), BigInt(_)) => true
60
+ | (Array({additionalItems: Schema(aItem)}), Array({additionalItems: Schema(bItem)})) =>
61
+ schemasAreCompatible(aItem, bItem)
62
+ | (Object({properties: aProps}), Object({properties: bProps})) => propsCompatible(aProps, bProps)
63
+ | (Union(_), Union(_)) =>
64
+ // Same set of variant tags, and each shared variant's payload compatible.
65
+ let av = extractAllVariants(a)
66
+ let bv = extractAllVariants(b)
67
+ let at = av->Array.map(v => v.tagName)->Array.toSorted(String.compare)
68
+ let bt = bv->Array.map(v => v.tagName)->Array.toSorted(String.compare)
69
+ at == bt &&
70
+ at->Array.every(tag =>
71
+ switch (av->Array.find(v => v.tagName == tag), bv->Array.find(v => v.tagName == tag)) {
72
+ | (Some(x), Some(y)) => propsCompatible(x.fields, y.fields)
73
+ | _ => false
74
+ }
75
+ )
76
+ | _ => false
77
+ }
78
+ // Two property maps are compatible when they carry the same field names and each
79
+ // field's schema is compatible.
80
+ and propsCompatible = (aProps: dict<S.t<unknown>>, bProps: dict<S.t<unknown>>): bool => {
81
+ let aKeys = aProps->Dict.keysToArray->Array.toSorted(String.compare)
82
+ let bKeys = bProps->Dict.keysToArray->Array.toSorted(String.compare)
83
+ aKeys == bKeys &&
84
+ aKeys->Array.every(k =>
85
+ switch (aProps->Dict.get(k), bProps->Dict.get(k)) {
86
+ | (Some(av), Some(bv)) => schemasAreCompatible(av, bv)
87
+ | _ => false
88
+ }
89
+ )
90
+ }
91
+
92
+ // Get a human-readable type name for a schema
93
+ let schemaTypeName = (schema: S.t<unknown>): string =>
94
+ switch schema {
95
+ | String(_) => "string"
96
+ | Number(_) => "float"
97
+ | Boolean(_) => "bool"
98
+ | BigInt(_) => "bigint"
99
+ | Array(_) => "array"
100
+ | Object(_) => "object"
101
+ | Union(_) => "union"
102
+ | Never(_) => "never"
103
+ | Unknown(_) => "unknown"
104
+ | Ref(_) => "ref"
105
+ | _ => "unknown"
106
+ }
107
+
108
+ type producerEntry = {
109
+ sliceName: string,
110
+ variant: variantInfo,
111
+ }
112
+
113
+ let dedupeKeys = (keys: array<string>): array<string> => {
114
+ let seen = Set.make()
115
+ keys->Array.filter(k =>
116
+ if seen->Set.has(k) {
117
+ false
118
+ } else {
119
+ seen->Set.add(k)
120
+ true
121
+ }
122
+ )
123
+ }
124
+
125
+ /**
126
+ Warns when a slice issues a **composite** (multi-tag) decision read for an event
127
+ type whose *produced* tag set is a strict superset of the query's tags.
128
+
129
+ The `tag_composite` GSI key is built from **all** of an event's tags, so a
130
+ composite query matches only events tagged *exactly* its query set. An event that
131
+ also carries an extra tag is therefore silently missed by the composite read,
132
+ while its per-tag fences still move (Issue 5). This commonly bites when a tag is
133
+ later added to a multi-tag event without updating the reading slice.
134
+
135
+ Only all-scalar multi-tag commands build composite reads: a command carrying a
136
+ tagged array reads via per-element OR (single-tag) clauses, which cannot hit the
137
+ exact-match trap. Returns one `validationError` (warning) per offending
138
+ (slice, consumed type) pair.
139
+
140
+ @param slices `(sliceName, commandSchema, consumedEventSchema)` per consumer.
141
+ @param producedTagKeys event type → its produced tag-key set (from the event-log schema).
142
+ */
143
+ let validateCompositeReads = (
144
+ ~slices: array<(string, S.t<unknown>, S.t<unknown>)>,
145
+ ~producedTagKeys: dict<array<string>>,
146
+ ): array<validationError> => {
147
+ let warnings: array<validationError> = []
148
+ slices->Array.forEach(((sliceName, commandSchema, consumedSchema)) =>
149
+ if !DcbTag.hasTaggedArrayFields(commandSchema) {
150
+ let commandTagKeysByVariant = DcbTag.extractTagKeysByEventType(commandSchema)
151
+ let consumedTypes = DcbTag.extractVariantNames(consumedSchema)
152
+ commandTagKeysByVariant
153
+ ->Dict.valuesToArray
154
+ ->Array.forEach(cmdKeys => {
155
+ let querySet = dedupeKeys(cmdKeys)
156
+ if querySet->Array.length >= 2 {
157
+ consumedTypes->Array.forEach(consumedType =>
158
+ switch producedTagKeys->Dict.get(consumedType) {
159
+ | None => ()
160
+ | Some(producedKeys) =>
161
+ let producedSet = dedupeKeys(producedKeys)
162
+ let isSuperset = querySet->Array.every(k => producedSet->Array.includes(k))
163
+ if isSuperset && producedSet->Array.length > querySet->Array.length {
164
+ let extra = producedSet->Array.filter(k => !(querySet->Array.includes(k)))
165
+ let _ = warnings->Array.push({
166
+ sliceName,
167
+ message: `composite read on tags [${querySet->Array.join(
168
+ ", ",
169
+ )}] will silently miss '${consumedType}', which also carries [${extra->Array.join(
170
+ ", ",
171
+ )}] — the tag_composite key includes ALL of an event's tags, so a composite query matches only events tagged exactly [${querySet->Array.join(
172
+ ", ",
173
+ )}]. Align the event's tag set with the query, or read '${consumedType}' via a single-tag clause.`,
174
+ })
175
+ }
176
+ }
177
+ )
178
+ }
179
+ })
180
+ }
181
+ )
182
+ warnings
183
+ }
184
+
185
+ let validateProducedAndConsumed = (
186
+ ~produced: array<(string, S.t<unknown>)>,
187
+ ~consumed: array<(string, S.t<unknown>)>,
188
+ ): result<unit, array<validationError>> => {
189
+ let errors: array<validationError> = []
190
+
191
+ // Build a map of TAG name -> array of (sliceName, variantInfo) for produced events
192
+ let producerMap: dict<array<producerEntry>> = Dict.make()
193
+ produced->Array.forEach(((sliceName, schema)) => {
194
+ let variants = extractAllVariants(schema)
195
+ variants->Array.forEach(variant => {
196
+ let existing = producerMap->Dict.get(variant.tagName)->Option.getOr([])
197
+ producerMap->Dict.set(variant.tagName, existing->Array.concat([{sliceName, variant}]))
198
+ })
199
+ })
200
+
201
+ // Rule 1: Payload equivalence across producers
202
+ // All producers of the same TAG must have identical fields, types, and tag annotations
203
+ producerMap->Dict.toArray->Array.forEach(((tagName, entries)) => {
204
+ if entries->Array.length > 1 {
205
+ let first = entries->Array.getUnsafe(0)
206
+ let firstFields = first.variant.fields->Dict.keysToArray->Array.toSorted(String.compare)
207
+ let firstTagged = first.variant.taggedFields->Array.toSorted(String.compare)
208
+
209
+ entries->Array.forEachWithIndex((entry, idx) => {
210
+ if idx > 0 {
211
+ let entryFields = entry.variant.fields->Dict.keysToArray->Array.toSorted(String.compare)
212
+ let entryTagged = entry.variant.taggedFields->Array.toSorted(String.compare)
213
+
214
+ // Check field names match
215
+ if firstFields->Array.length != entryFields->Array.length ||
216
+ firstFields->Array.some(f => !(entryFields->Array.includes(f))) {
217
+ let missingInEntry =
218
+ firstFields->Array.filter(f => !(entryFields->Array.includes(f)))
219
+ let extraInEntry =
220
+ entryFields->Array.filter(f => !(firstFields->Array.includes(f)))
221
+ let parts = []
222
+ if missingInEntry->Array.length > 0 {
223
+ let _ =
224
+ parts->Array.push(
225
+ `'${first.sliceName}' has fields [${missingInEntry->Array.join(", ")}] which '${entry.sliceName}' is missing`,
226
+ )
227
+ }
228
+ if extraInEntry->Array.length > 0 {
229
+ let _ =
230
+ parts->Array.push(
231
+ `'${entry.sliceName}' has fields [${extraInEntry->Array.join(", ")}] which '${first.sliceName}' is missing`,
232
+ )
233
+ }
234
+ let _ =
235
+ errors->Array.push({
236
+ sliceName: entry.sliceName,
237
+ message: `Producers '${first.sliceName}' and '${entry.sliceName}' both produce '${tagName}' but with different fields: ${parts->Array.join("; ")}`,
238
+ })
239
+ } else {
240
+ // Fields match by name — check types are compatible
241
+ firstFields->Array.forEach(fieldName => {
242
+ let firstSchema = first.variant.fields->Dict.getUnsafe(fieldName)
243
+ let entrySchema = entry.variant.fields->Dict.getUnsafe(fieldName)
244
+ if !schemasAreCompatible(firstSchema, entrySchema) {
245
+ let _ =
246
+ errors->Array.push({
247
+ sliceName: entry.sliceName,
248
+ message: `Producers '${first.sliceName}' and '${entry.sliceName}' both produce '${tagName}' but field '${fieldName}' has type '${schemaTypeName(firstSchema)}' in '${first.sliceName}' and '${schemaTypeName(entrySchema)}' in '${entry.sliceName}'`,
249
+ })
250
+ }
251
+ })
252
+ }
253
+
254
+ // Check tag annotations match
255
+ if firstTagged->Array.length != entryTagged->Array.length ||
256
+ firstTagged->Array.some(f => !(entryTagged->Array.includes(f))) {
257
+ let untaggedInEntry =
258
+ firstTagged->Array.filter(f => !(entryTagged->Array.includes(f)))
259
+ let untaggedInFirst =
260
+ entryTagged->Array.filter(f => !(firstTagged->Array.includes(f)))
261
+ let parts = []
262
+ if untaggedInEntry->Array.length > 0 {
263
+ let _ =
264
+ parts->Array.push(
265
+ `[${untaggedInEntry->Array.join(", ")}] tagged in '${first.sliceName}' but not in '${entry.sliceName}'`,
266
+ )
267
+ }
268
+ if untaggedInFirst->Array.length > 0 {
269
+ let _ =
270
+ parts->Array.push(
271
+ `[${untaggedInFirst->Array.join(", ")}] tagged in '${entry.sliceName}' but not in '${first.sliceName}'`,
272
+ )
273
+ }
274
+ let _ =
275
+ errors->Array.push({
276
+ sliceName: entry.sliceName,
277
+ message: `Producers '${first.sliceName}' and '${entry.sliceName}' both produce '${tagName}' but tag annotations differ: ${parts->Array.join("; ")}`,
278
+ })
279
+ }
280
+ }
281
+ })
282
+ }
283
+ })
284
+
285
+ // Rule 2: Every consumed event has a producer
286
+ // Rule 3: Consumed fields exist in produced shape (payload-less always valid)
287
+ // Rule 4: Type compatibility
288
+ consumed->Array.forEach(((sliceName, schema)) => {
289
+ let variants = extractAllVariants(schema)
290
+ variants->Array.forEach(variant => {
291
+ switch producerMap->Dict.get(variant.tagName) {
292
+ | None =>
293
+ let _ =
294
+ errors->Array.push({
295
+ sliceName,
296
+ message: `Slice '${sliceName}' consumes '${variant.tagName}' but no slice produces it`,
297
+ })
298
+ | Some(producers) =>
299
+ // Use first producer as the authoritative shape (Rule 1 ensures they're all identical)
300
+ let producer = producers->Array.getUnsafe(0)
301
+
302
+ // Skip field checks for payload-less consumed variants
303
+ if !isPayloadLess(variant) {
304
+ variant.fields->Dict.toArray->Array.forEach(((fieldName, consumedFieldSchema)) => {
305
+ switch producer.variant.fields->Dict.get(fieldName) {
306
+ | None =>
307
+ let _ =
308
+ errors->Array.push({
309
+ sliceName,
310
+ message: `Slice '${sliceName}' consumes '${variant.tagName}.${fieldName}' (${schemaTypeName(consumedFieldSchema)}) but produced '${variant.tagName}' has no field '${fieldName}'`,
311
+ })
312
+ | Some(producedFieldSchema) =>
313
+ if !schemasAreCompatible(consumedFieldSchema, producedFieldSchema) {
314
+ let _ =
315
+ errors->Array.push({
316
+ sliceName,
317
+ message: `Slice '${sliceName}' consumes '${variant.tagName}.${fieldName}' as ${schemaTypeName(consumedFieldSchema)} but producer declares it as ${schemaTypeName(producedFieldSchema)}`,
318
+ })
319
+ }
320
+ }
321
+ })
322
+ }
323
+ }
324
+ })
325
+ })
326
+
327
+ if errors->Array.length > 0 {
328
+ Error(errors)
329
+ } else {
330
+ Ok()
331
+ }
332
+ }
333
+
334
+ /**
335
+ Validates that DCB tag *scope* (`PartitionScoped` default vs `@crossPartition`)
336
+ agrees across every produced event type that carries a given tag key.
337
+
338
+ The scope is a property of the tag *key*: read routing and — decisively — the
339
+ consistency fence are driven by one global per-key flag (the fence is bumped by
340
+ *writers*, so a key cannot be cross-partition for one producer and
341
+ partition-scoped for another without making the fence ambiguous). This returns
342
+ one warning per producer that carries a key some *other* producer declared
343
+ `@crossPartition` but this one left partition-scoped.
344
+
345
+ @param producers `(sliceName, producedEventSchema)` per producing slice.
346
+ */
347
+ let validateCrossPartitionScope = (
348
+ ~producers: array<(string, S.t<unknown>)>,
349
+ ): array<validationError> => {
350
+ let perProducer = producers->Array.map(((name, schema)) => {
351
+ let cpSet = Set.make()
352
+ DcbTag.extractCrossPartitionTagKeys(schema)->Array.forEach(k => cpSet->Set.add(k))
353
+ let carried = Set.make()
354
+ DcbTag.extractTagKeysByEventType(schema)
355
+ ->Dict.valuesToArray
356
+ ->Array.forEach(ks => ks->Array.forEach(k => carried->Set.add(k)))
357
+ (name, cpSet, carried)
358
+ })
359
+
360
+ let globalCp = Set.make()
361
+ perProducer->Array.forEach(((_, cpSet, _)) =>
362
+ cpSet->Set.values->Array.fromIterator->Array.forEach(k => globalCp->Set.add(k))
363
+ )
364
+
365
+ let warnings: array<validationError> = []
366
+ perProducer->Array.forEach(((name, cpSet, carried)) =>
367
+ carried
368
+ ->Set.values
369
+ ->Array.fromIterator
370
+ ->Array.toSorted((a, b) => String.compare(a, b))
371
+ ->Array.forEach(k =>
372
+ if globalCp->Set.has(k) && !(cpSet->Set.has(k)) {
373
+ let _ = warnings->Array.push({
374
+ sliceName: name,
375
+ message: `tag '${k}' is declared @crossPartition by another producer but is partition-scoped here — DCB tag scope must agree across every event type that carries the tag (read-scope and fence-scope follow one global per-key flag). Add @crossPartition to '${k}' here, or remove it from the other producer.`,
376
+ })
377
+ }
378
+ )
379
+ )
380
+ warnings
381
+ }
382
+
383
+ /** Two-bucket result of validating annotations against the inferred scope. */
384
+ type scopeInferenceIssues = {
385
+ /** Annotations that conflict with inference — likely bugs (warn/error). */
386
+ contradictions: array<validationError>,
387
+ /** Annotations inference already derives — safe to delete (info). */
388
+ redundancies: array<validationError>,
389
+ }
390
+
391
+ /**
392
+ Validates explicit `@crossPartition` annotations against the *inferred* scope,
393
+ now that inference drives the decision-query wiring. Two cases per annotated key:
394
+
395
+ - **Contradiction** — the key is marked `@crossPartition` on a slice that inference
396
+ resolves as that slice's *own partition*. A slice's own identity is never a
397
+ cross-partition read; the annotation is wrong and (were it to drive the wiring)
398
+ would fan the partition into a spurious secondary read. Surface loudly.
399
+ - **Redundant** — the key is marked `@crossPartition` and inference *also* derives
400
+ it as cross-partition from the slice graph. Harmless, but the annotation can be
401
+ dropped — that is the whole point of inference.
402
+
403
+ @param annotations `(sliceName, @crossPartition keys on the slice's produced event)`.
404
+ */
405
+ let validateScopeVsInference = (
406
+ ~annotations: array<(string, array<string>)>,
407
+ ~inferred: DcbScopeInference.derived,
408
+ ): scopeInferenceIssues => {
409
+ let contradictions: array<validationError> = []
410
+ let redundancies: array<validationError> = []
411
+ annotations->Array.forEach(((name, cpKeys)) =>
412
+ cpKeys->Array.forEach(k =>
413
+ if inferred.partitionBySlice->Dict.get(name) == Some(k) {
414
+ let _ = contradictions->Array.push({
415
+ sliceName: name,
416
+ message: `tag '${k}' is marked @crossPartition but inference resolves it as this slice's own partition key — a slice's own identity is never a cross-partition read. Remove the annotation.`,
417
+ })
418
+ } else if inferred.crossPartitionTagKeys->Array.includes(k) {
419
+ let _ = redundancies->Array.push({
420
+ sliceName: name,
421
+ message: `tag '${k}' is marked @crossPartition but the framework already infers it as a cross-partition read from the slice graph — the annotation is redundant and can be removed.`,
422
+ })
423
+ }
424
+ )
425
+ )
426
+ {contradictions, redundancies}
427
+ }