@reventlessdev/reventless-core 3.0.0-alpha.261 → 3.0.0-alpha.263

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 (40) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/package.json +9 -9
  3. package/src/adapter/Secrets/Secrets_Node_Backend.res +60 -0
  4. package/src/adapter/Secrets/Secrets_Node_Backend.res.mjs +69 -0
  5. package/src/admin/Platform_Admin_Structure.res.mjs +1 -1
  6. package/src/components/Aggregate/Aggregate_Builder.res.mjs +1 -1
  7. package/src/components/CommandGenerator/CommandGenerator.res +4 -4
  8. package/src/components/CommandGenerator/CommandGenerator_Callback.res +13 -20
  9. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +3 -24
  10. package/src/components/Dcb/Dcb_Builder.res +45 -49
  11. package/src/components/Dcb/Dcb_Builder.res.mjs +31 -40
  12. package/src/components/StateChangeSlice/StateChangeSlice.res +2 -0
  13. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res +6 -0
  14. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res.mjs +4 -4
  15. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +51 -22
  16. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +47 -25
  17. package/src/plugin/component/Plugin_Structure.res +101 -4
  18. package/src/plugin/component/Plugin_Structure.res.mjs +82 -9
  19. package/tests/Secrets_Node_BackendTest.res +141 -0
  20. package/tests/Secrets_Node_BackendTest.res.mjs +152 -0
  21. package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +1 -1
  22. package/tests/admin/Platform_Admin_StructureTest.res +12 -3
  23. package/tests/admin/Platform_Admin_StructureTest.res.mjs +4 -3
  24. package/tests/commandgenerator/CommandGeneratorCallbackTest.res +1 -1
  25. package/tests/commandgenerator/CommandGeneratorCallbackTest.res.mjs +1 -1
  26. package/tests/commandgenerator/CommandGeneratorFixtures.res +2 -0
  27. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +17 -3
  28. package/tests/commandgenerator/OwnerStampingTest.res.mjs +2 -2
  29. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +1 -1
  30. package/tests/dcb/DcbFixtures.res +18 -1
  31. package/tests/dcb/DcbFixtures.res.mjs +14 -0
  32. package/tests/dcb/DcbScopeInferenceTest.res +234 -0
  33. package/tests/dcb/DcbScopeInferenceTest.res.mjs +425 -13
  34. package/tests/dcb/DcbStateChangeSliceTest.res.mjs +22 -22
  35. package/tests/dcb/DcbTagTest.res +81 -68
  36. package/tests/dcb/DcbTagTest.res.mjs +88 -89
  37. package/tests/plugin/PluginStructureAccessTest.res +1 -0
  38. package/tests/plugin/PluginStructureAccessTest.res.mjs +1 -1
  39. package/tests/plugin/PluginStructureTest.res +47 -0
  40. package/tests/plugin/PluginStructureTest.res.mjs +50 -10
