@reventlessdev/reventless-spec 3.0.0-alpha.97 → 3.0.0-alpha.99

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,21 @@
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.99 (2026-08-03)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **geocoding:** calibrate the confidence rule against a real index ([cd72b27](https://github.com/ReventlessDev/reventless-core/commit/cd72b2741460c871db2915a8603f7837b8191187))
11
+
12
+
13
+ # 3.0.0-alpha.98 (2026-08-03)
14
+
15
+ ### Features
16
+
17
+ * **aws:** let a deployment hand its plugins a geocoder ([143bf41](https://github.com/ReventlessDev/reventless-core/commit/143bf4137765362ce07dd42db0f4a57057da9f13))
18
+ * **outbound:** let an outbound slice read an aggregate, and geocode addresses with it ([867e63e](https://github.com/ReventlessDev/reventless-core/commit/867e63e774ebc8b78b2b19c78645c8a12a8d06f6))
19
+
20
+
6
21
  # 3.0.0-alpha.97 (2026-08-02)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.97",
3
+ "version": "3.0.0-alpha.99",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -76,6 +76,29 @@ module type Spec = {
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
78
 
79
+ /**
80
+ Event sources this slice subscribes to, by topic key.
81
+
82
+ `[]` — the default and the historical behaviour — means this plugin's own DCB
83
+ event log. Naming sources explicitly subscribes to them instead: an Aggregate's
84
+ `Spec.name`, or a DCB source name (conventionally `"<pluginName>DcbEventLog"`),
85
+ matching the keys `AutomationSlice` mappings already use.
86
+
87
+ This exists because an outbound slice is the framework's one component for
88
+ *calling an external service and feeding the answer back*, and that job is not
89
+ specific to DCB-modelled entities. An Aggregate whose events should trigger an
90
+ outbound call had no route to one while this list was hard-wired.
91
+
92
+ Unlike `AutomationSlice`, the sources are a flat list rather than per-source
93
+ `Mapping` modules. An automation needs a `resolve` per source (a different
94
+ event completes the item depending on where it came from); an outbound item is
95
+ resolved by its own `translate` succeeding, so the only thing that varies per
96
+ source is the decode — and the one `consumedEvent` union already covers that.
97
+ The cost of the flat form is that two sources sharing an event-type name are
98
+ indistinguishable; declare only the sources whose events you mean.
99
+ */
100
+ let sourceNames: array<string>
101
+
79
102
  /** Optional display name of the foreign system this anti-corruption slice publishes
80
103
  to (e.g. `"EmailService"`). Drives the **external box** drawn outside the plugin
81
104
  in the Event Graph / Context Map (see docs/plans/translation-external-boxes.md).
@@ -94,8 +117,15 @@ module type Translation = {
94
117
  Collect: map an incoming event to zero or more new outbound items.
95
118
  Each item has an `id` (deduplication key) and the `outboundItem` payload.
96
119
  Returns empty array if this event is not relevant.
120
+
121
+ `~sourceId` is the id of the entity the event was published for — the envelope's
122
+ `id`, not part of the event payload. A DCB event usually names its own subject
123
+ in the payload (`OrderPlaced({orderId, …})`) and can ignore this; an Aggregate's
124
+ event generally does not, because the aggregate id is what addressed it in the
125
+ first place. Without this the outbound item for `Registered({email, address})`
126
+ would have no way to say *which customer* it is for.
97
127
  */
98
- let collect: Spec.consumedEvent => array<(string, Spec.outboundItem)>
128
+ let collect: (Spec.consumedEvent, ~sourceId: string) => array<(string, Spec.outboundItem)>
99
129
 
100
130
  /**
101
131
  Translate: call the external service for a single outbound item.
@@ -0,0 +1,127 @@
1
+ /**
2
+ Turning an address into a point, and deciding whether to believe the answer.
3
+
4
+ The *transport* is provider-specific — Amazon Location, a self-hosted Nominatim,
5
+ a vendor's HTTP API — and lives with its provider. What lives here is everything
6
+ that is the same regardless of who answers: the shape of an answer, the two ways
7
+ a lookup can fail, and the rule for when a ranked list is confident enough to
8
+ write a coordinate into an event log.
9
+
10
+ That split matters because the confidence rule is the part that is easy to get
11
+ wrong and expensive to get wrong twice. A caller that re-derives it — takes
12
+ `results[0]` because the list was ranked — produces a plausible marker in the
13
+ wrong region, drawn without an error anywhere. Deciding it once, here, is what
14
+ stops each transport inventing its own answer.
15
+ */
16
+
17
+ /** One candidate a geocoder returned. */
18
+ type candidate = {
19
+ /** The provider's canonical rendering of the address it matched. */
20
+ label: string,
21
+ point: GeoPoint.t,
22
+ /**
23
+ How well this result matches the query, 0…1.
24
+
25
+ `None` when the provider does not score its results. That is not the same as a
26
+ low score and must not be read as a high one — `confidentMatch` declines it,
27
+ because an unscored list cannot support an unattended decision.
28
+ */
29
+ relevance: option<float>,
30
+ }
31
+
32
+ /**
33
+ Why a lookup produced no usable point.
34
+
35
+ Two constructors rather than one message because the caller's retry decision
36
+ turns on exactly this distinction, and a `switch` is the only form of it that
37
+ cannot be got wrong. A translator that cannot tell "no such address" from "the
38
+ service is down" turns one outage into a permanent verdict on every address in
39
+ flight.
40
+ */
41
+ type failure =
42
+ | /** The provider could not be reached, or refused the call. Retry. */
43
+ Unavailable(string)
44
+ | /** The provider answered, and had nothing for this text. Do not retry. */
45
+ NoMatch
46
+
47
+ /**
48
+ The port a caller reaches a geocoder through.
49
+
50
+ Everything above says what an answer looks like; this says how one is asked for.
51
+ Written down as a type before anything is injected against it, so that swapping
52
+ the implementation underneath a caller is a change of *supplier* rather than a
53
+ change of call site: whatever eventually hands a translator its geocoder — an
54
+ HTTP client reading an endpoint out of its environment, a provider SDK called
55
+ directly — has to satisfy this, and the `await search(~text=…)` in the caller
56
+ does not move.
57
+
58
+ `~text` is the address as a human typed it, unnormalised. Normalising it is the
59
+ provider's job, and its canonical rendering comes back as `candidate.label`.
60
+ */
61
+ type search = (~text: string) => promise<result<array<candidate>, failure>>
62
+
63
+ /**
64
+ The default confidence floor, calibrated against Amazon Location's Esri index.
65
+
66
+ Measured, not guessed — and the measurement moved it a long way. Esri does not
67
+ spread its scores over 0…1: everything it is willing to return at all lands in
68
+ roughly 0.9…1.0, so a floor of `0.8` admitted almost every answer and left the
69
+ ambiguity margin doing all the work. The separation is up at the top of the
70
+ range instead — in a 24-address corpus the correct pinpoint matches scored
71
+ ≥ 0.988 while the wrong ones (a misspelling resolved to the wrong state, a
72
+ street name matched to a different street in the right city) scored 0.913…0.958.
73
+
74
+ `0.97` sits in that gap. The wider consequence is that these numbers are
75
+ *provider-calibrated* even though everything else in this module is
76
+ provider-neutral, which is exactly why both are labelled arguments: a geocoder
77
+ that scores on a different curve needs its own pair, and the defaults are the
78
+ Esri answer rather than a universal one.
79
+ */
80
+ let defaultMinRelevance = 0.97
81
+
82
+ /**
83
+ How close the runner-up may come before the answer is called ambiguous.
84
+
85
+ Deliberately small, for the same reason the floor is large. Genuine ambiguity in
86
+ Esri shows up as a *tie* — "Springfield" returns five states at exactly 1.0,
87
+ "221B Baker" five towns at exactly 0.8222 — not as a near miss. A wide margin
88
+ does not catch more of those; it only starts rejecting clear winners, because a
89
+ correct match at 0.991 routinely has a plausible runner-up at 0.962.
90
+ */
91
+ let defaultAmbiguityMargin = 0.01
92
+
93
+ /**
94
+ The one candidate confident enough to store, or `None`.
95
+
96
+ Two ways to be unsure, and both decline:
97
+
98
+ - the top candidate scores below `minRelevance` — the provider matched
99
+ something, loosely;
100
+ - the runner-up scores within `ambiguityMargin` of the top — the provider
101
+ matched several things about equally well, which is what a bare town name
102
+ does.
103
+
104
+ `None` means "ask a human", not "no result". The candidates are still there for
105
+ a caller that wants to show them.
106
+ */
107
+ let confidentMatch = (
108
+ candidates: array<candidate>,
109
+ ~minRelevance: float=defaultMinRelevance,
110
+ ~ambiguityMargin: float=defaultAmbiguityMargin,
111
+ ): option<candidate> =>
112
+ switch candidates->Array.get(0) {
113
+ | None => None
114
+ | Some(top) =>
115
+ switch top.relevance {
116
+ | None => None
117
+ | Some(topScore) =>
118
+ if topScore < minRelevance {
119
+ None
120
+ } else {
121
+ switch candidates->Array.get(1)->Option.flatMap(c => c.relevance) {
122
+ | Some(runnerUp) if topScore -. runnerUp < ambiguityMargin => None
123
+ | _ => Some(top)
124
+ }
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,36 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+
5
+ function confidentMatch(candidates, minRelevanceOpt, ambiguityMarginOpt) {
6
+ let minRelevance = minRelevanceOpt !== undefined ? minRelevanceOpt : 0.97;
7
+ let ambiguityMargin = ambiguityMarginOpt !== undefined ? ambiguityMarginOpt : 0.01;
8
+ let top = candidates[0];
9
+ if (top === undefined) {
10
+ return;
11
+ }
12
+ let topScore = top.relevance;
13
+ if (topScore === undefined) {
14
+ return;
15
+ }
16
+ if (topScore < minRelevance) {
17
+ return;
18
+ }
19
+ let runnerUp = Stdlib_Option.flatMap(candidates[1], c => c.relevance);
20
+ if (runnerUp !== undefined && topScore - runnerUp < ambiguityMargin) {
21
+ return;
22
+ } else {
23
+ return top;
24
+ }
25
+ }
26
+
27
+ let defaultMinRelevance = 0.97;
28
+
29
+ let defaultAmbiguityMargin = 0.01;
30
+
31
+ export {
32
+ defaultMinRelevance,
33
+ defaultAmbiguityMargin,
34
+ confidentMatch,
35
+ }
36
+ /* No side effect */