@reventlessdev/reventless-spec 3.0.0-alpha.61 → 3.0.0-alpha.62

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.
package/CHANGELOG.md CHANGED
@@ -3,12 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
- # 3.0.0-alpha.61 (2026-06-23)
6
+ # 3.0.0-alpha.62 (2026-06-29)
7
+
8
+ ### Features
9
+
10
+ * external-system boxes for translation slices (Event Graph data) ([3f8ad39](https://github.com/ReventlessDev/reventless-core/commit/3f8ad39b78a3cb1182d59a0e1fb203b7dcb7379b))
7
11
 
8
- **Note:** Version bump only for package @reventlessdev/reventless-spec
9
12
 
13
+ # 3.0.0-alpha.61 (2026-06-27)
10
14
 
15
+ ### Bug Fixes
16
+
17
+ * **dcb:** harden scope inference against real catalog (partitionHint + rule 3) ([acea3f8](https://github.com/ReventlessDev/reventless-core/commit/acea3f8bb0e96a0993d81fd1aa521e9456982a13))
18
+ * **dcb:** only infer cross-partition for SCALAR foreign references ([57416bf](https://github.com/ReventlessDev/reventless-core/commit/57416bf150df4c801577a60bf72f69abe9c701a8))
19
+ ### Features
11
20
 
21
+ * **dcb:** add tag-scope inference core + runtime diff logging (Phase 1) ([5e17560](https://github.com/ReventlessDev/reventless-core/commit/5e17560fefc4272deb8b501dcb8ecef11c3a7c23))
22
+ * **dcb:** thread inferred tag scope into the decision-query wiring (Phase 2) ([63445b2](https://github.com/ReventlessDev/reventless-core/commit/63445b239bc368932b043872ca16b6c35f723566))
23
+ * **dcb:** validate [@cross](https://github.com/cross)Partition annotations against inferred scope ([acbb387](https://github.com/ReventlessDev/reventless-core/commit/acbb3870d32bbd4ef7e61ed52795800b87660e93))
12
24
 
13
25
 
14
26
  # 3.0.0-alpha.60 (2026-06-22)
package/package.json CHANGED
@@ -1,16 +1,11 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.61",
3
+ "version": "3.0.0-alpha.62",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
7
7
  "generate-plugin": "./run-generator.mjs"
8
8
  },
9
- "scripts": {
10
- "build": "rescript build",
11
- "start": "rescript start",
12
- "clean": "rescript clean"
13
- },
14
9
  "dependencies": {
15
10
  "jsonschema2graphql": "1.1.1",
16
11
  "sury": "11.0.0-alpha.4",
@@ -30,5 +25,9 @@
30
25
  "url": "git+https://github.com/ReventlessDev/reventless-core.git",
31
26
  "directory": "reventless/reventless-spec"
32
27
  },
33
- "gitHead": "c85024f7b9724d4215d34ecc712a14b43eac289d"
34
- }
28
+ "scripts": {
29
+ "build": "rescript build",
30
+ "start": "rescript start",
31
+ "clean": "rescript clean"
32
+ }
33
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ DCB tag-scope inference (Phase 1 — pure core).
3
+
4
+ Derives DCB tag scope from the *global* slice graph instead of hand-placed
5
+ `@partitionTag` / `@crossPartition` / `@noTag` annotations. This module is
6
+ deliberately **schema-agnostic**: its input carries only the structural shape of
7
+ each slice (variant names + `*Id`-shaped fields), never an `S.t` schema and never
8
+ a tag-metadata flag. That is precisely what we are replacing — so both the runtime
9
+ (building shapes from `S.t` schemas, via `DcbTag.sliceShapeFromSchemas`) and the
10
+ VS Code tooling (building shapes from parsed `.res` source) can feed the same
11
+ `infer`. See `docs/plans/dcb-tag-scope-inference.md` § "Phase 1 design".
12
+
13
+ The three rules (over the representation):
14
+
15
+ 1. **Owner / partition.** A slice's partition key is the key its *own* emitted
16
+ events are identified by — computed as `producedKeys(S)` minus the keys `S`
17
+ reads from a *foreign* producer (a consumed arm whose event type is produced by
18
+ a different slice). For `AddProduct` (`ProductAdded({productId, categoryId})`,
19
+ consuming `CategoryAdded({categoryId})`) the foreign-read `categoryId` is
20
+ removed, leaving `productId`. No fixpoint needed.
21
+
22
+ 2. **Cross-partition.** A key is cross-partition iff some slice reads it on a
23
+ *foreign* consumed event while partitioned by something else *and* the key is
24
+ another entity's partition. `AddProduct` reads `categoryId` (Category's
25
+ partition) while partitioned by `productId` ⇒ `categoryId` is cross-partition.
26
+
27
+ 3. **Index vs payload.** A `*Id` on an *emitted* event is indexed iff it is the
28
+ producing slice's own partition key. Foreign reference keys (e.g. `categoryId`
29
+ on `ProductAdded`) are payload ⇒ not indexed ⇒ the sibling-leak GSI write never
30
+ happens.
31
+ */
32
+
33
+ /** A `*Id` / `*Ids`-shaped field, identified by name only (no schema, no tag flag). */
34
+ type idField = {name: string, isList: bool}
35
+
36
+ /** One variant arm: its constructor name and the `*Id` fields it carries. */
37
+ type eventShape = {eventType: string, idFields: array<idField>}
38
+
39
+ /**
40
+ The structural shape of one slice, the boundary type both adapters produce:
41
+ - `command` — the `*Id` fields on the slice's command,
42
+ - `consumed` — the arms (and their `*Id` fields) the slice reads,
43
+ - `produced` — the arms (and their `*Id` fields) the slice writes,
44
+ - `partitionHint` — an explicit `@partitionTag` escape hatch (when the dev marked
45
+ the partition because the slice's own events legitimately carry two owned keys,
46
+ e.g. `RecordProductDemand`). Overrides the inferred partition.
47
+ */
48
+ type sliceShape = {
49
+ sliceName: string,
50
+ command: array<idField>,
51
+ consumed: array<eventShape>,
52
+ produced: array<eventShape>,
53
+ partitionHint: option<string>,
54
+ }
55
+
56
+ /** Derived scope for one tag key. */
57
+ type scope = Partition | CrossPartition | Payload
58
+
59
+ /**
60
+ The full derivation. `crossPartitionTagKeys` and `tagKeysByEventType` are the two
61
+ values the runtime threads today (via `DcbTag.extractCrossPartitionTagKeys` /
62
+ `mergeTagKeysByEventType`); under inference they are produced here.
63
+ */
64
+ type derived = {
65
+ /** sliceName -> its inferred partition key (absent when ambiguous). */
66
+ partitionBySlice: dict<string>,
67
+ /** tag key -> the name of a slice that owns it as its partition. */
68
+ ownerByKey: dict<string>,
69
+ /** keys read cross-partition by some slice (sorted, deduped). */
70
+ crossPartitionTagKeys: array<string>,
71
+ /** produced eventType -> its indexed (non-payload) tag keys (sorted). */
72
+ tagKeysByEventType: dict<array<string>>,
73
+ /** (sliceName, reason) for slices whose partition couldn't be inferred. */
74
+ ambiguities: array<(string, string)>,
75
+ }
76
+
77
+ /**
78
+ The tag key for a `*Id`-shaped field. A plural `*Ids: array<string>` shares the
79
+ singular producer's key (trailing `s` stripped — `productIds` -> `productId`);
80
+ a scalar `*Id` uses the field name verbatim. Mirrors the PPX's `*Ids` rule.
81
+ */
82
+ let tagKeyOf = (f: idField): string =>
83
+ if f.isList && f.name->String.endsWith("s") {
84
+ f.name->String.slice(~start=0, ~end=f.name->String.length - 1)
85
+ } else {
86
+ f.name
87
+ }
88
+
89
+ let dedupSorted = (keys: array<string>): array<string> => {
90
+ let seen = Set.make()
91
+ keys->Array.forEach(k => seen->Set.add(k))
92
+ Array.fromIterator(seen->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
93
+ }
94
+
95
+ let keysOfEvent = (e: eventShape): array<string> => e.idFields->Array.map(tagKeyOf)
96
+
97
+ let producedKeys = (s: sliceShape): array<string> =>
98
+ dedupSorted(s.produced->Array.flatMap(keysOfEvent))
99
+
100
+ let consumedKeys = (s: sliceShape): array<string> =>
101
+ dedupSorted(s.consumed->Array.flatMap(keysOfEvent))
102
+
103
+ /**
104
+ Keys the command carries as a **scalar** (`*Id: string`, not `*Ids: array<string>`).
105
+ A scalar tag is AND-ed with the partition into one composite clause unless it is
106
+ read cross-partition, so only a scalar foreign reference needs the cross-partition
107
+ fan. A foreign key the command carries *only* as an array already fans per element
108
+ and stays partition-scoped (it reads the foreign entity's own partition) — the
109
+ `PlaceOrder`/`productIds` shape — so it is **not** cross-partition.
110
+ */
111
+ let commandScalarKeys = (s: sliceShape): array<string> =>
112
+ dedupSorted(s.command->Array.filter(f => !f.isList)->Array.map(tagKeyOf))
113
+
114
+ /**
115
+ Infers DCB tag scope for a set of slices (one DCB consistency boundary / plugin).
116
+ Pure and total — never throws; unresolvable partitions land in `ambiguities`.
117
+ */
118
+ /**
119
+ The keys a slice reads from a *foreign* event — a consumed arm whose event type
120
+ the slice does **not** itself produce. These are the candidate cross-entity
121
+ references; they cannot be the slice's own partition. Defined on a single shape
122
+ so it works both globally (in `infer`) and per-slice (in the GWT harness, which
123
+ sees only one slice).
124
+ */
125
+ let foreignConsumedKeys = (s: sliceShape): array<string> => {
126
+ let ownProduced = Set.make()
127
+ s.produced->Array.forEach(e => ownProduced->Set.add(e.eventType))
128
+ dedupSorted(
129
+ s.consumed->Array.flatMap(e => ownProduced->Set.has(e.eventType) ? [] : e->keysOfEvent),
130
+ )
131
+ }
132
+
133
+ /**
134
+ Per-slice cross-partition keys for the test harness, which has no global owner
135
+ map: a foreign-read key that is not the slice's own partition is read across
136
+ partitions. Matches `infer`'s global rule 2 for the common "reference another
137
+ entity" case; the harness unions this with any explicit `@crossPartition`
138
+ annotation so capacity/escape-hatch reads remain covered.
139
+ */
140
+ let crossPartitionForSlice = (s: sliceShape): array<string> => {
141
+ let foreign = foreignConsumedKeys(s)
142
+ let scalar = commandScalarKeys(s)
143
+ let partition = switch s.partitionHint {
144
+ | Some(h) if producedKeys(s)->Array.includes(h) => Some(h)
145
+ | _ =>
146
+ switch producedKeys(s)->Array.filter(k => !(foreign->Array.includes(k))) {
147
+ | [single] => Some(single)
148
+ | _ => None
149
+ }
150
+ }
151
+ // A foreign read is cross-partition only when the command carries the key as a
152
+ // scalar (must be fanned); an array-only foreign key auto-fans partition-scoped.
153
+ foreign->Array.filter(k => Some(k) != partition && scalar->Array.includes(k))
154
+ }
155
+
156
+ let infer = (slices: array<sliceShape>): derived => {
157
+ // Rule 1 — partition(S) = producedKeys(S) \ foreignConsumedKeys(S), where a
158
+ // foreign-consumed key rides a consumed arm the slice does not itself produce.
159
+ // An explicit @partitionTag hint overrides the derivation (escape hatch for
160
+ // slices whose own events legitimately carry two owned keys).
161
+ let partitionBySlice = Dict.make()
162
+ let ambiguities = []
163
+ slices->Array.forEach(s => {
164
+ let produced = producedKeys(s)
165
+ switch s.partitionHint {
166
+ | Some(h) if produced->Array.includes(h) => partitionBySlice->Dict.set(s.sliceName, h)
167
+ | _ =>
168
+ let foreign = foreignConsumedKeys(s)
169
+ switch produced->Array.filter(k => !(foreign->Array.includes(k))) {
170
+ | [single] => partitionBySlice->Dict.set(s.sliceName, single)
171
+ | [] =>
172
+ let _ = ambiguities->Array.push((
173
+ s.sliceName,
174
+ "no own partition key — every produced *Id is read from a foreign producer (pure join?); add an explicit @partitionTag",
175
+ ))
176
+ | many =>
177
+ let _ = ambiguities->Array.push((
178
+ s.sliceName,
179
+ `multiple candidate partition keys (${many->Array.join(", ")}) — add an explicit @partitionTag`,
180
+ ))
181
+ }
182
+ }
183
+ })
184
+
185
+ // owner map + the set of keys that are *some* entity's partition.
186
+ let ownerByKey = Dict.make()
187
+ let ownedPartitionKeys = Set.make()
188
+ slices->Array.forEach(s =>
189
+ switch partitionBySlice->Dict.get(s.sliceName) {
190
+ | Some(k) =>
191
+ ownedPartitionKeys->Set.add(k)
192
+ switch ownerByKey->Dict.get(k) {
193
+ | Some(_) => ()
194
+ | None => ownerByKey->Dict.set(k, s.sliceName)
195
+ }
196
+ | None => ()
197
+ }
198
+ )
199
+
200
+ // Rule 2 — a key read on a foreign consumed event that is another entity's
201
+ // partition (and not this slice's own partition) is cross-partition — but only
202
+ // when the command carries it as a SCALAR. A foreign key the command carries
203
+ // only as an array auto-fans per element and stays partition-scoped (it reads
204
+ // the foreign entity's own partition), so it is not cross-partition.
205
+ let crossKeys = Set.make()
206
+ slices->Array.forEach(s => {
207
+ let own = partitionBySlice->Dict.get(s.sliceName)
208
+ let scalar = commandScalarKeys(s)
209
+ s->consumedKeys->Array.forEach(k =>
210
+ if ownedPartitionKeys->Set.has(k) && Some(k) != own && scalar->Array.includes(k) {
211
+ crossKeys->Set.add(k)
212
+ }
213
+ )
214
+ })
215
+ let crossPartitionTagKeys =
216
+ Array.fromIterator(crossKeys->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
217
+
218
+ // Rule 3 — a produced key is indexed iff it is the producing slice's own
219
+ // partition OR some slice issues a decision read of *that event type* by it
220
+ // (the key appears on a consumed arm naming the event type). Foreign reference
221
+ // keys that nobody reads this event type by are payload ⇒ no GSI write ⇒ the
222
+ // sibling leak is impossible. The read-by-anybody arm is what keeps a composite
223
+ // own-stream read (e.g. ProductDemandRecorded read by orderId) — and an M:N
224
+ // capacity read — correctly indexed without a hand annotation.
225
+ let readKeysByEventType = Dict.make()
226
+ slices->Array.forEach(s =>
227
+ s.consumed->Array.forEach(e => {
228
+ let prev = readKeysByEventType->Dict.get(e.eventType)->Option.getOr([])
229
+ readKeysByEventType->Dict.set(e.eventType, prev->Array.concat(e->keysOfEvent))
230
+ })
231
+ )
232
+ let tagKeysByEventType = Dict.make()
233
+ slices->Array.forEach(s => {
234
+ let own = partitionBySlice->Dict.get(s.sliceName)
235
+ s.produced->Array.forEach(e => {
236
+ let readKeys = readKeysByEventType->Dict.get(e.eventType)->Option.getOr([])
237
+ let indexed =
238
+ e->keysOfEvent->Array.filter(k => Some(k) == own || readKeys->Array.includes(k))
239
+ tagKeysByEventType->Dict.set(e.eventType, dedupSorted(indexed))
240
+ })
241
+ })
242
+
243
+ {partitionBySlice, ownerByKey, crossPartitionTagKeys, tagKeysByEventType, ambiguities}
244
+ }
@@ -0,0 +1,177 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
5
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
6
+
7
+ function tagKeyOf(f) {
8
+ if (f.isList && f.name.endsWith("s")) {
9
+ return f.name.slice(0, f.name.length - 1 | 0);
10
+ } else {
11
+ return f.name;
12
+ }
13
+ }
14
+
15
+ function dedupSorted(keys) {
16
+ let seen = new Set();
17
+ keys.forEach(k => {
18
+ seen.add(k);
19
+ });
20
+ return Array.from(seen.values()).toSorted(Primitive_string.compare);
21
+ }
22
+
23
+ function keysOfEvent(e) {
24
+ return e.idFields.map(tagKeyOf);
25
+ }
26
+
27
+ function producedKeys(s) {
28
+ return dedupSorted(s.produced.flatMap(keysOfEvent));
29
+ }
30
+
31
+ function consumedKeys(s) {
32
+ return dedupSorted(s.consumed.flatMap(keysOfEvent));
33
+ }
34
+
35
+ function commandScalarKeys(s) {
36
+ return dedupSorted(s.command.filter(f => !f.isList).map(tagKeyOf));
37
+ }
38
+
39
+ function foreignConsumedKeys(s) {
40
+ let ownProduced = new Set();
41
+ s.produced.forEach(e => {
42
+ ownProduced.add(e.eventType);
43
+ });
44
+ return dedupSorted(s.consumed.flatMap(e => {
45
+ if (ownProduced.has(e.eventType)) {
46
+ return [];
47
+ } else {
48
+ return e.idFields.map(tagKeyOf);
49
+ }
50
+ }));
51
+ }
52
+
53
+ function crossPartitionForSlice(s) {
54
+ let foreign = foreignConsumedKeys(s);
55
+ let scalar = commandScalarKeys(s);
56
+ let h = s.partitionHint;
57
+ let partition;
58
+ let exit = 0;
59
+ if (h !== undefined && producedKeys(s).includes(h)) {
60
+ partition = h;
61
+ } else {
62
+ exit = 1;
63
+ }
64
+ if (exit === 1) {
65
+ let match = producedKeys(s).filter(k => !foreign.includes(k));
66
+ partition = match.length !== 1 ? undefined : match[0];
67
+ }
68
+ return foreign.filter(k => {
69
+ if (Primitive_object.notequal(k, partition)) {
70
+ return scalar.includes(k);
71
+ } else {
72
+ return false;
73
+ }
74
+ });
75
+ }
76
+
77
+ function infer(slices) {
78
+ let partitionBySlice = {};
79
+ let ambiguities = [];
80
+ slices.forEach(s => {
81
+ let produced = producedKeys(s);
82
+ let h = s.partitionHint;
83
+ if (h !== undefined && produced.includes(h)) {
84
+ partitionBySlice[s.sliceName] = h;
85
+ return;
86
+ }
87
+ let foreign = foreignConsumedKeys(s);
88
+ let many = produced.filter(k => !foreign.includes(k));
89
+ let len = many.length;
90
+ if (len !== 1) {
91
+ if (len !== 0) {
92
+ ambiguities.push([
93
+ s.sliceName,
94
+ `multiple candidate partition keys (` + many.join(", ") + `) — add an explicit @partitionTag`
95
+ ]);
96
+ } else {
97
+ ambiguities.push([
98
+ s.sliceName,
99
+ "no own partition key — every produced *Id is read from a foreign producer (pure join?); add an explicit @partitionTag"
100
+ ]);
101
+ }
102
+ return;
103
+ }
104
+ let single = many[0];
105
+ partitionBySlice[s.sliceName] = single;
106
+ });
107
+ let ownerByKey = {};
108
+ let ownedPartitionKeys = new Set();
109
+ slices.forEach(s => {
110
+ let k = partitionBySlice[s.sliceName];
111
+ if (k === undefined) {
112
+ return;
113
+ }
114
+ ownedPartitionKeys.add(k);
115
+ let match = ownerByKey[k];
116
+ if (match !== undefined) {
117
+ return;
118
+ } else {
119
+ ownerByKey[k] = s.sliceName;
120
+ return;
121
+ }
122
+ });
123
+ let crossKeys = new Set();
124
+ slices.forEach(s => {
125
+ let own = partitionBySlice[s.sliceName];
126
+ let scalar = commandScalarKeys(s);
127
+ consumedKeys(s).forEach(k => {
128
+ if (ownedPartitionKeys.has(k) && Primitive_object.notequal(k, own) && scalar.includes(k)) {
129
+ crossKeys.add(k);
130
+ return;
131
+ }
132
+ });
133
+ });
134
+ let crossPartitionTagKeys = Array.from(crossKeys.values()).toSorted(Primitive_string.compare);
135
+ let readKeysByEventType = {};
136
+ slices.forEach(s => {
137
+ s.consumed.forEach(e => {
138
+ let prev = Stdlib_Option.getOr(readKeysByEventType[e.eventType], []);
139
+ readKeysByEventType[e.eventType] = prev.concat(e.idFields.map(tagKeyOf));
140
+ });
141
+ });
142
+ let tagKeysByEventType = {};
143
+ slices.forEach(s => {
144
+ let own = partitionBySlice[s.sliceName];
145
+ s.produced.forEach(e => {
146
+ let readKeys = Stdlib_Option.getOr(readKeysByEventType[e.eventType], []);
147
+ let indexed = e.idFields.map(tagKeyOf).filter(k => {
148
+ if (Primitive_object.equal(k, own)) {
149
+ return true;
150
+ } else {
151
+ return readKeys.includes(k);
152
+ }
153
+ });
154
+ tagKeysByEventType[e.eventType] = dedupSorted(indexed);
155
+ });
156
+ });
157
+ return {
158
+ partitionBySlice: partitionBySlice,
159
+ ownerByKey: ownerByKey,
160
+ crossPartitionTagKeys: crossPartitionTagKeys,
161
+ tagKeysByEventType: tagKeysByEventType,
162
+ ambiguities: ambiguities
163
+ };
164
+ }
165
+
166
+ export {
167
+ tagKeyOf,
168
+ dedupSorted,
169
+ keysOfEvent,
170
+ producedKeys,
171
+ consumedKeys,
172
+ commandScalarKeys,
173
+ foreignConsumedKeys,
174
+ crossPartitionForSlice,
175
+ infer,
176
+ }
177
+ /* No side effect */
@@ -926,6 +926,56 @@ let extractCrossPartitionTagKeys = (schema: S.t<'event>): array<string> => {
926
926
  Array.fromIterator(seen->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
927
927
  }
928
928
 
929
+ // --- Schema -> DcbScopeInference shapes (the runtime adapter) ---
930
+
931
+ /**
932
+ Collects the `*Id` / `*Ids`-shaped fields of one object-variant's properties as
933
+ `DcbScopeInference.idField`s — by **name**, independent of any DCB tag flag. This
934
+ is the un-annotated structural view the scope inference consumes.
935
+ */
936
+ let idFieldsOfProperties = (properties: dict<S.t<unknown>>): array<DcbScopeInference.idField> =>
937
+ properties
938
+ ->Dict.toArray
939
+ ->Array.filterMap(((name, fieldSchema)) =>
940
+ if name->String.endsWith("Ids") || name->String.endsWith("Id") {
941
+ let isList = switch fieldSchema {
942
+ | Array(_) => true
943
+ | _ => false
944
+ }
945
+ Some({DcbScopeInference.name, isList})
946
+ } else {
947
+ None
948
+ }
949
+ )
950
+
951
+ /**
952
+ Extracts the `DcbScopeInference.eventShape`s (variant name + `*Id` fields) from a
953
+ variant schema. Payload-less arms are kept (no id fields); non-variant schemas
954
+ return a single shape.
955
+ */
956
+ let eventShapesOfSchema = (schema: S.t<'a>): array<DcbScopeInference.eventShape> => {
957
+ let ofVariant = (variantSchema: S.t<unknown>): option<DcbScopeInference.eventShape> =>
958
+ switch variantSchema {
959
+ | Object({items, properties}) =>
960
+ items
961
+ ->Array.find(item => item.location == "TAG")
962
+ ->Option.flatMap(item =>
963
+ switch item.schema {
964
+ | String({const}) => Some(const)
965
+ | _ => None
966
+ }
967
+ )
968
+ ->Option.map(eventType => {DcbScopeInference.eventType, idFields: idFieldsOfProperties(properties)})
969
+ | String({const}) => Some({DcbScopeInference.eventType: const, idFields: []})
970
+ | _ => None
971
+ }
972
+ switch schema->toUnknownSchema {
973
+ | Union({anyOf}) => anyOf->Array.filterMap(ofVariant)
974
+ | Object(_) as obj => ofVariant(obj)->Option.mapOr([], s => [s])
975
+ | _ => []
976
+ }
977
+ }
978
+
929
979
  // --- Partition tag derivation ---
930
980
 
931
981
  /**
@@ -968,6 +1018,33 @@ let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
968
1018
  }
969
1019
  }
970
1020
 
1021
+ /**
1022
+ Builds the `DcbScopeInference.sliceShape` for one slice from its sury schemas.
1023
+ The `command` fields are flattened across command variants; `consumed` / `produced`
1024
+ keep their per-arm structure. Schema-coupling lives here so the inference core
1025
+ stays schema-agnostic.
1026
+ */
1027
+ let sliceShapeFromSchemas = (
1028
+ ~name: string,
1029
+ ~commandSchema: S.t<'c>,
1030
+ ~consumedEventSchema: S.t<'ce>,
1031
+ ~eventSchema: S.t<'e>,
1032
+ ): DcbScopeInference.sliceShape => {
1033
+ // An explicit @partitionTag on the produced event is the escape hatch for
1034
+ // slices whose own events carry two owned keys (e.g. RecordProductDemand).
1035
+ let partitionHint = switch extractPartitionTagFields(eventSchema) {
1036
+ | [single] => Some(single)
1037
+ | _ => None
1038
+ }
1039
+ {
1040
+ sliceName: name,
1041
+ command: eventShapesOfSchema(commandSchema)->Array.flatMap(e => e.idFields),
1042
+ consumed: eventShapesOfSchema(consumedEventSchema),
1043
+ produced: eventShapesOfSchema(eventSchema),
1044
+ partitionHint,
1045
+ }
1046
+ }
1047
+
971
1048
  /**
972
1049
  Checks whether any single variant in a schema has multiple tagged fields.
973
1050
  If so, a partition tag annotation is needed to disambiguate.
@@ -578,6 +578,63 @@ function extractCrossPartitionTagKeys(schema) {
578
578
  return Array.from(seen.values()).toSorted(Primitive_string.compare);
579
579
  }
580
580
 
581
+ function idFieldsOfProperties(properties) {
582
+ return Stdlib_Array.filterMap(Object.entries(properties), param => {
583
+ let name = param[0];
584
+ if (!(name.endsWith("Ids") || name.endsWith("Id"))) {
585
+ return;
586
+ }
587
+ let isList;
588
+ isList = param[1].type === "array";
589
+ return {
590
+ name: name,
591
+ isList: isList
592
+ };
593
+ });
594
+ }
595
+
596
+ function eventShapesOfSchema(schema) {
597
+ let ofVariant = variantSchema => {
598
+ switch (variantSchema.type) {
599
+ case "string" :
600
+ let $$const = variantSchema.const;
601
+ if ($$const !== undefined) {
602
+ return {
603
+ eventType: $$const,
604
+ idFields: []
605
+ };
606
+ } else {
607
+ return;
608
+ }
609
+ case "object" :
610
+ let properties = variantSchema.properties;
611
+ return Stdlib_Option.map(Stdlib_Option.flatMap(variantSchema.items.find(item => item.location === "TAG"), item => {
612
+ let match = item.schema;
613
+ if (match.type !== "string") {
614
+ return;
615
+ }
616
+ let $$const = match.const;
617
+ if ($$const !== undefined) {
618
+ return $$const;
619
+ }
620
+ }), eventType => ({
621
+ eventType: eventType,
622
+ idFields: idFieldsOfProperties(properties)
623
+ }));
624
+ default:
625
+ return;
626
+ }
627
+ };
628
+ switch (schema.type) {
629
+ case "object" :
630
+ return Stdlib_Option.mapOr(ofVariant(schema), [], s => [s]);
631
+ case "union" :
632
+ return Stdlib_Array.filterMap(schema.anyOf, ofVariant);
633
+ default:
634
+ return [];
635
+ }
636
+ }
637
+
581
638
  function extractPartitionTagFields(schema) {
582
639
  switch (schema.type) {
583
640
  case "object" :
@@ -608,6 +665,18 @@ function extractPartitionTagFields(schema) {
608
665
  }
609
666
  }
610
667
 
668
+ function sliceShapeFromSchemas(name, commandSchema, consumedEventSchema, eventSchema) {
669
+ let match = extractPartitionTagFields(eventSchema);
670
+ let partitionHint = match.length !== 1 ? undefined : match[0];
671
+ return {
672
+ sliceName: name,
673
+ command: eventShapesOfSchema(commandSchema).flatMap(e => e.idFields),
674
+ consumed: eventShapesOfSchema(consumedEventSchema),
675
+ produced: eventShapesOfSchema(eventSchema),
676
+ partitionHint: partitionHint
677
+ };
678
+ }
679
+
611
680
  function hasMultiTagVariant(schema) {
612
681
  switch (schema.type) {
613
682
  case "object" :
@@ -857,7 +926,10 @@ export {
857
926
  extractTaggedFields,
858
927
  crossPartitionKeysOfProperties,
859
928
  extractCrossPartitionTagKeys,
929
+ idFieldsOfProperties,
930
+ eventShapesOfSchema,
860
931
  extractPartitionTagFields,
932
+ sliceShapeFromSchemas,
861
933
  hasMultiTagVariant,
862
934
  findMultiTagVariantNames,
863
935
  extractCompositePartitionFieldsFromProperties,
@@ -360,3 +360,49 @@ let validateCrossPartitionScope = (
360
360
  )
361
361
  warnings
362
362
  }
363
+
364
+ /** Two-bucket result of validating annotations against the inferred scope. */
365
+ type scopeInferenceIssues = {
366
+ /** Annotations that conflict with inference — likely bugs (warn/error). */
367
+ contradictions: array<validationError>,
368
+ /** Annotations inference already derives — safe to delete (info). */
369
+ redundancies: array<validationError>,
370
+ }
371
+
372
+ /**
373
+ Validates explicit `@crossPartition` annotations against the *inferred* scope,
374
+ now that inference drives the decision-query wiring. Two cases per annotated key:
375
+
376
+ - **Contradiction** — the key is marked `@crossPartition` on a slice that inference
377
+ resolves as that slice's *own partition*. A slice's own identity is never a
378
+ cross-partition read; the annotation is wrong and (were it to drive the wiring)
379
+ would fan the partition into a spurious secondary read. Surface loudly.
380
+ - **Redundant** — the key is marked `@crossPartition` and inference *also* derives
381
+ it as cross-partition from the slice graph. Harmless, but the annotation can be
382
+ dropped — that is the whole point of inference.
383
+
384
+ @param annotations `(sliceName, @crossPartition keys on the slice's produced event)`.
385
+ */
386
+ let validateScopeVsInference = (
387
+ ~annotations: array<(string, array<string>)>,
388
+ ~inferred: DcbScopeInference.derived,
389
+ ): scopeInferenceIssues => {
390
+ let contradictions: array<validationError> = []
391
+ let redundancies: array<validationError> = []
392
+ annotations->Array.forEach(((name, cpKeys)) =>
393
+ cpKeys->Array.forEach(k =>
394
+ if inferred.partitionBySlice->Dict.get(name) == Some(k) {
395
+ let _ = contradictions->Array.push({
396
+ sliceName: name,
397
+ 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.`,
398
+ })
399
+ } else if inferred.crossPartitionTagKeys->Array.includes(k) {
400
+ let _ = redundancies->Array.push({
401
+ sliceName: name,
402
+ 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.`,
403
+ })
404
+ }
405
+ )
406
+ )
407
+ {contradictions, redundancies}
408
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
5
6
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
6
7
  import * as DcbTag$Reventless from "./DcbTag.res.mjs";
7
8
 
@@ -346,6 +347,35 @@ function validateCrossPartitionScope(producers) {
346
347
  return warnings;
347
348
  }
348
349
 
350
+ function validateScopeVsInference(annotations, inferred) {
351
+ let contradictions = [];
352
+ let redundancies = [];
353
+ annotations.forEach(param => {
354
+ let name = param[0];
355
+ param[1].forEach(k => {
356
+ if (Primitive_object.equal(inferred.partitionBySlice[name], k)) {
357
+ contradictions.push({
358
+ sliceName: name,
359
+ 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.`
360
+ });
361
+ return;
362
+ } else if (inferred.crossPartitionTagKeys.includes(k)) {
363
+ redundancies.push({
364
+ sliceName: name,
365
+ 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.`
366
+ });
367
+ return;
368
+ } else {
369
+ return;
370
+ }
371
+ });
372
+ });
373
+ return {
374
+ contradictions: contradictions,
375
+ redundancies: redundancies
376
+ };
377
+ }
378
+
349
379
  export {
350
380
  extractVariantInfo,
351
381
  extractAllVariants,
@@ -356,5 +386,6 @@ export {
356
386
  validateCompositeReads,
357
387
  validateProducedAndConsumed,
358
388
  validateCrossPartitionScope,
389
+ validateScopeVsInference,
359
390
  }
360
391
  /* DcbTag-Reventless Not a pure module */
@@ -51,6 +51,12 @@ module type Spec = {
51
51
  /** Name of the aggregate or StateChangeSlice that receives the produced command. */
52
52
  let targetName: string
53
53
 
54
+ /** Optional display name of the foreign system this anti-corruption slice receives
55
+ from (e.g. `"SupplierFeed"`). Drives the **external box** drawn outside the plugin
56
+ in the Event Graph / Context Map (see docs/plans/translation-external-boxes.md).
57
+ Auto-injected by `@@reventless.spec` defaulting to `None` — set it to name the box. */
58
+ let externalSystem: option<string>
59
+
54
60
  /** Authorization rule evaluated at the GraphQL resolver entry before any
55
61
  external input is translated. Auto-injected by `@@reventless.spec` and
56
62
  on structurally-detected inline spec modules — defaults to
@@ -75,6 +75,12 @@ module type Spec = {
75
75
 
76
76
  /** Name of the aggregate or StateChangeSlice that receives the inbound command, or None for fire-and-forget. */
77
77
  let targetName: option<string>
78
+
79
+ /** Optional display name of the foreign system this anti-corruption slice publishes
80
+ to (e.g. `"EmailService"`). Drives the **external box** drawn outside the plugin
81
+ in the Event Graph / Context Map (see docs/plans/translation-external-boxes.md).
82
+ Auto-injected by `@@reventless.spec` defaulting to `None` — set it to name the box. */
83
+ let externalSystem: option<string>
78
84
  }
79
85
 
80
86
  /**
@@ -243,6 +243,8 @@ type outboundTranslationSliceDef = {
243
243
  consumedEventTypes: array<string>,
244
244
  inboundCommandTypes: array<string>,
245
245
  targetName: @s.matches(stringOptionSchema) option<string>,
246
+ // Foreign system this slice publishes to — drives the external box (Event Graph).
247
+ externalSystem: @s.matches(stringOptionSchema) option<string>,
246
248
  }
247
249
 
248
250
  @schema
@@ -250,6 +252,8 @@ type inboundTranslationSliceDef = {
250
252
  name: string,
251
253
  commandTypes: array<string>,
252
254
  targetName: string,
255
+ // Foreign system this slice receives from — drives the external box (Event Graph).
256
+ externalSystem: @s.matches(stringOptionSchema) option<string>,
253
257
  }
254
258
 
255
259
  @schema
@@ -132,13 +132,15 @@ let outboundTranslationSliceDefSchema = S.schema(s => ({
132
132
  name: s.m(S.string),
133
133
  consumedEventTypes: s.m(S.array(S.string)),
134
134
  inboundCommandTypes: s.m(S.array(S.string)),
135
- targetName: s.m(stringOptionSchema)
135
+ targetName: s.m(stringOptionSchema),
136
+ externalSystem: s.m(stringOptionSchema)
136
137
  }));
137
138
 
138
139
  let inboundTranslationSliceDefSchema = S.schema(s => ({
139
140
  name: s.m(S.string),
140
141
  commandTypes: s.m(S.array(S.string)),
141
- targetName: s.m(S.string)
142
+ targetName: s.m(S.string),
143
+ externalSystem: s.m(stringOptionSchema)
142
144
  }));
143
145
 
144
146
  let extensionDefSchema = S.schema(s => ({