@reventlessdev/reventless-spec 3.0.0-alpha.126 → 3.0.0-alpha.127

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,6 +3,23 @@
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.127 (2026-09-02)
7
+
8
+ * feat(aws)!: the messaging sender is configuration, and a stack can choose to only log ([23b8b4b](https://github.com/ReventlessDev/reventless-core/commit/23b8b4bfe9c70555de4d74266ca686cb427485ca))
9
+ ### Features
10
+
11
+ * **dcb:** a boundary that cannot derive its scope says so ([db79969](https://github.com/ReventlessDev/reventless-core/commit/db79969df36bd90607425f6a22b4624227cfc4a0))
12
+
13
+ ### BREAKING CHANGES
14
+
15
+ * `Capability_Messaging_Ses.make` is replaced by
16
+ `Capability_Messaging.make(~name)`, which reads the transport and the address
17
+ from config; the SES module keeps only `emailSender`. A deployment that named its
18
+ sender in code must move it to `platform:messagingEmailSender` or the deploy is
19
+ refused.
20
+
21
+
22
+
6
23
  # 3.0.0-alpha.126 (2026-09-01)
7
24
 
8
25
  **Note:** Version bump only for package @reventlessdev/reventless-spec
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.126",
3
+ "version": "3.0.0-alpha.127",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -130,6 +130,40 @@ let foreignConsumedKeys = (s: sliceShape): array<string> => {
130
130
  )
131
131
  }
132
132
 
133
+ /**
134
+ Which consumed arms cost a slice its partition — the actionable half of the
135
+ "no own partition key" ambiguity.
136
+
137
+ Rule 1 subtracts `foreignConsumedKeys` from `producedKeys`, so a slice whose only
138
+ produced key is also declared on a consumed arm it does not itself produce is left
139
+ with nothing. In practice that is almost always one mistake: a *lifecycle* arm
140
+ naming the id it is already partitioned by (`ProductAdded({productId})` on a slice
141
+ whose every event carries `productId`), which reads as "this id comes from a
142
+ foreign producer" when it is in fact this slice's own partition.
143
+
144
+ The ambiguity message can only say a partition was not found. This says which arm
145
+ to delete, which is the whole difference between a diagnostic and a puzzle — so it
146
+ is here, beside the rule that produces the ambiguity, rather than duplicated by
147
+ each surface that reports one.
148
+
149
+ Returns one entry per produced key that a foreign arm claims, naming those arms.
150
+ Empty when the slice has a partition (nothing to explain) or when the produced
151
+ keys are simply too many (a different ambiguity, answered by `@partitionTag`).
152
+ */
153
+ let partitionBlockers = (s: sliceShape): array<(string, array<string>)> => {
154
+ let foreign = foreignConsumedKeys(s)
155
+ let ownProduced = Set.make()
156
+ s.produced->Array.forEach(e => ownProduced->Set.add(e.eventType))
157
+ producedKeys(s)
158
+ ->Array.filter(k => foreign->Array.includes(k))
159
+ ->Array.map(k => (
160
+ k,
161
+ s.consumed
162
+ ->Array.filter(e => !(ownProduced->Set.has(e.eventType)) && e->keysOfEvent->Array.includes(k))
163
+ ->Array.map(e => e.eventType),
164
+ ))
165
+ }
166
+
133
167
  /**
134
168
  Per-slice cross-partition keys for the test harness, which has no global owner
135
169
  map: a foreign-read key that is not the slice's own partition is read across
@@ -169,9 +203,16 @@ let infer = (slices: array<sliceShape>): derived => {
169
203
  switch produced->Array.filter(k => !(foreign->Array.includes(k))) {
170
204
  | [single] => partitionBySlice->Dict.set(s.sliceName, single)
171
205
  | [] =>
206
+ // Name the arms that took the key. Almost always a lifecycle arm
207
+ // declaring the id the slice is already partitioned by, where the fix is
208
+ // to drop the field rather than to annotate around it.
209
+ let blame =
210
+ partitionBlockers(s)
211
+ ->Array.map(((key, arms)) => `${arms->Array.join("/")} declares ${key}`)
212
+ ->Array.join("; ")
172
213
  let _ = ambiguities->Array.push((
173
214
  s.sliceName,
174
- "no own partition key — every produced *Id is read from a foreign producer (pure join?); add an explicit @partitionTag",
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`,
175
216
  ))
176
217
  | many =>
177
218
  let _ = ambiguities->Array.push((
@@ -50,6 +50,24 @@ function foreignConsumedKeys(s) {
50
50
  }));
51
51
  }
52
52
 
53
+ function partitionBlockers(s) {
54
+ let foreign = foreignConsumedKeys(s);
55
+ let ownProduced = new Set();
56
+ s.produced.forEach(e => {
57
+ ownProduced.add(e.eventType);
58
+ });
59
+ return producedKeys(s).filter(k => foreign.includes(k)).map(k => [
60
+ k,
61
+ s.consumed.filter(e => {
62
+ if (ownProduced.has(e.eventType)) {
63
+ return false;
64
+ } else {
65
+ return e.idFields.map(tagKeyOf).includes(k);
66
+ }
67
+ }).map(e => e.eventType)
68
+ ]);
69
+ }
70
+
53
71
  function crossPartitionForSlice(s) {
54
72
  let foreign = foreignConsumedKeys(s);
55
73
  let scalar = commandScalarKeys(s);
@@ -93,12 +111,13 @@ function infer(slices) {
93
111
  s.sliceName,
94
112
  `multiple candidate partition keys (` + many.join(", ") + `) — add an explicit @partitionTag`
95
113
  ]);
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
- ]);
114
+ return;
101
115
  }
116
+ let blame = partitionBlockers(s).map(param => param[1].join("/") + ` declares ` + param[0]).join("; ");
117
+ ambiguities.push([
118
+ s.sliceName,
119
+ `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`
120
+ ]);
102
121
  return;
103
122
  }
104
123
  let single = many[0];
@@ -171,6 +190,7 @@ export {
171
190
  consumedKeys,
172
191
  commandScalarKeys,
173
192
  foreignConsumedKeys,
193
+ partitionBlockers,
174
194
  crossPartitionForSlice,
175
195
  infer,
176
196
  }
@@ -1008,6 +1008,15 @@ The DCB decision-read scope threaded into every StateChangeSlice callback:
1008
1008
  type effectiveScope = {
1009
1009
  crossPartitionTagKeys: array<string>,
1010
1010
  tagKeysByEventType: dict<array<string>>,
1011
+ /** Slices whose partition did not resolve, as `(slice, reason)` — non-empty
1012
+ means the two fields above are the *annotated* fallback, not the derivation. */
1013
+ ambiguities: array<(string, string)>,
1014
+ /** Cross-partition keys the inference found that the fallback does not carry.
1015
+ Non-empty is the harmful case and only that: a reference read silently
1016
+ narrows to its own partition, so a slice decides against events it cannot
1017
+ see and rejects a command whose facts are in the log. Empty (even with
1018
+ ambiguities) means the fallback happens to agree and nothing is lost. */
1019
+ droppedCrossPartitionTagKeys: array<string>,
1011
1020
  }
1012
1021
 
1013
1022
  /**
@@ -1023,6 +1032,12 @@ the runtime decision query cannot diverge from the storage/GSI scope. Before thi
1023
1032
  existed the entry point re-derived scope from annotations alone, silently dropping
1024
1033
  inferred cross-partition reference reads — see
1025
1034
  `docs/analysis/dcb-runtime-scope-annotation-drift.md`.
1035
+
1036
+ The fallback reports itself. `ambiguities` says it happened and
1037
+ `droppedCrossPartitionTagKeys` says whether it cost anything, so a caller can
1038
+ distinguish a fallback that agrees with the derivation from one that quietly
1039
+ narrows a reference read — the second is a wrong answer waiting for the first
1040
+ command that needs the key, and callers are expected to raise it.
1026
1041
  */
1027
1042
  let deriveEffectiveScope = (slices: array<sliceSchemas>): effectiveScope => {
1028
1043
  let producedSchemas = slices->Array.map(s => s.eventSchema)
@@ -1055,6 +1070,10 @@ let deriveEffectiveScope = (slices: array<sliceSchemas>): effectiveScope => {
1055
1070
  {
1056
1071
  crossPartitionTagKeys: useInferred ? inferred.crossPartitionTagKeys : annotatedCross,
1057
1072
  tagKeysByEventType: useInferred ? inferred.tagKeysByEventType : annotatedTagKeys,
1073
+ ambiguities: inferred.ambiguities,
1074
+ droppedCrossPartitionTagKeys: useInferred
1075
+ ? []
1076
+ : inferred.crossPartitionTagKeys->Array.filter(k => !(annotatedCross->Array.includes(k))),
1058
1077
  }
1059
1078
  }
1060
1079
 
@@ -636,7 +636,9 @@ function deriveEffectiveScope(slices) {
636
636
  let useInferred = inferred.ambiguities.length === 0;
637
637
  return {
638
638
  crossPartitionTagKeys: useInferred ? inferred.crossPartitionTagKeys : annotatedCross,
639
- tagKeysByEventType: useInferred ? inferred.tagKeysByEventType : annotatedTagKeys
639
+ tagKeysByEventType: useInferred ? inferred.tagKeysByEventType : annotatedTagKeys,
640
+ ambiguities: inferred.ambiguities,
641
+ droppedCrossPartitionTagKeys: useInferred ? [] : inferred.crossPartitionTagKeys.filter(k => !annotatedCross.includes(k))
640
642
  };
641
643
  }
642
644
 
@@ -608,6 +608,27 @@ let renderComposition = (
608
608
  pluginNameToEnvBase(config.name) ++
609
609
  "_UI_BUNDLE_URL\"",
610
610
  )
611
+ // The DCB boundary's slices as schemas, outside `Make` because the scope they
612
+ // determine is a property of the specs and not of any platform. Emitted rather
613
+ // than hand-listed so a slice added tomorrow is in it: this is the only value
614
+ // from which the whole boundary's scope can be derived without applying the
615
+ // functor, which is what lets a test — and the repo's scope check — assert that
616
+ // it resolves. A per-slice test cannot: one slice never has a boundary.
617
+ if resolved.stateChangeSlices->Array.length > 0 {
618
+ lines->Array.push("")
619
+ lines->Array.push("let dcbSliceSchemas: array<Reventless.DcbTag.sliceSchemas> = [")
620
+ resolved.stateChangeSlices->Array.forEach(stem =>
621
+ lines->Array.push(
622
+ " {" ++
623
+ `name: ${stem}.name, ` ++
624
+ `commandSchema: ${stem}.commandSchema->S.castToUnknown, ` ++
625
+ `consumedEventSchema: ${stem}.consumedEventSchema->S.castToUnknown, ` ++
626
+ `eventSchema: ${stem}.eventSchema->S.castToUnknown` ++ "},",
627
+ )
628
+ )
629
+ lines->Array.push("]")
630
+ }
631
+
611
632
  lines->Array.push("")
612
633
  lines->Array.push("module Make = (Platform: ReventlessInfra.Platform.T) => {")
613
634
 
@@ -399,6 +399,14 @@ function renderComposition(config, resolved, componentChapters) {
399
399
  lines.push("// AUTO-GENERATED — do not edit. Run `npm run generate` to update.");
400
400
  lines.push("");
401
401
  lines.push("@val external uiBundleUrl: option<string> = \"process.env." + pluginNameToEnvBase(config.name) + "_UI_BUNDLE_URL\"");
402
+ if (resolved.stateChangeSlices.length !== 0) {
403
+ lines.push("");
404
+ lines.push("let dcbSliceSchemas: array<Reventless.DcbTag.sliceSchemas> = [");
405
+ resolved.stateChangeSlices.forEach(stem => {
406
+ lines.push(" {" + (`name: ` + stem + `.name, `) + (`commandSchema: ` + stem + `.commandSchema->S.castToUnknown, `) + (`consumedEventSchema: ` + stem + `.consumedEventSchema->S.castToUnknown, `) + (`eventSchema: ` + stem + `.eventSchema->S.castToUnknown`) + "},");
407
+ });
408
+ lines.push("]");
409
+ }
402
410
  lines.push("");
403
411
  lines.push("module Make = (Platform: ReventlessInfra.Platform.T) => {");
404
412
  let push = sectionLines => {
@@ -45,6 +45,30 @@ let channelToString = (channel: channel): string =>
45
45
  | Push => "Push"
46
46
  }
47
47
 
48
+ /**
49
+ The `From:` header a deployment's email sender presents as: the bare address, or
50
+ a display name in front of it.
51
+
52
+ Provider-neutral for the reason everything else here is — the header is the
53
+ RFC's, not a transport's, and two backends formatting it apart would present the
54
+ same deployment under two names. It is applied where the sender is *provisioned*
55
+ rather than at the send, so a transport receives one string and never has to know
56
+ whether a name was configured.
57
+
58
+ Quoted and escaped unconditionally rather than only when the name looks like it
59
+ needs it. The unquoted form excludes characters an ordinary shop name carries — a
60
+ comma above all, which would otherwise split the header into two addresses — and a
61
+ rule applied only where it looks necessary is a rule that gets the exceptions
62
+ wrong.
63
+ */
64
+ let fromHeader = (~displayName: option<string>, ~address: string): string =>
65
+ switch displayName {
66
+ | None => address
67
+ | Some(name) =>
68
+ let escaped = name->String.replaceAll("\\", "\\\\")->String.replaceAll("\"", "\\\"")
69
+ `"${escaped}" <${address}>`
70
+ }
71
+
48
72
  /**
49
73
  What to say.
50
74
 
@@ -23,6 +23,14 @@ function channelToString(channel) {
23
23
  }
24
24
  }
25
25
 
26
+ function fromHeader(displayName, address) {
27
+ if (displayName === undefined) {
28
+ return address;
29
+ }
30
+ let escaped = displayName.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
31
+ return `"` + escaped + `" <` + address + `>`;
32
+ }
33
+
26
34
  function retriable(failure) {
27
35
  switch (failure.TAG) {
28
36
  case "Unavailable" :
@@ -50,6 +58,7 @@ function supports(provider, recipient) {
50
58
  export {
51
59
  channelOf,
52
60
  channelToString,
61
+ fromHeader,
53
62
  retriable,
54
63
  failureReason,
55
64
  supports,