package/CHANGELOG.md CHANGED
@@ -3,6 +3,37 @@
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.263 (2026-09-16)
7
+
8
+ * feat(spec)!: infer the DCB partition key per slice, and drop @partitionTag where inference agrees ([6c10161](https://github.com/ReventlessDev/reventless-core/commit/6c101611d438beea4bc9175ae8ea0ea1f6f7493b))
9
+ ### Features
10
+
11
+ * **core:** fail the build when an extension cannot find its slice's partition key ([cf2b093](https://github.com/ReventlessDev/reventless-core/commit/cf2b093dd5dc7cf9425cdac919cd3910fc4aeefd))
12
+ * **spec:** let a slice's chapter break a partition-key tie ([2b9ae77](https://github.com/ReventlessDev/reventless-core/commit/2b9ae77e98eb42177d2bdd4977bc62bb7c7911aa))
13
+
14
+ ### BREAKING CHANGES
15
+
16
+ * DcbTag.derivePartitionTag is removed; a boundary whose
17
+ partition cannot be inferred no longer deploys.
18
+
19
+
20
+
21
+ # 3.0.0-alpha.262 (2026-09-11)
22
+
23
+ * feat(spec)!: a secret is a capability, so a test can know it in advance ([71507c6](https://github.com/ReventlessDev/reventless-core/commit/71507c6459b49178746f00c5040cb479d7fdade8))
24
+ ### Features
25
+
26
+ * **aws:** the first administrator is one command, not the console ([5f1a846](https://github.com/ReventlessDev/reventless-core/commit/5f1a84603b21a9feb691a08a148751c6c6e8eca8))
27
+
28
+ ### BREAKING CHANGES
29
+
30
+ * Capabilities.t gains a secrets member. A platform that builds
31
+ the record literally no longer compiles until it supplies one;
32
+ Capabilities.none.secrets is the refusing value. Code that spreads
33
+ Capabilities.none is unaffected.
34
+
35
+
36
+
6
37
  # 3.0.0-alpha.261 (2026-09-09)
7
38
 
8
39
  **Note:** Version bump only for package @reventlessdev/reventless-core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.261",
3
+ "version": "3.0.0-alpha.263",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,16 +29,16 @@
29
29
  "sury": "11.0.0-rc.2",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.33",
32
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.11",
33
- "@reventlessdev/rescript-node": "2.0.0-alpha.11",
34
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
32
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.12",
35
33
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.20",
34
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
+ "@reventlessdev/rescript-node": "2.0.0-alpha.12",
36
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.21",
37
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.12",
37
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
38
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.11",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.162",
40
- "@reventlessdev/reventless-interop": "3.0.0-alpha.36",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.134"
39
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.136",
40
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.164",
41
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.37"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -0,0 +1,60 @@
1
+ // The secret source both platforms use: `node:crypto`, which AWS Lambda and the
2
+ // local runtime both have. One implementation rather than two, for the same
3
+ // reason the log messaging transport is shared — a second copy is one edit away
4
+ // from differing on the part that matters.
5
+
6
+ let _bytes = (count: int): array<int> => {
7
+ // Read back as hex and re-split, because the binding treats a Buffer as opaque
8
+ // — it is only ever stringified or fed to an HMAC. Two hex characters is one
9
+ // byte, so this is a decode rather than a reinterpretation.
10
+ let hex = NodeCrypto.randomBytes(count)->NodeCrypto.bufferToString("hex")
11
+ Array.fromInitializer(~length=count, i =>
12
+ hex->String.substring(~start=i * 2, ~end=i * 2 + 2)->Int.fromString(~radix=16)->Option.getOr(0)
13
+ )
14
+ }
15
+
16
+ /**
17
+ Whether a random byte may be folded into an alphabet of `size`, or must be drawn
18
+ again.
19
+
20
+ 🚨 **`byte % size` is not uniform, and this predicate is the whole defence.** 256
21
+ is not a multiple of 10, so bytes 250–255 fold onto digits 0–5 and make those six
22
+ about four percent likelier each than the other four. An attacker guessing a
23
+ numeric code guesses the likelier half first. Discarding everything at or above
24
+ the largest whole multiple of `size` costs a few extra draws and makes every
25
+ character equally likely.
26
+
27
+ Separate and named because the skew it prevents is far too small to catch by
28
+ sampling the output — it is checked directly, over every byte value.
29
+ */
30
+ let accepts = (~byte: int, ~size: int): bool => byte < 256 - mod(256, size)
31
+
32
+ let _draw = (~length: int, ~from: string): string => {
33
+ let size = from->String.length
34
+ let out = []
35
+ while out->Array.length < length {
36
+ // Over-draw: most bytes are kept, so one round is nearly always enough, and
37
+ // a short round simply goes again.
38
+ _bytes((length - out->Array.length) * 2 + 8)->Array.forEach(byte =>
39
+ if accepts(~byte, ~size) && out->Array.length < length {
40
+ out->Array.push(from->String.charAt(mod(byte, size)))
41
+ }
42
+ )
43
+ }
44
+ out->Array.join("")
45
+ }
46
+
47
+ /**
48
+ The provider both platforms pass.
49
+
50
+ `length` is refused rather than clamped when it is not positive: a caller asking
51
+ for a zero-length token has a bug, and answering `""` would hand back something
52
+ that compares equal to nothing and settles every challenge.
53
+ */
54
+ let provider: Reventless.Secrets.t = {
55
+ randomToken: (~length, ~alphabet) =>
56
+ length <= 0
57
+ ? Error(Unavailable(`a token needs a positive length, not ${length->Int.toString}`))
58
+ : Ok(_draw(~length, ~from=Reventless.Secrets.characters(alphabet))),
59
+ hash: input => Ok(NodeCrypto.sha256Hex(input)),
60
+ }
@@ -0,0 +1,69 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as NodeCrypto from "@reventlessdev/rescript-node/src/NodeCrypto.res.mjs";
4
+ import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
5
+ import * as Nodecrypto from "node:crypto";
6
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
7
+ import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
8
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
9
+ import * as Secrets$Reventless from "@reventlessdev/reventless-spec/src/semantic/Secrets.res.mjs";
10
+
11
+ function _bytes(count) {
12
+ let hex = Nodecrypto.randomBytes(count).toString("hex");
13
+ return Stdlib_Array.fromInitializer(count, i => Stdlib_Option.getOr(Stdlib_Int.fromString(hex.substring((i << 1), (i << 1) + 2 | 0), 16), 0));
14
+ }
15
+
16
+ function accepts(byte, size) {
17
+ return byte < (256 - Primitive_int.mod_(256, size) | 0);
18
+ }
19
+
20
+ function _draw(length, from) {
21
+ let size = from.length;
22
+ let out = [];
23
+ while (out.length < length) {
24
+ _bytes(((length - out.length | 0) << 1) + 8 | 0).forEach(byte => {
25
+ if (accepts(byte, size) && out.length < length) {
26
+ out.push(from.charAt(Primitive_int.mod_(byte, size)));
27
+ return;
28
+ }
29
+ });
30
+ };
31
+ return out.join("");
32
+ }
33
+
34
+ function provider_randomToken(length, alphabet) {
35
+ if (length <= 0) {
36
+ return {
37
+ TAG: "Error",
38
+ _0: {
39
+ TAG: "Unavailable",
40
+ _0: `a token needs a positive length, not ` + length.toString()
41
+ }
42
+ };
43
+ } else {
44
+ return {
45
+ TAG: "Ok",
46
+ _0: _draw(length, Secrets$Reventless.characters(alphabet))
47
+ };
48
+ }
49
+ }
50
+
51
+ function provider_hash(input) {
52
+ return {
53
+ TAG: "Ok",
54
+ _0: NodeCrypto.sha256Hex(input)
55
+ };
56
+ }
57
+
58
+ let provider = {
59
+ randomToken: provider_randomToken,
60
+ hash: provider_hash
61
+ };
62
+
63
+ export {
64
+ _bytes,
65
+ accepts,
66
+ _draw,
67
+ provider,
68
+ }
69
+ /* NodeCrypto Not a pure module */
@@ -7,7 +7,7 @@ import * as PluginsReadModelSpec$ReventlessCore from "../plugin/lifecycle/Plugin
7
7
 
8
8
  let pluginId = "Platform";
9
9
 
10
- let pluginCommands = Plugin_Structure$ReventlessCore.extractCommandDefs(true, variantName => Api_Naming$ReventlessCore.adminField(PluginSpec$ReventlessCore.name + "_" + variantName), PluginSpec$ReventlessCore.commandAuthorization, PluginSpec$ReventlessCore.commandTransition, undefined, PluginSpec$ReventlessCore.commandSchema);
10
+ let pluginCommands = Plugin_Structure$ReventlessCore.extractCommandDefs(true, undefined, variantName => Api_Naming$ReventlessCore.adminField(PluginSpec$ReventlessCore.name + "_" + variantName), PluginSpec$ReventlessCore.commandAuthorization, PluginSpec$ReventlessCore.commandTransition, undefined, PluginSpec$ReventlessCore.commandSchema);
11
11
 
12
12
  let pluginAggregate_producedEventTypes = [];
13
13
 
@@ -61,7 +61,7 @@ function Make(Spec) {
61
61
  let fields = registeredFields !== undefined ? (
62
62
  registeredFields.length !== 0 ? registeredFields : []
63
63
  ) : [];
64
- let generateCommand = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, publishJsonsAndWait, Spec.name, Spec.commandSchema, "Aggregate", undefined);
64
+ let generateCommand = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, publishJsonsAndWait, Spec.name, Spec.commandSchema, "Aggregate", undefined, undefined);
65
65
  fields.forEach(field => bindHandler(field, generateCommand));
66
66
  } else {
67
67
  let resources = Component$ReventlessCore.outputs(commandTopic).resources;
@@ -7,10 +7,10 @@ The `id` field is the envelope id for the produced command message:
7
7
  - For **Aggregates**, it is required — the resolver populates it from the
8
8
  GraphQL `id: ID!` argument injected into the mutation SDL.
9
9
  - For **DCB StateChangeSlices**, it is optional. When the resolver does not
10
- supply one, `makeGenerateCommand` derives the envelope id from the command's
11
- `@partitionTag` / `@compositePartitionTag` field(s) using the same helpers
12
- the slice itself uses for its decision-model partition key, so envelope id
13
- and slice partition key share one source of truth.
10
+ supply one, `makeGenerateCommand` takes the envelope id from the command's
11
+ value of the handling slice's partition key — the key `Dcb_Builder` derives
12
+ for storage and the fence — so envelope id and slice partition key share one
13
+ source of truth.
14
14
  */
15
15
  type arguments = {id: string}
16
16
  type meta = {ip: array<string>, user: string, info: string}
@@ -112,6 +112,8 @@ let makeGenerateCommand = (
112
112
  ~commandSchema: S.t<unknown>,
113
113
  ~componentKind: commandComponentKind,
114
114
  ~stripIdFromParams: bool=true,
115
+ // The handling slice's partition; the envelope id is its value on the command.
116
+ ~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>=?,
115
117
  ): CommandGenerator.commandGenerator => {
116
118
  (payload: CommandGenerator.payload) =>
117
119
  Effect.sync(() => {
@@ -157,9 +159,11 @@ let makeGenerateCommand = (
157
159
  }
158
160
  // Resolve the envelope id.
159
161
  // - Aggregates always rely on the resolver-supplied id (id: ID! arg in SDL).
160
- // - DCB StateChangeSlices fall back to deriving the id from the command's
161
- // partition tag(s) when the resolver didn't supply one — this is what makes
162
- // @compositePartitionTag work end-to-end without per-call-site id stuffing.
162
+ // - DCB StateChangeSlices fall back to the value of the handling slice's
163
+ // partition field on the command when the resolver didn't supply one —
164
+ // what makes @compositePartitionTag work end-to-end without per-call-site
165
+ // id stuffing. A command without that field, or a generator that was not
166
+ // given the partition (the permissive `S.json` fallback), gets `""`.
163
167
  let suppliedId = payload.arguments.id
164
168
  let id = switch componentKind {
165
169
  | Aggregate => suppliedId
@@ -167,23 +171,12 @@ let makeGenerateCommand = (
167
171
  switch (suppliedId->Obj.magic: Nullable.t<string>)->Nullable.toOption {
168
172
  | Some(idValue) => idValue
169
173
  | None =>
170
- try {
171
- let derived = Reventless.DcbTag.derivePartitionTag([(serviceName, "", commandSchema)])
172
- switch derived {
173
- | Simple(pt) =>
174
- let tags = Reventless.DcbTag.extractTagsFromJson(commandSchema, commandJson)
175
- Reventless.DcbTag.getPartitionTagValue([{tags: tags}], pt)->Option.getOr("")
176
- | Composite(spec) =>
177
- let tags = Reventless.DcbTag.extractTagsFromJson(commandSchema, commandJson)
178
- Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec)
179
- }
180
- } catch {
181
- // Permissive schemas (e.g. S.json on the AppSync direct-invocation path)
182
- // have no tagged fields — derivePartitionTag throws. Fall back to "" so
183
- // existing transports keep their pre-fix behavior; the slice decoder
184
- // surfaces the missing id downstream as it did before.
185
- | _ => ""
186
- }
174
+ partitionTag->Option.mapOr("", pt =>
175
+ Reventless.DcbTag.extractTagsFromJson(
176
+ commandSchema,
177
+ commandJson,
178
+ )->Reventless.DcbTag.partitionValueOfTags(pt)
179
+ )
187
180
  }
188
181
  }
189
182
  (meta, commandJson, id)
@@ -59,7 +59,7 @@ function dropNullArguments(obj) {
59
59
  });
60
60
  }
61
61
 
62
- function makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, commandSchema, componentKind, $staropt$star) {
62
+ function makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, commandSchema, componentKind, $staropt$star, partitionTag) {
63
63
  return payload => {
64
64
  let stripIdFromParams = $staropt$star !== undefined ? $staropt$star : true;
65
65
  return Effect.flatMap(Effect.tap(Effect.sync(() => {
@@ -85,28 +85,7 @@ function makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, com
85
85
  ]].concat(params)) : commandStr;
86
86
  let suppliedId = payload.arguments.id;
87
87
  let id;
88
- if (componentKind === "Aggregate" || !(suppliedId == null)) {
89
- id = suppliedId;
90
- } else {
91
- try {
92
- let derived = DcbTag$Reventless.derivePartitionTag([[
93
- serviceName,
94
- "",
95
- commandSchema
96
- ]]);
97
- if (derived.TAG === "Simple") {
98
- let tags = DcbTag$Reventless.extractTagsFromJson(commandSchema, commandJson);
99
- id = Stdlib_Option.getOr(DcbTag$Reventless.getPartitionTagValue([{
100
- tags: tags
101
- }], derived._0), "");
102
- } else {
103
- let tags$1 = DcbTag$Reventless.extractTagsFromJson(commandSchema, commandJson);
104
- id = DcbTag$Reventless.getCompositePartitionKeyValue(tags$1, derived._0);
105
- }
106
- } catch (exn) {
107
- id = "";
108
- }
109
- }
88
+ id = componentKind === "Aggregate" || !(suppliedId == null) ? suppliedId : Stdlib_Option.mapOr(partitionTag, "", pt => DcbTag$Reventless.partitionValueOfTags(DcbTag$Reventless.extractTagsFromJson(commandSchema, commandJson), pt));
110
89
  return [
111
90
  meta,
112
91
  commandJson,
@@ -181,7 +160,7 @@ function makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, com
181
160
 
182
161
  function Make(Spec) {
183
162
  return AggregateSpec => {
184
- let generateCommand = makeGenerateCommand(Spec.publishJsons, Spec.publishJsonsAndWait, AggregateSpec.name, AggregateSpec.commandSchema, "Aggregate", undefined);
163
+ let generateCommand = makeGenerateCommand(Spec.publishJsons, Spec.publishJsonsAndWait, AggregateSpec.name, AggregateSpec.commandSchema, "Aggregate", undefined, undefined);
185
164
  return {
186
165
  generateCommand: generateCommand
187
166
  };
@@ -1,26 +1,5 @@
1
- // Converts a file:// module URL to a path relative to the project root (located by lerna.json),
2
- // resolving symlinks so workspace packages show their source location rather than node_modules.
3
- // Falls back to the bare URL string on any error.
4
1
  let log = Logger.fromEnv()
5
2
 
6
- let toRelativePath: string => string = %raw(`function toRelativePath(moduleUrl) {
7
- try {
8
- const fs = process.getBuiltinModule('node:fs');
9
- const path = process.getBuiltinModule('node:path');
10
- const { fileURLToPath } = process.getBuiltinModule('node:url');
11
- const builderReal = fs.realpathSync(fileURLToPath(import.meta.url));
12
- const sliceReal = fs.realpathSync(fileURLToPath(moduleUrl));
13
- let dir = path.dirname(builderReal);
14
- while (dir !== path.dirname(dir)) {
15
- if (fs.existsSync(path.join(dir, 'lerna.json'))) break;
16
- dir = path.dirname(dir);
17
- }
18
- return path.relative(dir, sliceReal).replace(/\.mjs$/, '.res');
19
- } catch(e) {
20
- return moduleUrl.replace('file://', '');
21
- }
22
- }`)
23
-
24
3
  // The tag of one compiled variant arm: `{TAG: "<Name>", …}` for a payload-bearing
25
4
  // constructor, a bare string const for a payload-less one.
26
5
  let variantTag = (variant: S.t<unknown>) =>
@@ -182,12 +161,6 @@ module Make = (
182
161
  Sc.Spec.name,
183
162
  Sc.Spec.eventSchema->S.castToUnknown,
184
163
  ))
185
- let producedNamed =
186
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => (
187
- Sc.Spec.name,
188
- toRelativePath(Sc.Spec.moduleUrl),
189
- Sc.Spec.eventSchema->S.castToUnknown,
190
- ))
191
164
  let consumed =
192
165
  stateChangeSlices
193
166
  ->Array.map((module(Sc: StateChangeSlice.T)) => (
@@ -253,7 +226,39 @@ module Make = (
253
226
  indexes
254
227
  }
255
228
 
256
- let partitionTag = Reventless.DcbTag.derivePartitionTag(producedNamed)
229
+ let sliceSchemas = stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => {
230
+ Reventless.DcbTag.name: Sc.Spec.name,
231
+ commandSchema: Sc.Spec.commandSchema->S.castToUnknown,
232
+ consumedEventSchema: Sc.Spec.consumedEventSchema->S.castToUnknown,
233
+ eventSchema: Sc.Spec.eventSchema->S.castToUnknown,
234
+ moduleUrl: Sc.Spec.moduleUrl,
235
+ })
236
+ let inferenceShapes = sliceSchemas->Array.map(Reventless.DcbTag.sliceShape)
237
+
238
+ // A `@partitionTag` inference contradicts would steer storage, fence and read
239
+ // scope to a key the slice only references, so it stops the build. One that
240
+ // inference agrees with is noise, and says so.
241
+ let hintIssues = Reventless.DcbValidation.validatePartitionHintsVsInference(
242
+ ~shapes=inferenceShapes,
243
+ )
244
+ hintIssues.redundancies->Array.forEach(e =>
245
+ log.info(~comp="Dcb_Builder", `DCB partition (${e.sliceName}): ${e.message}`)
246
+ )
247
+ if hintIssues.contradictions->Array.length > 0 {
248
+ let detail =
249
+ hintIssues.contradictions
250
+ ->Array.map(e => `${e.sliceName}: ${e.message}`)
251
+ ->Array.join(" | ")
252
+ log.error(~comp="Dcb_Builder", `DCB partition contradiction (${name}): ${detail}`)
253
+ JsError.throwWithMessage(`DCB partition contradiction (${name}): ${detail}`)
254
+ }
255
+
256
+ // Where each event is stored — and so its fence, and each command's envelope
257
+ // id. Throws, naming the slice, when a partition cannot be inferred.
258
+ let boundaryPartition = Reventless.DcbTag.deriveBoundaryPartition(sliceSchemas)
259
+ let partitionTag = boundaryPartition.partitionTag
260
+ let partitionTagOf = sliceName =>
261
+ boundaryPartition->Reventless.DcbTag.slicePartitionTag(sliceName)
257
262
 
258
263
  // Tag keys declared `@crossPartition` across the produced event schemas.
259
264
  // Derived once at build time (the scope is a property of the tag key, and
@@ -325,15 +330,6 @@ module Make = (
325
330
  // preserved (generalised rule 3). Safety guard: if any slice's partition is
326
331
  // ambiguous we keep the annotated values for the whole boundary rather than
327
332
  // thread a partial inference. See docs/plans/done/dcb-tag-scope-inference.md.
328
- let inferenceShapes =
329
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
330
- Reventless.DcbTag.sliceShapeFromSchemas(
331
- ~name=Sc.Spec.name,
332
- ~commandSchema=Sc.Spec.commandSchema->S.castToUnknown,
333
- ~consumedEventSchema=Sc.Spec.consumedEventSchema->S.castToUnknown,
334
- ~eventSchema=Sc.Spec.eventSchema->S.castToUnknown,
335
- )
336
- )
337
333
  let inferred = Reventless.DcbScopeInference.infer(inferenceShapes)
338
334
  if inferred.crossPartitionTagKeys != crossPartitionTagKeys {
339
335
  log.info(
@@ -388,14 +384,7 @@ module Make = (
388
384
  // (DcbCommandTopicEntryPoint.mjs) route through `DcbTag.deriveEffectiveScope`
389
385
  // so the runtime decision query cannot drift from this scope — see
390
386
  // docs/analysis/dcb-runtime-scope-annotation-drift.md.
391
- let effective = Reventless.DcbTag.deriveEffectiveScope(
392
- stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) => {
393
- Reventless.DcbTag.name: Sc.Spec.name,
394
- commandSchema: Sc.Spec.commandSchema->S.castToUnknown,
395
- consumedEventSchema: Sc.Spec.consumedEventSchema->S.castToUnknown,
396
- eventSchema: Sc.Spec.eventSchema->S.castToUnknown,
397
- }),
398
- )
387
+ let effective = Reventless.DcbTag.deriveEffectiveScope(sliceSchemas)
399
388
  let effectiveCrossPartitionTagKeys = effective.crossPartitionTagKeys
400
389
  let effectiveTagKeysByEventType = effective.tagKeysByEventType
401
390
 
@@ -487,6 +476,7 @@ module Make = (
487
476
  ~publishJsons,
488
477
  ~tagKeysByEventType=effectiveTagKeysByEventType,
489
478
  ~crossPartitionTagKeys=effectiveCrossPartitionTagKeys,
479
+ ~partitionTag=?partitionTagOf(StateChangeSlice.Spec.name),
490
480
  ~runtime=?componentRuntime->Dict.get(StateChangeSlice.Spec.name),
491
481
  ~opts,
492
482
  )
@@ -507,6 +497,7 @@ module Make = (
507
497
  ~publishJsons=asyncPublishJsons,
508
498
  ~tagKeysByEventType=effectiveTagKeysByEventType,
509
499
  ~crossPartitionTagKeys=effectiveCrossPartitionTagKeys,
500
+ ~partitionTag=?partitionTagOf(StateChangeSlice.Spec.name),
510
501
  ~runtime=?componentRuntime->Dict.get(StateChangeSlice.Spec.name),
511
502
  ~opts,
512
503
  )
@@ -572,6 +563,7 @@ module Make = (
572
563
  ~commandSchema=S.Spec.commandSchema->Obj.magic,
573
564
  ~componentKind=CommandGenerator_Callback.StateChangeSlice,
574
565
  ~stripIdFromParams=false,
566
+ ~partitionTag=?partitionTagOf(S.Spec.name),
575
567
  )
576
568
  Api_Naming.sliceMutationFields(
577
569
  ~plugin=apiNamePrefix,
@@ -602,6 +594,7 @@ module Make = (
602
594
  ~commandSchema=S.Spec.commandSchema->Obj.magic,
603
595
  ~componentKind=CommandGenerator_Callback.StateChangeSlice,
604
596
  ~stripIdFromParams=false,
597
+ ~partitionTag=?partitionTagOf(S.Spec.name),
605
598
  )
606
599
  Api_Naming.sliceMutationFields(
607
600
  ~plugin=apiNamePrefix,
@@ -813,7 +806,7 @@ module Make = (
813
806
  `Two DCB slices in "${name}" both declare the command "${tag}". ` ++
814
807
  "Owner stamping resolves a command by that name alone, so it cannot " ++ "tell them apart. Rename one of the constructors.",
815
808
  )
816
- | None => byTag->Dict.set(tag, commandSchema)
809
+ | None => byTag->Dict.set(tag, (commandSchema, partitionTagOf(S.Spec.name)))
817
810
  }
818
811
  })
819
812
  }
@@ -825,7 +818,7 @@ module Make = (
825
818
  dcbCommandTopic
826
819
  ->Component.operations
827
820
  ->Pulumi.Output.apply(ops => {
828
- let make = commandSchema =>
821
+ let make = (commandSchema, partitionTag) =>
829
822
  CommandGenerator_Callback.makeGenerateCommand(
830
823
  ~publishJsons=ops.publishJsons,
831
824
  ~publishJsonsAndWait=?ops.publishJsonsAndWait,
@@ -833,16 +826,19 @@ module Make = (
833
826
  ~commandSchema,
834
827
  ~componentKind=CommandGenerator_Callback.StateChangeSlice,
835
828
  ~stripIdFromParams=false,
829
+ ~partitionTag?,
836
830
  )
837
831
  let byTag = Dict.make()
838
832
  dcbCommandSchemasByTag
839
833
  ->Dict.toArray
840
- ->Array.forEach(((tag, schema)) => byTag->Dict.set(tag, make(schema)))
834
+ ->Array.forEach(((tag, (schema, partitionTag))) =>
835
+ byTag->Dict.set(tag, make(schema, partitionTag))
836
+ )
841
837
  // A command no slice claims keeps the permissive generator — what every
842
838
  // command got before this change — so nothing that reaches this handler
843
839
  // by another route starts failing. It also cannot stamp, which is why
844
840
  // the unknown case is logged rather than left to look like a success.
845
- (byTag, make(S.json->S.castToUnknown))
841
+ (byTag, make(S.json->S.castToUnknown, None))
846
842
  })
847
843
 
848
844
  let dcbHandler =