@reventlessdev/reventless-spec 3.0.0-alpha.134 → 3.0.0-alpha.136

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 (36) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/package.json +4 -3
  3. package/run-check-lifecycle.mjs +2 -1
  4. package/run-prepare-accounts.mjs +3 -0
  5. package/src/components/CapabilityManifest.res +4 -0
  6. package/src/components/CapabilityManifest.res.mjs +13 -2
  7. package/src/components/DcbScopeInference.res +224 -73
  8. package/src/components/DcbScopeInference.res.mjs +187 -49
  9. package/src/components/DcbTag.res +142 -186
  10. package/src/components/DcbTag.res.mjs +137 -128
  11. package/src/components/DcbValidation.res +50 -0
  12. package/src/components/DcbValidation.res.mjs +51 -0
  13. package/src/generator/Codegen.res +2 -1
  14. package/src/generator/Codegen.res.mjs +1 -1
  15. package/src/generator/PlatformCodegen.res +1 -0
  16. package/src/generator/PlatformCodegen.res.mjs +5 -0
  17. package/src/lifecycle/CheckLifecycleModel.res +148 -8
  18. package/src/lifecycle/CheckLifecycleModel.res.mjs +154 -7
  19. package/src/semantic/Capabilities.res +17 -0
  20. package/src/semantic/Capabilities.res.mjs +9 -1
  21. package/src/semantic/CapabilityNeed.res +12 -0
  22. package/src/semantic/CapabilityNeed.res.mjs +9 -4
  23. package/src/semantic/IdentityProvider.res +127 -0
  24. package/src/semantic/IdentityProvider.res.mjs +65 -0
  25. package/src/semantic/Secrets.res +76 -0
  26. package/src/semantic/Secrets.res.mjs +49 -0
  27. package/src/types/AccountsManifest.res +287 -0
  28. package/src/types/AccountsManifest.res.mjs +312 -0
  29. package/src/types/AdminGroup.res +27 -0
  30. package/src/types/AdminGroup.res.mjs +9 -0
  31. package/src/types/OwnerScope.res +27 -0
  32. package/src/types/OwnerScope.res.mjs +12 -0
  33. package/src/types/PrepareAccounts.res +119 -0
  34. package/src/types/PrepareAccounts.res.mjs +148 -0
  35. package/src/util/Util_Password.res +60 -0
  36. package/src/util/Util_Password.res.mjs +53 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
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.136 (2026-09-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** make the demo accounts work on a pool that signs in by email ([97b036f](https://github.com/ReventlessDev/reventless-core/commit/97b036f86979ba55dbbf93d460fe8076c35abf1a))
11
+ * feat(spec)!: infer the DCB partition key per slice, and drop @partitionTag where inference agrees ([6c10161](https://github.com/ReventlessDev/reventless-core/commit/6c101611d438beea4bc9175ae8ea0ea1f6f7493b))
12
+ ### Features
13
+
14
+ * **spec:** let a slice's chapter break a partition-key tie ([2b9ae77](https://github.com/ReventlessDev/reventless-core/commit/2b9ae77e98eb42177d2bdd4977bc62bb7c7911aa))
15
+ * **spec:** report which command yields which event or error ([ac0bdc2](https://github.com/ReventlessDev/reventless-core/commit/ac0bdc23f76a5b71b5bf5c99415ff0937690a279))
16
+
17
+ ### BREAKING CHANGES
18
+
19
+ * DcbTag.derivePartitionTag is removed; a boundary whose
20
+ partition cannot be inferred no longer deploys.
21
+
22
+
23
+
24
+ # 3.0.0-alpha.135 (2026-09-11)
25
+
26
+ ### Bug Fixes
27
+
28
+ * **spec:** a command that declines to act is not a command that was refused ([0b3d8a4](https://github.com/ReventlessDev/reventless-core/commit/0b3d8a40092ce7ed7801a2aae5d4d7bbed03c179))
29
+ * feat(spec)!: a secret is a capability, so a test can know it in advance ([71507c6](https://github.com/ReventlessDev/reventless-core/commit/71507c6459b49178746f00c5040cb479d7fdade8))
30
+ * feat(spec)!: making a principal is a capability, so the provider is replaceable ([3e59b9b](https://github.com/ReventlessDev/reventless-core/commit/3e59b9b665cc7e4b6b6f156b63b1b6bd0c6181ca))
31
+ ### Features
32
+
33
+ * **aws:** the first administrator is one command, not the console ([5f1a846](https://github.com/ReventlessDev/reventless-core/commit/5f1a84603b21a9feb691a08a148751c6c6e8eca8))
34
+ * **aws:** the rest of the cast is a file, not a comment ([aa23581](https://github.com/ReventlessDev/reventless-core/commit/aa23581f09948a47fb451ec2ecf2fc2bdd8e6ee6))
35
+ * **spec:** preparing a manifest is not an AWS errand ([bd7f8e2](https://github.com/ReventlessDev/reventless-core/commit/bd7f8e2f6132c0cacac2ab839165f21fe91eaf6c))
36
+
37
+ ### BREAKING CHANGES
38
+
39
+ * Capabilities.t gains a secrets member. A platform that builds
40
+ the record literally no longer compiles until it supplies one;
41
+ Capabilities.none.secrets is the refusing value. Code that spreads
42
+ Capabilities.none is unaffected.
43
+ * Capabilities.t gains an identityProvider member. A platform
44
+ that builds the record literally no longer compiles until it supplies one;
45
+ Capabilities.none.identityProvider is the refusing value to pass while no
46
+ backend exists. Code that spreads Capabilities.none is unaffected.
47
+
48
+
49
+
6
50
  # 3.0.0-alpha.134 (2026-09-09)
7
51
 
8
52
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.134",
3
+ "version": "3.0.0-alpha.136",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "graft-trait": "./run-graft-trait.mjs",
10
10
  "certify-trait": "./run-certify-trait.mjs",
11
11
  "trait-manifest": "./run-trait-manifest.mjs",
12
- "check-lifecycle": "./run-check-lifecycle.mjs"
12
+ "check-lifecycle": "./run-check-lifecycle.mjs",
13
+ "prepare-accounts": "./run-prepare-accounts.mjs"
13
14
  },
14
15
  "jest": {
15
16
  "testMatch": [
@@ -25,7 +26,7 @@
25
26
  "sury": "11.0.0-rc.2",
26
27
  "sury-ppx": "11.0.0-rc.2",
27
28
  "yaml": "^2.8.3",
28
- "@reventlessdev/rescript-node": "2.0.0-alpha.11"
29
+ "@reventlessdev/rescript-node": "2.0.0-alpha.12"
29
30
  },
30
31
  "devDependencies": {
31
32
  "rescript": "12.3.0",
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import "./src/lifecycle/CheckLifecycleModel.res.mjs"
2
+ import { main } from "./src/lifecycle/CheckLifecycleModel.res.mjs"
3
+ await main()
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./src/types/PrepareAccounts.res.mjs"
3
+ await main()
@@ -21,6 +21,9 @@ type kind =
21
21
  /** Sending a message, reached through `Capabilities.messaging`. Slice-declared
22
22
  like `Geocoding`, and for the same reason carries no `field`. */
23
23
  | Messaging
24
+ /** Making, grouping and unmaking principals, reached through
25
+ `Capabilities.identityProvider`. Slice-declared, so no `field`. */
26
+ | IdentityProvider
24
27
 
25
28
  /** The declaration site: the component's spec name, and — for a store — the field
26
29
  carrying the `@storageRef` annotation plus the store exactly as that field
@@ -80,6 +83,7 @@ let fromStructure = (structure: Plugin.pluginStructure): t => {
80
83
  kind: switch need {
81
84
  | Geocoding => Geocoding
82
85
  | Messaging => Messaging
86
+ | IdentityProvider => IdentityProvider
83
87
  },
84
88
  key,
85
89
  declaredBy: needs->Array.filterMap(
@@ -10,7 +10,8 @@ import * as CapabilityNeed$Reventless from "../semantic/CapabilityNeed.res.mjs";
10
10
  let kindSchema = Sury.union([
11
11
  Sury.literal("ObjectStore"),
12
12
  Sury.literal("Geocoding"),
13
- Sury.literal("Messaging")
13
+ Sury.literal("Messaging"),
14
+ Sury.literal("IdentityProvider")
14
15
  ]);
15
16
 
16
17
  let provenanceSchema = Sury.$schema(s => ({
@@ -48,7 +49,17 @@ function fromStructure(structure) {
48
49
  let capabilityKeys = Belt_SetString.toArray(Belt_SetString.fromArray(needs.map(d => d.capability)));
49
50
  let capabilities = Stdlib_Array.filterMap(capabilityKeys, key => Stdlib_Option.map(CapabilityNeed$Reventless.fromString(key), need => {
50
51
  let tmp;
51
- tmp = need === "Geocoding" ? "Geocoding" : "Messaging";
52
+ switch (need) {
53
+ case "Geocoding" :
54
+ tmp = "Geocoding";
55
+ break;
56
+ case "Messaging" :
57
+ tmp = "Messaging";
58
+ break;
59
+ case "IdentityProvider" :
60
+ tmp = "IdentityProvider";
61
+ break;
62
+ }
52
63
  return {
53
64
  kind: tmp,
54
65
  key: key,
@@ -17,7 +17,12 @@ The three rules (over the representation):
17
17
  reads from a *foreign* producer (a consumed arm whose event type is produced by
18
18
  a different slice). For `AddProduct` (`ProductAdded({productId, categoryId})`,
19
19
  consuming `CategoryAdded({categoryId})`) the foreign-read `categoryId` is
20
- removed, leaving `productId`. No fixpoint needed.
20
+ removed, leaving `productId`. Only when that leaves nothing is a key given
21
+ back: one whose every foreign arm comes from a slice partitioned by that same
22
+ key, since reading your own entity's events is identity, not a reference.
23
+ That step depends on other slices' partitions, so it runs to a fixpoint.
24
+ When several keys are left, the slice's chapter breaks the tie if exactly one
25
+ of them is carried by every event its chapter writes.
21
26
 
22
27
  2. **Cross-partition.** A key is cross-partition iff some slice reads it on a
23
28
  *foreign* consumed event while partitioned by something else *and* the key is
@@ -50,6 +55,8 @@ type sliceShape = {
50
55
  consumed: array<eventShape>,
51
56
  produced: array<eventShape>,
52
57
  partitionHint: option<string>,
58
+ /** The chapter the slice sits in (`src/<Chapter>/<Kind>/…`), when it has one. */
59
+ chapter?: string,
53
60
  }
54
61
 
55
62
  /** Derived scope for one tag key. */
@@ -129,39 +136,227 @@ let foreignConsumedKeys = (s: sliceShape): array<string> => {
129
136
  )
130
137
  }
131
138
 
139
+ /** Rule 1's outcome for a boundary, before the scope rules build on it. */
140
+ type partitionResolution = {
141
+ /** sliceName -> its partition key (absent when unresolved). */
142
+ partitionBySlice: dict<string>,
143
+ /** sliceName -> the keys rule 1 left standing; one entry means resolved. */
144
+ candidatesBySlice: dict<array<string>>,
145
+ /** sliceName -> (produced key, the foreign arms that kept it from the slice). */
146
+ blockersBySlice: dict<array<(string, array<string>)>>,
147
+ /** (sliceName, reason) for slices whose partition couldn't be inferred. */
148
+ ambiguities: array<(string, string)>,
149
+ }
150
+
151
+ let sameAssignment = (a: dict<string>, b: dict<string>): bool => {
152
+ let entries = d => d->Dict.toArray->Array.toSorted(((x, _), (y, _)) => String.compare(x, y))
153
+ entries(a) == entries(b)
154
+ }
155
+
156
+ /**
157
+ Rule 1 over a whole boundary.
158
+
159
+ 1. **Seeds.** A slice whose events carry a single key is partitioned by it, whatever
160
+ it reads; an explicit `@partitionTag` naming a produced key is a seed too.
161
+ 2. **Subtraction.** Otherwise, `producedKeys − foreignConsumedKeys`. One key left
162
+ is the partition; several go to the chapter (4), then to `@partitionTag`.
163
+ 3. **Give-back.** Only when the subtraction leaves nothing: a key comes back when
164
+ every foreign arm carrying it is produced by a slice partitioned by that key.
165
+ An unseen producer makes the arm a reference. An unresolved producer counts as
166
+ possibly partitioned by any key it writes, which breaks cycles such as two
167
+ slices each reading the other's event; the answer counts only once a further
168
+ pass changes nothing, so that optimism never decides it alone.
169
+ 4. **Chapter.** When several keys are left, keep the ones every id-carrying event
170
+ written in the slice's chapter carries. `PlaceOrder` is left with `orderId` and
171
+ `customerId`, whose reads never show the customer to be a reference; every
172
+ event under `Order/` carries `orderId`, and only some carry `customerId`. Only a
173
+ single survivor decides — a join such as `RecordProductDemand`, whose chapter's
174
+ events all carry both ids, stays ambiguous. It is a tie-breaker, not a rule:
175
+ it never overrides a seed, the subtraction or the give-back, and moving a slice
176
+ to another chapter can change only a partition this step decided.
177
+
178
+ Applying the give-back everywhere would be wrong: `AddProduct` reads
179
+ `CategoryAdded({categoryId})` from a slice partitioned by `categoryId`, which
180
+ would hand `categoryId` back and leave it two candidates.
181
+ */
182
+ let resolvePartitions = (slices: array<sliceShape>): partitionResolution => {
183
+ let producersByEventType: dict<array<sliceShape>> = Dict.make()
184
+ slices->Array.forEach(s =>
185
+ s.produced->Array.forEach(e => {
186
+ let prev = producersByEventType->Dict.get(e.eventType)->Option.getOr([])
187
+ if !(prev->Array.some(p => p.sliceName == s.sliceName)) {
188
+ producersByEventType->Dict.set(e.eventType, prev->Array.concat([s]))
189
+ }
190
+ })
191
+ )
192
+ // chapter -> the keys every id-carrying event written in that chapter carries.
193
+ let chapterKeys = Dict.make()
194
+ slices->Array.forEach(s =>
195
+ s.chapter->Option.forEach(chapter => {
196
+ let events = s.produced->Array.filter(e => e.idFields->Array.length > 0)
197
+ events->Array.forEach(
198
+ e => {
199
+ let keys = e->keysOfEvent
200
+ let shared = switch chapterKeys->Dict.get(chapter) {
201
+ | Some(prev) => prev->Array.filter(k => keys->Array.includes(k))
202
+ | None => dedupSorted(keys)
203
+ }
204
+ chapterKeys->Dict.set(chapter, shared)
205
+ },
206
+ )
207
+ })
208
+ )
209
+ let chapterKeysOf = (s: sliceShape) => s.chapter->Option.flatMap(c => chapterKeys->Dict.get(c))
210
+ let byChapter = (s: sliceShape, candidates) =>
211
+ switch s->chapterKeysOf {
212
+ | Some(keys) =>
213
+ switch candidates->Array.filter(k => keys->Array.includes(k)) {
214
+ | [k] => [k]
215
+ | _ => candidates
216
+ }
217
+ | None => candidates
218
+ }
219
+ let foreignArms = (s: sliceShape) => {
220
+ let own = Set.make()
221
+ s.produced->Array.forEach(e => own->Set.add(e.eventType))
222
+ s.consumed->Array.filter(e => !(own->Set.has(e.eventType)))
223
+ }
224
+ // An arm is identity evidence for `k` iff every producer of its event type is
225
+ // (or, while unresolved, could be) partitioned by `k`.
226
+ let armIsIdentity = (e: eventShape, k, known: dict<string>) =>
227
+ switch producersByEventType->Dict.get(e.eventType) {
228
+ | None | Some([]) => false
229
+ | Some(producers) =>
230
+ producers->Array.every(p =>
231
+ switch known->Dict.get(p.sliceName) {
232
+ | Some(pk) => pk == k
233
+ | None => producedKeys(p)->Array.includes(k)
234
+ }
235
+ )
236
+ }
237
+ let blockersOf = (s: sliceShape, known) =>
238
+ producedKeys(s)->Array.filterMap(k => {
239
+ let arms =
240
+ s
241
+ ->foreignArms
242
+ ->Array.filter(e => e->keysOfEvent->Array.includes(k) && !armIsIdentity(e, k, known))
243
+ ->Array.map(e => e.eventType)
244
+ arms->Array.length > 0 ? Some((k, arms)) : None
245
+ })
246
+ let seedOf = (s: sliceShape) => {
247
+ let produced = producedKeys(s)
248
+ switch (s.partitionHint, produced) {
249
+ | (Some(h), _) if produced->Array.includes(h) => Some(h)
250
+ | (_, [single]) => Some(single)
251
+ | _ => None
252
+ }
253
+ }
254
+ let subtracted = (s: sliceShape) => {
255
+ let foreign = foreignConsumedKeys(s)
256
+ producedKeys(s)->Array.filter(k => !(foreign->Array.includes(k)))
257
+ }
258
+ let candidatesOf = (s: sliceShape, known) =>
259
+ switch seedOf(s) {
260
+ | Some(k) => [k]
261
+ | None =>
262
+ switch subtracted(s) {
263
+ | [] =>
264
+ let blocked = s->blockersOf(known)->Array.map(((k, _)) => k)
265
+ s->byChapter(producedKeys(s)->Array.filter(k => !(blocked->Array.includes(k))))
266
+ | remaining => s->byChapter(remaining)
267
+ }
268
+ }
269
+
270
+ let pass = known => {
271
+ let next = Dict.make()
272
+ slices->Array.forEach(s =>
273
+ switch candidatesOf(s, known) {
274
+ | [k] => next->Dict.set(s.sliceName, k)
275
+ | _ => ()
276
+ }
277
+ )
278
+ next
279
+ }
280
+ // Each pass can resolve at most one more link of a chain, so a boundary that
281
+ // settles does so within one pass per slice; the extra pass confirms it.
282
+ let rec settle = (known, remaining) => {
283
+ let next = pass(known)
284
+ if sameAssignment(next, known) {
285
+ (next, true)
286
+ } else if remaining == 0 {
287
+ (next, false)
288
+ } else {
289
+ settle(next, remaining - 1)
290
+ }
291
+ }
292
+ let (settled, converged) = settle(Dict.make(), slices->Array.length + 1)
293
+ let previous = pass(settled)
294
+
295
+ let partitionBySlice = Dict.make()
296
+ let candidatesBySlice = Dict.make()
297
+ let blockersBySlice = Dict.make()
298
+ let ambiguities = []
299
+ slices->Array.forEach(s => {
300
+ let candidates = candidatesOf(s, settled)
301
+ candidatesBySlice->Dict.set(s.sliceName, candidates)
302
+ switch candidates {
303
+ | _ if !converged && settled->Dict.get(s.sliceName) != previous->Dict.get(s.sliceName) =>
304
+ ambiguities->Array.push((
305
+ s.sliceName,
306
+ `partition inference did not settle — the slices it reads from keep changing each other's partition. Add an explicit @partitionTag`,
307
+ ))
308
+ | [k] => partitionBySlice->Dict.set(s.sliceName, k)
309
+ | [] =>
310
+ // Name the arms that took the key. Almost always a lifecycle arm
311
+ // declaring the id the slice is already partitioned by, where the fix is
312
+ // to drop the field rather than to annotate around it.
313
+ let blockers = s->blockersOf(settled)
314
+ blockersBySlice->Dict.set(s.sliceName, blockers)
315
+ let blame =
316
+ blockers
317
+ ->Array.map(((key, arms)) => `${arms->Array.join("/")} declares ${key}`)
318
+ ->Array.join("; ")
319
+ ambiguities->Array.push((
320
+ s.sliceName,
321
+ `no own partition key — every produced *Id is read from a foreign producer (${blame}). If that field is this slice's own partition, remove it from the consumed arm; if the slice really is a pure join, add an explicit @partitionTag`,
322
+ ))
323
+ | many =>
324
+ let chapterNote = switch (s.chapter, s->chapterKeysOf) {
325
+ | (Some(chapter), Some(keys)) =>
326
+ ` The ${chapter} chapter does not decide: the ids every event in it carries are [${keys->Array.join(
327
+ ", ",
328
+ )}].`
329
+ | _ => ""
330
+ }
331
+ ambiguities->Array.push((
332
+ s.sliceName,
333
+ `multiple candidate partition keys (${many->Array.join(
334
+ ", ",
335
+ )}) — add an explicit @partitionTag.${chapterNote}`,
336
+ ))
337
+ }
338
+ })
339
+ {partitionBySlice, candidatesBySlice, blockersBySlice, ambiguities}
340
+ }
341
+
132
342
  /**
133
343
  Which consumed arms cost a slice its partition — the actionable half of the
134
- "no own partition key" ambiguity.
344
+ "no own partition key" ambiguity, seen from this slice alone.
135
345
 
136
- Rule 1 subtracts `foreignConsumedKeys` from `producedKeys`, so a slice whose only
137
- produced key is also declared on a consumed arm it does not itself produce is left
138
- with nothing. In practice that is almost always one mistake: a *lifecycle* arm
139
- naming the id it is already partitioned by (`ProductAdded({productId})` on a slice
140
- whose every event carries `productId`), which reads as "this id comes from a
141
- foreign producer" when it is in fact this slice's own partition.
346
+ A slice whose produced keys all ride consumed arms from other producers, and
347
+ whose arms cannot be given back, is left with nothing. In practice that is almost
348
+ always one mistake: a *lifecycle* arm naming the id the slice is already
349
+ partitioned by, which reads as "this id comes from a foreign producer".
142
350
 
143
351
  The ambiguity message can only say a partition was not found. This says which arm
144
- to delete, which is the whole difference between a diagnostic and a puzzle — so it
145
- is here, beside the rule that produces the ambiguity, rather than duplicated by
146
- each surface that reports one.
352
+ to delete, which is the whole difference between a diagnostic and a puzzle.
147
353
 
148
- Returns one entry per produced key that a foreign arm claims, naming those arms.
149
- Empty when the slice has a partition (nothing to explain) or when the produced
150
- keys are simply too many (a different ambiguity, answered by `@partitionTag`).
354
+ Alone, no producer is in sight, so every foreign arm is a reference. Returns one
355
+ entry per produced key a foreign arm claims, naming those arms. Empty when the
356
+ slice has a partition or when too many keys are left (answered by `@partitionTag`).
151
357
  */
152
- let partitionBlockers = (s: sliceShape): array<(string, array<string>)> => {
153
- let foreign = foreignConsumedKeys(s)
154
- let ownProduced = Set.make()
155
- s.produced->Array.forEach(e => ownProduced->Set.add(e.eventType))
156
- producedKeys(s)
157
- ->Array.filter(k => foreign->Array.includes(k))
158
- ->Array.map(k => (
159
- k,
160
- s.consumed
161
- ->Array.filter(e => !(ownProduced->Set.has(e.eventType)) && e->keysOfEvent->Array.includes(k))
162
- ->Array.map(e => e.eventType),
163
- ))
164
- }
358
+ let partitionBlockers = (s: sliceShape): array<(string, array<string>)> =>
359
+ resolvePartitions([s]).blockersBySlice->Dict.get(s.sliceName)->Option.getOr([])
165
360
 
166
361
  /**
167
362
  Per-slice cross-partition keys for the test harness, which has no global owner
@@ -173,58 +368,14 @@ annotation so capacity/escape-hatch reads remain covered.
173
368
  let crossPartitionForSlice = (s: sliceShape): array<string> => {
174
369
  let foreign = foreignConsumedKeys(s)
175
370
  let scalar = commandScalarKeys(s)
176
- let partition = switch s.partitionHint {
177
- | Some(h) if producedKeys(s)->Array.includes(h) => Some(h)
178
- | _ =>
179
- switch producedKeys(s)->Array.filter(k => !(foreign->Array.includes(k))) {
180
- | [single] => Some(single)
181
- | _ => None
182
- }
183
- }
371
+ let partition = resolvePartitions([s]).partitionBySlice->Dict.get(s.sliceName)
184
372
  // A foreign read is cross-partition only when the command carries the key as a
185
373
  // scalar (must be fanned); an array-only foreign key auto-fans partition-scoped.
186
374
  foreign->Array.filter(k => Some(k) != partition && scalar->Array.includes(k))
187
375
  }
188
376
 
189
377
  let infer = (slices: array<sliceShape>): derived => {
190
- // Rule 1 — partition(S) = producedKeys(S) \ foreignConsumedKeys(S), where a
191
- // foreign-consumed key rides a consumed arm the slice does not itself produce.
192
- // An explicit @partitionTag hint overrides the derivation (escape hatch for
193
- // slices whose own events legitimately carry two owned keys).
194
- let partitionBySlice = Dict.make()
195
- let ambiguities = []
196
- slices->Array.forEach(s => {
197
- let produced = producedKeys(s)
198
- switch s.partitionHint {
199
- | Some(h) if produced->Array.includes(h) => partitionBySlice->Dict.set(s.sliceName, h)
200
- | _ =>
201
- let foreign = foreignConsumedKeys(s)
202
- switch produced->Array.filter(k => !(foreign->Array.includes(k))) {
203
- | [single] => partitionBySlice->Dict.set(s.sliceName, single)
204
- | [] =>
205
- // Name the arms that took the key. Almost always a lifecycle arm
206
- // declaring the id the slice is already partitioned by, where the fix is
207
- // to drop the field rather than to annotate around it.
208
- let blame =
209
- partitionBlockers(s)
210
- ->Array.map(((key, arms)) => `${arms->Array.join("/")} declares ${key}`)
211
- ->Array.join("; ")
212
- let _ =
213
- ambiguities->Array.push((
214
- s.sliceName,
215
- `no own partition key — every produced *Id is read from a foreign producer (${blame}). If that field is this slice's own partition, remove it from the consumed arm; if the slice really is a pure join, add an explicit @partitionTag`,
216
- ))
217
- | many =>
218
- let _ =
219
- ambiguities->Array.push((
220
- s.sliceName,
221
- `multiple candidate partition keys (${many->Array.join(
222
- ", ",
223
- )}) — add an explicit @partitionTag`,
224
- ))
225
- }
226
- }
227
- })
378
+ let {partitionBySlice, ambiguities} = resolvePartitions(slices)
228
379
 
229
380
  // owner map + the set of keys that are *some* entity's partition.
230
381
  let ownerByKey = Dict.make()