@reventlessdev/reventless-core 3.0.0-alpha.250 → 3.0.0-alpha.252

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 (70) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +7 -7
  3. package/src/admin/Platform_Admin_Structure.res +4 -1
  4. package/src/admin/Platform_Admin_Structure.res.mjs +4 -2
  5. package/src/admin/Platform_BakedManifest.res.mjs +3 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res +31 -1
  7. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +29 -1
  8. package/src/admin/Platform_PluginStructuresApi.res +80 -8
  9. package/src/admin/Platform_PluginStructuresApi.res.mjs +76 -2
  10. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs +8 -0
  11. package/src/components/Api/ApiNoApiHelpers.res +37 -3
  12. package/src/components/Api/ApiNoApiHelpers.res.mjs +29 -2
  13. package/src/components/AutomationSlice/AutomationSlice_Callback.res +4 -4
  14. package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +3 -3
  15. package/src/plugin/api/PluginBaseFragment.res +3 -3
  16. package/src/plugin/component/Plugin_Structure.res +787 -384
  17. package/src/plugin/component/Plugin_Structure.res.mjs +430 -20
  18. package/src/plugin/connect/PluginConnectExtension_Mapping.res +3 -6
  19. package/src/plugin/connect/PluginConnectExtension_Mapping.res.mjs +14 -2
  20. package/src/plugin/connect/PluginExtensionPoint_Plugin.res +0 -1
  21. package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +63 -3
  22. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res.mjs +22 -3
  23. package/src/plugin/lifecycle/PluginBehavior.res +11 -11
  24. package/src/plugin/lifecycle/PluginBehavior.res.mjs +11 -11
  25. package/src/plugin/lifecycle/PluginSpec.res +11 -3
  26. package/src/plugin/lifecycle/PluginSpec.res.mjs +11 -3
  27. package/tests/admin/AdminApiSchemaDriftTest.res +486 -0
  28. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +619 -0
  29. package/tests/admin/Platform_Admin_StructureTest.res +8 -3
  30. package/tests/admin/Platform_Admin_StructureTest.res.mjs +8 -2
  31. package/tests/admin/Platform_BakedManifestTest.res +2 -0
  32. package/tests/admin/Platform_BakedManifestTest.res.mjs +3 -1
  33. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +10 -0
  34. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +30 -8
  35. package/tests/admin/Platform_PluginStructuresApiTest.res +28 -1
  36. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +27 -5
  37. package/tests/aggregate/AggregateCacheTest.res.mjs +15 -3
  38. package/tests/aggregate/AggregateConflictTest.res.mjs +15 -3
  39. package/tests/aggregate/AggregateFixtures.res.mjs +15 -3
  40. package/tests/aggregate/AggregateSnapshotTest.res.mjs +21 -5
  41. package/tests/api/ApiNoApiTest.res +46 -0
  42. package/tests/api/ApiNoApiTest.res.mjs +49 -1
  43. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +27 -3
  44. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +12 -2
  45. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +2 -0
  46. package/tests/commandtopic/CommandTopicCallbackFixtures.res.mjs +12 -2
  47. package/tests/dcb/DcbFixtures.res.mjs +8 -0
  48. package/tests/dcb/DcbStateChangeSliceTest.res.mjs +18 -0
  49. package/tests/eventmapper/EventMapperFixtures.res.mjs +15 -3
  50. package/tests/extensionpoint/ExtensionPointFixtures.res.mjs +9 -1
  51. package/tests/extensionpoint/ExtensionPointOperationsTest.res.mjs +9 -1
  52. package/tests/message/MessageTest.res +2 -0
  53. package/tests/message/MessageTest.res.mjs +3 -1
  54. package/tests/plugin/PluginStructureAccessTest.res.mjs +2 -0
  55. package/tests/plugin/PluginStructureTest.res +294 -1
  56. package/tests/plugin/PluginStructureTest.res.mjs +373 -3
  57. package/tests/plugin/StateChangeSlice/PsAttachInvoice.res.mjs +8 -0
  58. package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +8 -0
  59. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +8 -0
  60. package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +8 -0
  61. package/tests/plugin/StateChangeSlice/PsPlaceOrder.res.mjs +8 -0
  62. package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +8 -0
  63. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +8 -0
  64. package/tests/plugin/StateChangeSlice/PsTransitionSwitch.res +59 -0
  65. package/tests/plugin/StateChangeSlice/PsTransitionSwitch.res.mjs +115 -0
  66. package/tests/plugin/StateChangeSlice/PsTypoStore.res.mjs +8 -0
  67. package/tests/plugin/StateChangeSlice/PsUploadAvatar.res.mjs +8 -0
  68. package/tests/plugin/StateChangeSlice/PsUploadImages.res.mjs +8 -0
  69. package/tests/plugin/pluginDefinitionRequiredScalars.txt +26 -0
  70. package/tests/util/CommandPublisherTest.res.mjs +9 -1
@@ -1,32 +1,18 @@
1
- // Pure metadata extraction from spec modules.
2
- // Extracts the pluginStructure (component graph metadata) that Auto UI, the
3
- // event-graph view, and MCP tooling consume. Kept standalone so it can be
4
- // unit-tested without spinning up a Platform.
1
+ // Pure metadata extraction from spec modules: the pluginStructure that Auto UI,
2
+ // the event graph and MCP tooling consume. Standalone, so it unit-tests without
3
+ // a Platform.
5
4
 
6
5
  let log = Logger.fromEnv()
7
6
 
8
- // What a field *is* is `SchemaType`'s question, and it is the IR every other
9
- // schema consumer here reads. The two predicates below are the only two answers
10
- // this module needs; both are stated over the IR rather than over sury shapes,
11
- // so a field that is a date, a reference or an enum is one for the label picker
12
- // too.
13
-
14
- // Whether a field can name a record. `Nullable` unwraps — an optional name is
15
- // still the entity's name, absent on some rows, which is a rendering question
16
- // rather than a declaration one. A `Semantic` wrapper is refused: it is the
17
- // schema saying the string means something other than prose (a storage ref, a
18
- // URL), and a bucket key is not a name. `DateTime`, `EntityId`, `Enum` and every
19
- // composite shape fall out of the catch-all without being named — which is the
20
- // point of reading an IR rather than restating it: the next shape it grows is
21
- // excluded before it exists.
7
+ // Whether a field can name a record, stated over `SchemaType`'s IR so every
8
+ // shape it grows is excluded before it exists. `Nullable` unwraps; a `Semantic`
9
+ // wrapper is refused — a bucket key or URL is not prose.
22
10
  let rec isLabelShape = (t: SchemaType.schemaType): bool =>
23
11
  switch t {
24
12
  | ScalarString => true
25
13
  | Nullable(inner) => isLabelShape(inner)
26
- // Written out rather than left to the catch-all: a union is a composite whose
27
- // rendering depends on which arm a row is in, so no single string names the
28
- // record. The label picker's fallback rung is a *guess*, and this is the shape
29
- // it would guess most confidently and most wrongly.
14
+ // Written out rather than left to the catch-all: no single string names a row
15
+ // whose rendering depends on its arm.
30
16
  | TaggedUnion(_, _) => false
31
17
  | _ => false
32
18
  }
@@ -53,19 +39,9 @@ let conventionalLabelNames = ["name", "title", "label", "displayname"]
53
39
  let shapeOfField = (~entityName: string, ~name: string, schema: S.t<unknown>): SchemaType.schemaType =>
54
40
  SchemaType.fromSury(~parentName=entityName, ~fieldName=name, schema)
55
41
 
56
- // Resolve the field that holds the entity's lifecycle, used to filter a per-row
57
- // command menu against each command's `allowedStates`. Resolution order:
58
- // 1. Field annotated `@lifecycle` (PPX-emitted; see StateAnnotations).
59
- // 2. Field literally named `"lifecycle"` whose IR shape is an enum (convention;
60
- // mirrors how labelField falls back to a conventionally-named field).
61
- // 3. None — filter is inert for this read model.
62
- //
63
- // The convention rung is keyed on `lifecycle` rather than `status` deliberately:
64
- // `status` is a promiscuous name — geocoding progress, todo-queue progress and
65
- // translation audit outcome all wear it — so a convention keyed on it guesses,
66
- // and guesses often. `lifecycle` is a word nobody types by accident, so matching
67
- // it is closer to a declaration written in the field name. A record whose field
68
- // is honestly called something else annotates instead.
42
+ // The field holding the entity's lifecycle, for filtering a per-row command menu:
43
+ // `@lifecycle`, else an enum field literally named `lifecycle`, else None. Not
44
+ // keyed on `status` — a promiscuous name that would guess, and guess often.
69
45
  let lifecycleFieldFromStateSchema = (
70
46
  ~entityName: string,
71
47
  stateSchema: S.t<unknown>,
@@ -91,12 +67,8 @@ let lifecycleFieldFromStateSchema = (
91
67
  }
92
68
  }
93
69
 
94
- // The field whose truth withdraws a row from ordinary reads. Annotation-only:
95
- // there is no convention rung here, and its absence is the point. `lifecycleField`
96
- // may fall back to a field literally named "lifecycle" because guessing wrong makes
97
- // a command menu filter oddly; guessing wrong here makes rows vanish for every
98
- // caller who is not elevated, so a boolean named `archived` that nobody annotated
99
- // stays exactly as visible as it was.
70
+ // The field whose truth withdraws a row from ordinary reads. Annotation-only —
71
+ // no convention rung, since guessing wrong here makes rows vanish.
100
72
  let retiredFromStateSchema = (
101
73
  stateSchema: S.t<unknown>,
102
74
  ): option<Reventless.StateAnnotations.retiredSpec> =>
@@ -108,62 +80,29 @@ let retiredFromStateSchema = (
108
80
  let retiredFieldFromStateSchema = (stateSchema: S.t<unknown>): option<string> =>
109
81
  retiredFromStateSchema(stateSchema)->Option.map(r => r.field)
110
82
 
111
- // The states a row is retired *in*, for the enum form. `None` is the boolean
112
- // form, where the value is always `true` and naming it would be a parameter that
113
- // can only hold one thing. A set, because a lifecycle may be withdrawn by more
114
- // than one state, and a one-member set is the ordinary case.
115
- //
116
- // Published beside `retiredField` rather than left for a consumer to dig out of
117
- // the schema: a client that has the def in hand has the whole predicate, and two
118
- // places deriving one comparison is how they come to disagree about it.
83
+ // The states a row is retired *in*, for the enum form; `None` is the boolean
84
+ // form. Published beside `retiredField` so a client holds the whole predicate.
119
85
  let retiredValuesFromStateSchema = (stateSchema: S.t<unknown>): option<array<string>> =>
120
86
  retiredFromStateSchema(stateSchema)->Option.flatMap(r => r.values)
121
87
 
122
- // Whether a reference to a retired row of this view still resolves its name —
123
- // `@namedWhenRetired`. Read off the retirement rather than from a second
124
- // annotation, so a record cannot declare the reach of a retirement it does not
125
- // have; the PPX refuses that pairing, and reading it here from the same place
126
- // keeps the two halves agreeing by construction rather than by review.
88
+ // Whether a reference to a retired row still resolves its name. Read off the
89
+ // retirement itself, so a record cannot declare the reach of one it lacks.
127
90
  let namedWhenRetiredFromStateSchema = (stateSchema: S.t<unknown>): bool =>
128
91
  retiredFromStateSchema(stateSchema)->Option.mapOr(false, r => r.namedWhenRetired)
129
92
 
130
- // What one record's `@retired` declaration could be told about its own field.
131
- //
132
- // Three outcomes rather than a bool, because "nothing to check" and "could not
133
- // check" are different facts and only the second is worth a plugin's attention.
93
+ // What one record's `@retired` could be told about its own field. Three outcomes,
94
+ // because "nothing to check" and "could not check" are different facts.
134
95
  type retiredCheck =
135
96
  | NotDeclared
136
- // Why the names could not be compared. A fatal rule that is invisible when it
137
- // does not run is the failure mode the transition check spends a counter to
138
- // avoid, so this is reported rather than skipped in silence.
97
+ // Why the names could not be compared — reported, never skipped in silence.
139
98
  | Unchecked(string)
140
99
  | Checked(array<string>)
141
100
 
142
- // The check the PPX cannot make, in the one place that can: the payload is a
143
- // constructor reference the PPX only ever sees as a name, and whether that name
144
- // is a case of the field's enum needs the schema.
145
- //
146
- // Two rules, and they are held to different standards on purpose.
147
- //
148
- // **A name the field's enum does not declare is unambiguously wrong** — no domain
149
- // means it — and the symptom is a data-exposure bug: the retirement predicate
150
- // compares every row against a state no row is ever in, so every row stays
151
- // visible to every caller while the annotation sits on the schema looking like
152
- // enforcement. That is returned as a failure for the caller to raise on.
153
- //
154
- // It is the same fault the PPX already refuses to compile when the enum is
155
- // declared in the same file, and the PPX says so in its own message. This is the
156
- // residue that a per-file pass cannot reach: field form, enum imported from
157
- // elsewhere. Two rungs of one ladder — until this was promoted, which rung you
158
- // landed on decided whether a data-exposure bug stopped the build, and the
159
- // arbiter was where the enum happened to be declared.
160
- //
161
- // **A `value` on a field that is not the record's lifecycle stays a warning.**
162
- // It would keep the read narrowing while silently losing the command filtering
163
- // that motivates it — `@transition` is written in terms of the lifecycle field,
164
- // so a retirement state anywhere else is a state no command can name. That is a
165
- // modelling judgement rather than a wrong name, and judgement calls are what the
166
- // withdrawn dead-end rule taught us not to hard-fail on.
101
+ // The check the PPX cannot make (it sees the payload only as a name; the schema
102
+ // says whether that name is a case of the field's enum). A name the enum does not
103
+ // declare is a failure — the predicate then matches no row and every row stays
104
+ // visible. A `value` on a field that is not the lifecycle is only a warning: a
105
+ // modelling judgement rather than a wrong name.
167
106
  let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retiredCheck =>
168
107
  switch retiredFromStateSchema(stateSchema) {
169
108
  // The boolean form names no state, so there is nothing to compare. Not a skip.
@@ -216,14 +155,9 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retire
216
155
  }
217
156
  }
218
157
 
219
- // Raised together, after every view has been walked, so an author sees every bad
220
- // name at once rather than the first one and then a rebuild.
221
- //
222
- // Retroactive in a way the transition check was not: `@transition` was new when
223
- // its check landed, so nothing deployed could carry a stale name, while `@retired`
224
- // has been shipping. A deployed plugin holding a misspelled retired value gets a
225
- // red build on its next deploy — which is the point, and is why the examples were
226
- // swept before this was promoted.
158
+ // Raised together, after every view is walked, so an author sees every bad name
159
+ // at once. Retroactive: a deployed plugin with a stale value goes red on its next
160
+ // build, which is the point.
227
161
  let reportRetiredStates = (
228
162
  ~pluginName: string,
229
163
  ~failures: array<string>,
@@ -245,11 +179,8 @@ let reportRetiredStates = (
245
179
  }
246
180
  }
247
181
 
248
- // The states a record's lifecycle field can hold. The same extraction
249
- // `checkRetiredValue` does, keyed on the declared lifecycle field rather than the
250
- // retired one — which is the field a command's `@transition` is written in terms
251
- // of. `None` means the record declares no lifecycle at all; `Some([])` means it
252
- // declares one whose shape carries no cases to compare against.
182
+ // The states a record's lifecycle field can hold — the field `@transition` is
183
+ // written in terms of. `None`: no lifecycle; `Some([])`: one with no cases.
253
184
  let lifecycleStatesFromStateSchema = (
254
185
  ~entityName: string,
255
186
  stateSchema: S.t<unknown>,
@@ -270,34 +201,190 @@ let lifecycleStatesFromStateSchema = (
270
201
  }
271
202
  )
272
203
 
273
- // The other check the PPX cannot make, and the reason `@transition` is worth
274
- // more than a rename.
275
- //
276
- // A command declares the states it may run from and the state it lands in, but
277
- // those states belong to ANOTHER component's lifecycle enum. The PPX only ever
278
- // sees them as names — it strips the attribute before the typechecker, and a
279
- // synthetic reference to the constructor does not survive ReScript's pre-PPX
280
- // dependency walk. So a misspelled state compiles clean, ships, and produces a
281
- // command that is legal in a state no row is ever in: a menu entry that never
282
- // appears, with nothing anywhere saying why.
204
+ // ── The port's translation table ─────────────────────────────────────────────
283
205
  //
284
- // Here both sides are in hand. This runs at plugin-structure assembly, which
285
- // happens in the deploy program and at local-platform start — never inside a
286
- // deployed Lambda, which reads a persisted structure rather than building one.
287
- // So raising is a failed deploy, not a dead function.
288
- //
289
- // Two severities, and the split is deliberate:
290
- // - a state the linked views do not declare → RAISE. The author named
291
- // something that does not exist.
292
- // - no resolvable linked view, or none declaring a lifecycle → warn. The
293
- // metadata gap is real but hard-failing on it would turn "this plugin's
294
- // views could not be resolved" into a deploy outage, and that population is
295
- // broad.
206
+ // The PPX reads the table off the mapping's own arms, so these checks are what
207
+ // stands behind a HAND-WRITTEN one — the escape hatch for a mapping whose arms
208
+ // the PPX refused to guess at. Names raise (both sides are `@schema` types, so a
209
+ // non-constructor is a mistake); the probe below is best-effort and reports what
210
+ // it could not follow.
211
+
212
+ let translationTableFailures = (
213
+ ~label: string,
214
+ ~declared: array<ReventlessInfra.ExtensionPointMapping.publishedEvent>,
215
+ ~publishedNames: array<string>,
216
+ ~sourceNames: array<string>,
217
+ ): array<string> => {
218
+ let failures = []
219
+ let push = msg => failures->Array.push(msg)->ignore
220
+ declared->Array.forEach(({name, fromEventTypes}) => {
221
+ if !(publishedNames->Array.includes(name)) {
222
+ push(
223
+ `${label}: publishedEvents names "${name}", which the extension point does not ` ++
224
+ `publish — it declares ${publishedNames->Array.join(", ")}.`,
225
+ )
226
+ }
227
+ fromEventTypes->Array.forEach(src => {
228
+ if !(sourceNames->Array.includes(src)) {
229
+ push(
230
+ `${label}: publishedEvents says "${name}" comes from "${src}", which is not an ` ++
231
+ `event of ${sourceNames->Array.join(", ")}.`,
232
+ )
233
+ }
234
+ })
235
+ })
236
+ failures
237
+ }
238
+
239
+ // What the probe saw against what was declared. Produced-but-undeclared raises
240
+ // (the probe watched it happen); declared-but-unproduced only warns (the arm may
241
+ // branch on a payload the probe cannot synthesise). Unfollowed sources are not
242
+ // judged at all — "did not look" must not read as "no edge".
243
+ let translationTableDrift = (
244
+ ~label: string,
245
+ ~declared: array<ReventlessInfra.ExtensionPointMapping.publishedEvent>,
246
+ ~observed: array<(string, string)>,
247
+ ~followed: array<string>,
248
+ ): (array<string>, array<string>) => {
249
+ let declaredEdges = declared->Array.reduce([], (acc, {name, fromEventTypes}) =>
250
+ Array.concat(acc, fromEventTypes->Array.map(src => (src, name)))
251
+ )
252
+ let has = (edges, (src, pub)) => edges->Array.some(((s, p)) => s == src && p == pub)
253
+
254
+ let failures =
255
+ observed
256
+ ->Array.filter(edge => !(declaredEdges->has(edge)))
257
+ ->Array.map(((src, pub)) =>
258
+ `${label}: "${src}" publishes "${pub}", which publishedEvents does not declare.`
259
+ )
260
+
261
+ let warnings =
262
+ declaredEdges
263
+ ->Array.filter(((src, pub)) => followed->Array.includes(src) && !(observed->has((src, pub))))
264
+ ->Array.map(((src, pub)) =>
265
+ `${label}: publishedEvents declares "${src}" → "${pub}", which the mapping did not ` ++
266
+ `produce for a synthesised "${src}".`
267
+ )
268
+
269
+ (failures, warnings)
270
+ }
271
+
272
+ // The subscriber's mirror, resolved against the union of both command sets:
273
+ // publishing back to the port is as real an edge as publishing inward.
274
+ let handledTableFailures = (
275
+ ~label: string,
276
+ ~declared: array<ReventlessInfra.ExtensionMapping.handledEvent>,
277
+ ~eventNames: array<string>,
278
+ ~commandNames: array<string>,
279
+ ): array<string> => {
280
+ let failures = []
281
+ declared->Array.forEach(({name, toCommandTypes}) => {
282
+ if !(eventNames->Array.includes(name)) {
283
+ failures
284
+ ->Array.push(
285
+ `${label}: handledEvents names "${name}", which the extension point does not ` ++
286
+ `publish — it declares ${eventNames->Array.join(", ")}.`,
287
+ )
288
+ ->ignore
289
+ }
290
+ toCommandTypes->Array.forEach(cmd =>
291
+ if !(commandNames->Array.includes(cmd)) {
292
+ failures
293
+ ->Array.push(
294
+ `${label}: handledEvents says "${name}" routes to "${cmd}", which is neither a ` ++
295
+ `delegate command nor an extension point command — ${commandNames->Array.join(", ")}.`,
296
+ )
297
+ ->ignore
298
+ }
299
+ )
300
+ })
301
+ failures
302
+ }
303
+
304
+ // The command direction's two tables. Both key on an EP command and value a list
305
+ // of delegate names, so one check serves them: `keyed` names the table, `keyKind`
306
+ // and `valueKind` name what each side must be.
307
+ let commandTableFailures = (
308
+ ~label: string,
309
+ ~keyed: string,
310
+ ~valueKind: string,
311
+ ~rows: array<(string, array<string>)>,
312
+ ~keyNames: array<string>,
313
+ ~valueNames: array<string>,
314
+ ): array<string> => {
315
+ let failures = []
316
+ let push = msg => failures->Array.push(msg)->ignore
317
+ rows->Array.forEach(((name, values)) => {
318
+ if !(keyNames->Array.includes(name)) {
319
+ push(
320
+ `${label}: ${keyed} names "${name}", which is not a command of the extension ` ++
321
+ `point — it declares ${keyNames->Array.join(", ")}.`,
322
+ )
323
+ }
324
+ values->Array.forEach(v =>
325
+ if !(valueNames->Array.includes(v)) {
326
+ push(
327
+ `${label}: ${keyed} says "${name}" ${valueKind} "${v}", which the delegate does ` ++
328
+ `not declare — ${valueNames->Array.join(", ")}.`,
329
+ )
330
+ }
331
+ )
332
+ })
333
+ failures
334
+ }
335
+
336
+ // The probe's stand-ins. A mapping can only reach the query engine behind a
337
+ // promise, and such an arm is reported as unfollowed anyway.
338
+ let probeId = "probe"
339
+ let probeMeta: Reventless.Message.meta = {
340
+ service: "Plugin_Structure",
341
+ time: "",
342
+ msgId: "",
343
+ correlationId: "",
344
+ }
345
+ let probeQueryEngine: Reventless.QueryEngine.operations = {
346
+ scan: async (~readModelName as _, ~filterConfigs as _, ~limit as _) => [],
347
+ query: async (
348
+ ~readModelName as _,
349
+ ~key as _=?,
350
+ ~id as _,
351
+ ~subIdConfig as _=?,
352
+ ~filterConfigs as _=?,
353
+ ~ascending as _=?,
354
+ ~limit as _=?,
355
+ ) => [],
356
+ }
357
+
358
+ // One place raises, at the end of the build: a stale table drifts in more than
359
+ // one spot, and stopping at the first would hide the rest.
360
+ let reportTranslationTables = (
361
+ ~pluginName: string,
362
+ ~failures: array<string>,
363
+ ~warnings: array<string>,
364
+ ): unit => {
365
+ if Array.length(warnings) > 0 {
366
+ log.warn(~comp="Plugin_Structure", `${pluginName}:\n` ++ warnings->Array.join("\n"))
367
+ }
368
+ if Array.length(failures) > 0 {
369
+ JsError.throwWithMessage(
370
+ `${pluginName}: the declared translation table does not match the mapping.\n` ++
371
+ failures->Array.join("\n"),
372
+ )
373
+ }
374
+ }
375
+
376
+ // A command's declared edge names states belonging to another component's enum.
377
+ // From `@transition` the PPX only ever sees names, so a misspelling compiles clean
378
+ // and produces a command legal in a state no row is in. Both sides are in hand
379
+ // here. A state the linked views do not declare raises (this runs at assembly,
380
+ // never in a Lambda, so that is a failed deploy); no resolvable view only warns.
381
+ // Checked against the UNION of the linked views: a slice feeding two is not
382
+ // claiming which one.
296
383
  //
297
- // Checked against the UNION of the linked views' lifecycles rather than a single
298
- // view: a command whose slice feeds two views is not claiming which one, and
299
- // failing on an ambiguity the author never expressed would be a false positive
300
- // on correct code.
384
+ // A `commandTransition` switch comes through the same `commandDef` fields and is
385
+ // checked the same way. The compiler has already resolved its constructors, so
386
+ // what survives to here is naming the wrong enum rather than misspelling one —
387
+ // which this catches for the same reason and with the same message.
301
388
  let checkDeclaredTransitions = (
302
389
  ~pluginName: string,
303
390
  ~writables: array<Reventless.Plugin.writableDef>,
@@ -331,7 +418,7 @@ let checkDeclaredTransitions = (
331
418
  ->Array.forEach(state =>
332
419
  failures
333
420
  ->Array.push(
334
- `${w.name}.${cmd.name}: @transition names "${state}", which none of its ` ++
421
+ `${w.name}.${cmd.name} declares state "${state}", which none of its ` ++
335
422
  `linked views declare — ${w.linkedViews->Array.join(
336
423
  ", ",
337
424
  )} know ${known->Array.join(", ")}.`,
@@ -349,37 +436,23 @@ let checkDeclaredTransitions = (
349
436
  if unvalidated.contents > 0 {
350
437
  log.warn(
351
438
  ~comp="Plugin_Structure",
352
- `${pluginName}: ${unvalidated.contents->Int.toString} command(s) declare a @transition ` ++
439
+ `${pluginName}: ${unvalidated.contents->Int.toString} command(s) declare a transition ` ++
353
440
  `but no linked view declares a lifecycle to check it against.`,
354
441
  )
355
442
  }
356
443
 
357
444
  if Array.length(failures) > 0 {
358
445
  JsError.throwWithMessage(
359
- `${pluginName}: @transition names states that do not exist.\n` ++
446
+ `${pluginName}: a declared transition names states that do not exist.\n` ++
360
447
  failures->Array.join("\n"),
361
448
  )
362
449
  }
363
450
  }
364
451
 
365
- // Beyond "does this state exist" — does the declared graph make sense?
366
- //
367
- // A separate pass from the name check on purpose. That one asks whether a name
368
- // is a case of an enum, which is a question about one annotation in isolation.
369
- // These ask whether the annotations AGREE with each other across an entity, and
370
- // a graph can be built entirely out of valid names and still be wrong: a state
371
- // nothing reaches, or one nothing can leave that was never marked as an ending.
372
- //
373
- // Reported as warnings rather than raised. The name check fails a build because
374
- // a state that does not exist is unambiguously a mistake — there is no domain in
375
- // which it is what the author meant. These are weaker signals: a state with no
376
- // way out may be a genuine dead end nobody has marked yet, or a legitimate
377
- // terminal the model reaches by a route this metadata cannot see (an automation,
378
- // an external system). Failing a deploy on a modelling smell would be the wrong
379
- // trade, and a smell that stops a deploy gets silenced rather than fixed.
380
- // Pure, and returns its findings rather than logging them, so the rule can be
381
- // tested without reading a log. `checkLifecycleTopology` below is the thin part
382
- // that reports them.
452
+ // Whether the declared transitions AGREE across an entity — a graph of valid names
453
+ // can still leave a state nothing reaches. Warnings only: a smell is not a mistake,
454
+ // and one that stops a deploy gets silenced rather than fixed. Pure, so the rule
455
+ // tests without a log; `checkLifecycleTopology` reports.
383
456
  let lifecycleTopologyFindings = (
384
457
  ~writables: array<Reventless.Plugin.writableDef>,
385
458
  ~lifecycleStatesByView: dict<array<string>>,
@@ -388,20 +461,15 @@ let lifecycleTopologyFindings = (
388
461
  lifecycleStatesByView
389
462
  ->Dict.toArray
390
463
  ->Array.forEach(((view, states)) => {
391
- // Every state some command declares a transition INTO. The question this
392
- // check asks is only about arrival, so it reads the target and never the
393
- // from-set — which is what lets a creating command (`@transition(() => X)`,
394
- // a target and no from-set, because it runs from no row) count towards
395
- // reachability the same way an edge out of another state does.
464
+ // Arrival only, so a creating command (a target and no from-set) counts
465
+ // towards reachability like any other edge.
396
466
  let reachable = writables->Array.reduce([], (acc, w) =>
397
467
  w.linkedViews->Array.includes(view)
398
468
  ? Array.concat(acc, w.commands->Array.filterMap(cmd => cmd.targetState))
399
469
  : acc
400
470
  )
401
471
  if Array.length(reachable) > 0 {
402
- // Rows start in the first declared state — the same convention the
403
- // lifecycle diagram uses — so nothing pointing at it is expected rather
404
- // than suspicious.
472
+ // Rows start in the first declared state, so nothing pointing at it is fine.
405
473
  let initial = states->Array.get(0)
406
474
 
407
475
  states->Array.forEach(state => {
@@ -414,23 +482,9 @@ let lifecycleTopologyFindings = (
414
482
  ))
415
483
  ->ignore
416
484
  }
417
- // NOT checked: a state with no way out.
418
- //
419
- // The obvious second rule — "a dead end that is not `@retired` is
420
- // suspicious" — was written, run against the shipped examples, and
421
- // removed, because it is wrong twice over.
422
- //
423
- // It fires on correct models: `Shipped` and `Refunded` are terminal in
424
- // the aggregates shop, as terminal states are in most lifecycles, and
425
- // there is nothing to fix about either.
426
- //
427
- // Worse, its suggested fix is harmful. `@retired` does not mean
428
- // "terminal" — it means WITHDRAWN FROM ORDINARY READS. Marking a shipped
429
- // order retired to silence a lint would hide every shipped order from
430
- // every caller who cannot widen their read. An ending and a withdrawal
431
- // are different facts, and nothing in the vocabulary currently
432
- // distinguishes an intentional terminal from an accidental one, so the
433
- // check cannot tell them apart and should not pretend to.
485
+ // NOT checked: a state with no way out. Terminal states are ordinary,
486
+ // and the only fix a lint could suggest — `@retired` — withdraws the
487
+ // rows from reads rather than marking an ending.
434
488
  })
435
489
  }
436
490
  })
@@ -446,44 +500,12 @@ let checkLifecycleTopology = (
446
500
  log.warn(~comp="Plugin_Structure", `${pluginName}/${view}: ${message}`)
447
501
  )
448
502
 
449
- // NOT here: the event-consumption completeness check.
450
- //
451
- // The failure it would catch is real and has already happened: a command emitted
452
- // an event, the slice that owned the opposite command folded it, and the view
453
- // that renders the entity did not — so the row kept rendering the state it was
454
- // in before, with every annotation in the plugin correct. That is a class of bug
455
- // no declaration check can see, because nothing declared is wrong.
456
- //
457
- // It is not computable from this metadata, and the reason is worth recording so
458
- // the attempt is not repeated. Two narrowings were needed and only one was
459
- // available:
460
- //
461
- // 1. The event must belong to the view's own entity, not merely be something a
462
- // slice looked up. Available: require it to be PRODUCED by a writable
463
- // linked to the same view. Without this the check fires on ordinary DCB —
464
- // `AddProduct` folds `CategoryAdded` to check a category exists, and the
465
- // `Products` view is right to ignore it.
466
- //
467
- // 2. The slice must be known to fold the event. NOT available:
468
- // `consumedEventTypes` is built from `eventVariantNames`, which drops
469
- // payload-less variants — and a lifecycle-moving event is usually
470
- // payload-less in the slice that folds it. Measured on the shipped example:
471
- // both order slices publish `consumedEventTypes: ["Ordering.OrderPlaced"]`
472
- // and nothing else, though each folds three more.
473
- //
474
- // So the exact events the rule is about are the ones the metadata does not
475
- // record. Making it work means publishing the payload-less consumed variants,
476
- // which is a platform change with its own consequences, not a lint.
477
-
478
- // Which rung of the ladder below produced the label. Published on `queryableDef`
479
- // as `labelFieldSource`, because the four rungs are not equally believable and a
480
- // consumer with a name rule of its own has to rank the declaration against it:
481
- // rung 1 is the author saying which field names the record, rungs 2 and 3 are
482
- // guesses this repo makes on their behalf, and rung 4 is the admission that
483
- // there was nothing to guess from. Convention and position are both guesses and
484
- // nothing here branches on the difference — but a conventional name is the one a
485
- // client can independently arrive at, while a positional pick is a fact only
486
- // this side knows.
503
+ // NOT here: an event-consumption completeness check. `consumedEventTypes` drops
504
+ // payload-less variants, which is exactly what a lifecycle-moving event usually
505
+ // is — so the events such a rule is about are the ones this metadata omits.
506
+
507
+ // Which rung produced the label, published as `labelFieldSource`: a consumer with
508
+ // a name rule of its own has to rank a declaration (rung 1) against a guess.
487
509
  type labelFieldSource =
488
510
  | Annotation
489
511
  | Convention
@@ -504,22 +526,9 @@ type labelResolution = {
504
526
  source: labelFieldSource,
505
527
  }
506
528
 
507
- // Resolves the field a state is named by, from a state schema.
508
- // Source ladder:
509
- // 1. @displayName spec present → "displayName" + spec.fields (raw underlying fields)
510
- // 2. A candidate field named `name`/`title`/`label`/`displayName`
511
- // 3. The first candidate in declaration order
512
- // 4. "id" fallback with a logged warning (no searchable fields)
513
- //
514
- // A *candidate* is a non-TAG field, not literally named `id`, whose IR shape can
515
- // name a record (`isLabelShape`). Rungs 2 and 3 are both guesses, and the order
516
- // between them matters: declaration order alone means a state gains a new name
517
- // whenever a field is inserted above the old one — a `placedAt` added so date
518
- // views have something to key off renames every order to a timestamp.
519
- //
520
- // `id` is excluded here rather than by the IR because `SchemaType` treats a name
521
- // that short as an ordinary string; every other reference — `*Id`/`*Ids`, a DCB
522
- // tag, a `Reference.to` — is already an `EntityId` there.
529
+ // The field a state is named by: `@displayName`, else a candidate named
530
+ // name/title/label/displayName, else the first candidate, else `id` with a
531
+ // warning. A candidate is a non-TAG field, not named `id`, passing `isLabelShape`.
523
532
  let labelFieldsFromStateSchema = (
524
533
  ~entityName: string,
525
534
  stateSchema: S.t<unknown>,
@@ -559,18 +568,13 @@ let labelFieldsFromStateSchema = (
559
568
  }
560
569
  }
561
570
 
562
- // ── Per-variant event / error field extraction (Phase 6.3) ───────────────────
563
- // Mirrors toCommandDef/extractCommandDefs for emitted events: name (TAG const),
564
- // payload JSON Schema, and cross-entity references. Module-level rather than
565
- // inside `make` because the synthetic Platform_Admin structure — hand-written,
566
- // because its components never pass through `make` — has to derive its defs the
567
- // same way, and a second copy of this walk would be free to drift from this one.
568
-
569
- // A variant's declared cross-entity references. Shared by the command and event
570
- // walks because the two ask the identical question of identical field dicts, and
571
- // the question is `getFieldTarget` rather than `getTarget`: a reference declared
572
- // on an `array<string>` field sits on the element schema. Collecting it in one
573
- // place is what stops one walk from being taught that and the other not.
571
+ // ── Per-variant event / error field extraction ───────────────────────────────
572
+ // Mirrors the command walk for emitted events. Module-level so Platform_Admin,
573
+ // whose components never pass through `make`, derives its defs the same way.
574
+
575
+ // A variant's cross-entity references, shared by the command and event walks.
576
+ // `getFieldTarget`, not `getTarget`: an `array<string>` field declares it on the
577
+ // element schema.
574
578
  let extractReferences = (properties: dict<S.t<unknown>>): array<
575
579
  Reventless.Plugin.fieldReference,
576
580
  > =>
@@ -607,10 +611,8 @@ let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
607
611
  | _ => None
608
612
  }
609
613
  )
610
- // Payload-less event variants (`| Archived`) compile to a bare string literal.
611
- // DCB-projection lookups can't WHERE-clause on them, so they stay out of
612
- // producedEventTypes/consumedEventTypes; but the full `events` list carries them
613
- // so the event graph can still draw the emitted (orphan) event node.
614
+ // Payload-less variants compile to a bare string literal. Kept here (though not
615
+ // in produced/consumedEventTypes) so the graph can draw the orphan node.
614
616
  | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties=Dict.make()))
615
617
  | _ => None
616
618
  }
@@ -622,21 +624,16 @@ let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.even
622
624
  | _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
623
625
  }
624
626
 
625
- // Declared errors walk identically to emitted events — an error variant is a
626
- // variant, and the payload-less form (`| CategoryNotFound`) is the common case
627
- // the bare-string branch above already handles. Reusing the walk rather than
628
- // copying it keeps the two from drifting; only the def type differs, so that a
629
- // consumer (and the SDL) can tell a refusal from a fact.
627
+ // Errors walk identically to events; only the def type differs, so a consumer
628
+ // can tell a refusal from a fact. Shared rather than copied, so they cannot drift.
630
629
  let extractErrorDefs = (errorSchema: S.t<unknown>): array<Reventless.Plugin.errorDef> =>
631
630
  extractEventDefs(errorSchema)->Array.map(({name, schema, references}) => (
632
631
  {name, schema, references}: Reventless.Plugin.errorDef
633
632
  ))
634
633
 
635
- // The command walk, module-level for the same reason as the event walk above:
636
- // the synthetic Platform_Admin structure cannot reach `make` — it has no
637
- // `module(Aggregate.T)` to hand it — and hand-writing a second copy of this walk
638
- // is what let its command metadata drift from the SDL generated off the same
639
- // schema.
634
+ // Module-level like the event walk: the synthetic Platform_Admin structure has no
635
+ // `module(Aggregate.T)` to hand `make`, and a second copy of this walk is what let
636
+ // its metadata drift from the SDL.
640
637
 
641
638
  // Aggregate commands that initialize a new aggregate instance are Collection-level
642
639
  // (shown as table-top buttons); all others are Instance-level (shown per-row).
@@ -677,32 +674,18 @@ let commandLevelAndId = (~isAggregate, ~variantName, properties: dict<S.t<unknow
677
674
  }
678
675
  }
679
676
 
680
- // A rule the server enforces, expressed as the keys a client checks against
681
- // `identity.groups ++ config.accessTiers`. `AllowGroups` is satisfied by ANY of
682
- // its groups (`Authorization.isAllowed` is `some`, not `every`), so the array is
683
- // an any-of and a client must read it that way.
684
- //
685
- // `AllowAuthenticated` / `AllowAnonymous` ask for nothing a client can check —
686
- // anyone holding a session already satisfies them — so they publish no keys
687
- // rather than a key everyone holds.
688
- //
689
- // `DenyAll` also publishes none, deliberately. An unsatisfiable key would render
690
- // as locked-with-upsell in a tiered shell: a surface advertised as purchasable
691
- // that no purchase unlocks. A component nobody may call belongs in no menu, and
692
- // that is an omission for the enumerating side to make, not a key to invent here.
677
+ // The server's rule as keys a client checks against `identity.groups ++
678
+ // config.accessTiers`; an any-of, since `isAllowed` is `some`. The rules asking
679
+ // for nothing checkable — and `DenyAll` — publish no keys rather than an
680
+ // unsatisfiable one.
693
681
  let accessKeysFor = (rule: Reventless.Authorization.permission): option<array<string>> =>
694
682
  switch rule {
695
683
  | AllowGroups(groups) if groups->Array.length > 0 => Some(groups)
696
684
  | AllowGroups(_) | AllowAuthenticated | AllowAnonymous | DenyAll => None
697
685
  }
698
686
 
699
- // Write each mutation argument's rendered GraphQL type onto the property it
700
- // belongs to, so a consumer assembling its own mutation document declares the
701
- // variable the server actually expects instead of guessing `String!`.
702
- //
703
- // Mutates the freshly derived schema in place — `deriveObjectSchema` has just
704
- // built it and nothing else holds it yet. A property with no matching
705
- // argument is left alone rather than annotated with a guess.
687
+ // Each mutation argument's GraphQL type onto its property, so a consumer declares
688
+ // the variable the server expects. Mutates the freshly derived schema in place.
706
689
  let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
707
690
  schema
708
691
  ->JSON.Decode.object
@@ -730,6 +713,18 @@ let toCommandDef = (
730
713
  // one aggregate carries commands with very different audiences, and a
731
714
  // component-level shortcut would gate `AddProduct` and `PlaceOrder` alike.
732
715
  ~commandAuthorization: unknown => Reventless.Authorization.permission,
716
+ // The spec's `command => Transition.t<_>`, evaluated per variant the same way
717
+ // `commandAuthorization` is. It wins over the `@transition` metadata where it
718
+ // declares an edge, and stands aside where it says `Unrestricted` — which is
719
+ // what the PPX injects for a spec that never wrote the switch.
720
+ //
721
+ // Read at `t<string>` because this is the erasure boundary: the spec declares
722
+ // its edges over the linked view's own lifecycle enum, whose arms are
723
+ // payload-less and therefore ARE their own names at run time. The one cast
724
+ // that says so is the same `->Obj.magic` every spec member arrives through
725
+ // (see the call sites below), so `Transition` itself asserts nothing about
726
+ // representation and stays parameterised all the way down.
727
+ ~commandTransition: unknown => Reventless.Transition.t<string>,
733
728
  v: S.t<unknown>,
734
729
  ): option<Reventless.Plugin.commandDef> => {
735
730
  // Build a commandDef for one variant. `properties` is the variant's field dict —
@@ -738,15 +733,41 @@ let toCommandDef = (
738
733
  let mkDef = (~variantName, ~properties) => {
739
734
  let (level, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
740
735
  let references = extractReferences(properties)
741
- // Per-variant `allowedStates` lives on the *parent* command schema
742
- // (the PPX attaches a single dict<variantName, [|states|]> via
743
- // markAllowedStates). Look it up by variant name; back-compat
744
- // None when the variant lacks a @transition annotation.
745
- let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
746
- // The `@transition` target (the command's *to* status), read the same way
747
- // as allowedStates. None ⇒ AutoUI's board resolver falls back to its
748
- // name-stem heuristic.
749
- let targetState = ApiTargetStateHelpers.getTargetState(parentSchema, ~variantName)
736
+ // Evaluated against a synthetic value per constructor, the same shape the
737
+ // resolver builds at call time: a payload-bearing variant compiles to
738
+ // `{TAG, ...}`, a payload-less one to a bare string.
739
+ let syntheticCommand: unknown =
740
+ Reventless.DcbTag.isVariantPayloadBearing(parentSchema, variantName)
741
+ ? {"TAG": variantName}->Obj.magic
742
+ : variantName->Obj.magic
743
+ // The spec's own switch, which is the only form that can speak for a
744
+ // constructor the host did not declare: `@transition` lowers to a dict on
745
+ // the parent union, and a variant spread splices members, so a spliced
746
+ // command reaches the metadata below carrying nothing.
747
+ let declared = commandTransition(syntheticCommand)
748
+ // An edge is ONE declaration, so the two fields are chosen together. Taking
749
+ // them separately would let `Guards(…)` — which positively claims the
750
+ // command moves the row nowhere — inherit a target from an annotation it
751
+ // was written to replace.
752
+ //
753
+ // The `@transition` metadata is per-variant but attached by the PPX to the
754
+ // *parent* schema as one dict. It is consulted only where the switch
755
+ // declares no edge at all: a spec that never wrote one answers
756
+ // `Unrestricted` for every command, which is how the annotation stays in
757
+ // charge for the hosts that still use it.
758
+ //
759
+ // `targetState: None` ⇒ AutoUI's board resolver falls back to its name-stem
760
+ // heuristic.
761
+ let (allowedStates, targetState) = switch declared {
762
+ | Unrestricted => (
763
+ ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName),
764
+ ApiTargetStateHelpers.getTargetState(parentSchema, ~variantName),
765
+ )
766
+ | _ => (
767
+ Reventless.Transition.allowedStates(declared),
768
+ Reventless.Transition.targetState(declared),
769
+ )
770
+ }
750
771
  // API-exposed iff the whole command isn't @noApi and this variant
751
772
  // isn't in its @noApi-variants set — mirrors the API-generation filter
752
773
  // (Plugin_Helpers / PluginBaseFragment). Drives the event-graph API badge.
@@ -756,13 +777,6 @@ let toCommandDef = (
756
777
  | Some(excluded) => !(excluded->Set.has(variantName))
757
778
  | None => true
758
779
  }
759
- // Evaluated against a synthetic value per constructor, the same shape the
760
- // resolver builds at call time: a payload-bearing variant compiles to
761
- // `{TAG, ...}`, a payload-less one to a bare string.
762
- let syntheticCommand: unknown =
763
- Reventless.DcbTag.isVariantPayloadBearing(parentSchema, variantName)
764
- ? {"TAG": variantName}->Obj.magic
765
- : variantName->Obj.magic
766
780
  let requiredAccess = accessKeysFor(commandAuthorization(syntheticCommand))
767
781
  // See the note on the record's `mutationField` for why a non-exposed
768
782
  // variant gets the empty sentinel. It has no callable field, and the
@@ -780,28 +794,13 @@ let toCommandDef = (
780
794
  }
781
795
  ({
782
796
  Reventless.Plugin.name: variantName,
783
- // The derived schema, not sury's raw one: `S.toJSONSchema` carries the
784
- // shape and drops every `x-reventless-*` marker the PPX put on the
785
- // fields, so a command's `@storageRef`/`@semantic`/`@ref` reached the
786
- // wire on the read side and nowhere on the write side. A reader that
787
- // matches a field against its setter — or picks the upload endpoint of
788
- // the store a command argument declares — then has nothing to match on.
789
- // `MCP_SchemaGenerator` already derives these same variant schemas.
790
- //
791
- // Carries `x-reventless-graphql-type` per property — see
792
- // `annotateArgTypes`.
797
+ // The derived schema, not sury's raw one, which drops every `x-reventless-*`
798
+ // marker the PPX put on the fields. Also carries `x-reventless-graphql-type`.
793
799
  schema: annotatedSchema->JSON.stringify,
794
800
  level,
795
801
  aggregateIdField,
796
- // A non-exposed (`@noApi`) variant has no callable mutation field. For a
797
- // single-exposed-command slice, `mutationFieldFor` resolves *every*
798
- // variant — including the `@noApi` one — to the slice's one mutation
799
- // field, so emitting it here would hand the non-exposed variant a
800
- // sibling's callable-looking field (e.g. `ReopenOrder` →
801
- // `Ordering_CancelOrder`). Emit an empty sentinel instead; the variant
802
- // stays listed with `apiExposed: false` for the event-graph badge, but
803
- // no consumer can mistake it for a callable field. Exposed variants are
804
- // byte-identical.
802
+ // Empty for a `@noApi` variant: `mutationFieldFor` would resolve it to a
803
+ // sibling's field, which reads as callable. Exposed variants are unchanged.
805
804
  mutationField,
806
805
  references,
807
806
  allowedStates,
@@ -837,6 +836,7 @@ let extractCommandDefs = (
837
836
  ~isAggregate,
838
837
  ~mutationFieldFor: string => string,
839
838
  ~commandAuthorization: unknown => Reventless.Authorization.permission,
839
+ ~commandTransition: unknown => Reventless.Transition.t<string>,
840
840
  commandSchema: S.t<unknown>,
841
841
  ): array<Reventless.Plugin.commandDef> =>
842
842
  switch commandSchema {
@@ -847,6 +847,7 @@ let extractCommandDefs = (
847
847
  ~mutationFieldFor,
848
848
  ~parentSchema=commandSchema,
849
849
  ~commandAuthorization,
850
+ ~commandTransition,
850
851
  v,
851
852
  )
852
853
  )
@@ -857,6 +858,7 @@ let extractCommandDefs = (
857
858
  ~mutationFieldFor,
858
859
  ~parentSchema=commandSchema,
859
860
  ~commandAuthorization,
861
+ ~commandTransition,
860
862
  commandSchema,
861
863
  )->Option.mapOr([], def => [def])
862
864
  }
@@ -871,24 +873,10 @@ let visibilityTag = (v: Reventless.Visibility.t): option<string> =>
871
873
  }
872
874
 
873
875
  /**
874
- A read model's published `queryableDef`, assembled from its **spec** rather than
875
- from a built component.
876
-
877
- `make` cannot serve the platform's own components: it takes
878
- `module(ReadModel.T)` values, and the platform has no built module to hand
879
- itself at structure-assembly time — the components it is describing are the ones
880
- being assembled. That is a real constraint and this routes around it rather than
881
- pretending it away, by taking the schemas the extractors actually need.
882
-
883
- Everything here is the same helper `make` calls on the same schema, so the
884
- platform's own view is described by the mechanism that describes everyone
885
- else's. The alternative — a hand-written record — is what let the admin's
886
- metadata drift from the SDL generated off the same spec, four times over.
887
-
888
- `queryField` and `singleQueryField` come from `Api_Naming`, which is the only
889
- place that decides how a name singularises. For the admin these are
890
- byte-identical to the names it used to hand-write; `singularize` already handled
891
- the plural-spec-name / singular-type shape that looked bespoke.
876
+ A read model's `queryableDef` assembled from its spec, for the platform's own
877
+ components: `make` takes `module(ReadModel.T)` values, and at structure-assembly
878
+ time the platform has no built module to hand itself. Every extractor here is the
879
+ one `make` calls, so the two cannot drift — a hand-written record did, four times.
892
880
  */
893
881
  let queryableDefFromSpec = (
894
882
  ~plugin: string,
@@ -940,23 +928,18 @@ let make = (
940
928
  ~inboundTranslationSlices: array<module(ReventlessInfra.InboundTranslationSlice.T)>=[],
941
929
  ~extensions: array<module(ReventlessInfra.Extension.Blueprint)>=[],
942
930
  ~extensionPoints: array<module(ReventlessInfra.ExtensionPointMapping.Mapping)>=[],
943
- // Component name → chapter (intra-plugin grouping band), captured at build time
944
- // from each component's source folder by the plugin generator. A component whose
945
- // spec name has no entry (or lives directly under a kind-folder) carries no chapter
946
- // and renders flat. Keyed by `Spec.name`, which equals the source filename stem for
947
- // every graph-node kind, so the generator can build this map from the discovered
948
- // file paths. See `Codegen.chapterOf` and docs/plans/done/deployed-chapter-grouping.md.
931
+ // Component name → chapter, captured from each component's source folder by the
932
+ // plugin generator. Keyed by `Spec.name`; no entry renders flat.
949
933
  ~componentChapters: dict<string>=Dict.make(),
950
934
  ): Reventless.Plugin.pluginStructure => {
951
935
  let chapterOf = (compName: string): option<string> => componentChapters->Dict.get(compName)
952
- // Event schemas: filter out payload-less variants — DCB event-type lookups
953
- // can't WHERE-clause on bare-string events, so the plugin graph mustn't
954
- // claim cross-component edges that the runtime can't honour.
936
+ // Payload-less variants dropped: the graph must not claim an edge a DCB lookup
937
+ // cannot WHERE-clause on.
955
938
  let eventVariantNames = schema => Reventless.DcbTag.extractVariantNames(schema)
956
- // Command schemas: keep every constructor (including payload-less) so the
957
- // GraphQL mutation surface stays addressable.
939
+ // Every constructor, so the mutation surface stays addressable.
958
940
  let commandVariantNames = schema => Reventless.DcbTag.extractAllVariantNames(schema)
959
941
  let qualify = (~prefix, names) => names->Array.map(n => prefix ++ "." ++ n)
942
+ let dedupe = (xs: array<string>) => xs->Belt.Set.String.fromArray->Belt.Set.String.toArray
960
943
 
961
944
  // ── Per-component event type extraction ────────────────────────────────────
962
945
 
@@ -1058,21 +1041,10 @@ let make = (
1058
1041
 
1059
1042
  // ── Declared object stores ─────────────────────────────────────────────────
1060
1043
  //
1061
- // A field typed as a storage ref states that the deployment needs the named
1062
- // store to exist. Read that declaration the same way `Reference.getTarget` is
1063
- // read for entity references, so the requirement travels with the plugin's
1064
- // structure instead of living only inside a field's schema, where the deploy
1065
- // would have to re-walk every component to find it.
1066
- //
1067
- // An unqualified store belongs to the declaring plugin, so every collected
1068
- // entry is qualified to `{plugin}.{store}` — one shape, and the string is
1069
- // directly the store's identity. Many fields legitimately name one store, so
1070
- // the key set is deduplicated.
1071
- //
1072
- // Each collected entry keeps its declaration site `(component, field)` as
1073
- // provenance, and `requiredStores` is derived from the collected entries —
1074
- // one walk, so the capability manifest's provenance and the deploy's key set
1075
- // cannot come from different readings of the schemas.
1044
+ // A storage-ref field states the deployment needs that store. Collected here so
1045
+ // the requirement travels with the structure, qualified to `{plugin}.{store}`
1046
+ // and deduplicated, each entry keeping its `(component, field)` site.
1047
+ // `requiredStores` is derived from the same walk, so the two cannot disagree.
1076
1048
 
1077
1049
  let storesFromProperties = (~component, properties: dict<S.t<unknown>>): array<
1078
1050
  Reventless.Plugin.requiredStoreDeclaration,
@@ -1088,11 +1060,8 @@ let make = (
1088
1060
  Reventless.Plugin.store: target.plugin->Option.getOr(name) ++ "." ++ target.store,
1089
1061
  component,
1090
1062
  field,
1091
- // The annotation as its author wrote it. `target.plugin` is `None`
1092
- // exactly when the field left the store unqualified, so this is the
1093
- // source text rather than a guess at it — and this is the only place
1094
- // that distinction survives. Always `Some` here: the field is optional
1095
- // on the type only so definitions stored before it existed still decode.
1063
+ // The source text, not a guess: `target.plugin` is `None` exactly when the
1064
+ // field left the store unqualified, and only here does that survive.
1096
1065
  annotation: Some(
1097
1066
  switch target.plugin {
1098
1067
  | None => target.store
@@ -1135,10 +1104,51 @@ let make = (
1135
1104
  }
1136
1105
  }
1137
1106
 
1138
- // The (component, schema) sites the store walk reads. One list feeds both the
1139
- // declared-store collection and the heuristic lint below, so a field is read
1140
- // exactly once: either its declaration is collected, or its name is eligible
1141
- // to warn — never a third reading that could disagree with both.
1107
+ // ── One command name, one handler ──────────────────────────────────────────
1108
+ //
1109
+ // A DCB plugin routes a command by its bare type name: the runtime builds
1110
+ // `handlersByType[typeName]` across every StateChangeSlice in the plugin and
1111
+ // dispatches on the incoming command's TAG alone. Two slices declaring one name
1112
+ // therefore have one handler between them — the second registration overwrites
1113
+ // the first, and the plugin deploys.
1114
+ //
1115
+ // Refused here rather than logged there. The runtime notices only when it goes
1116
+ // to stamp owner fields, by which point the deploy has succeeded and the losing
1117
+ // slice is silently unreachable; a name is a compile-time fact and this is the
1118
+ // last place that can see all of them at once.
1119
+ //
1120
+ // Aggregates are exempt and are not checked: their commands are addressed per
1121
+ // component channel, so two aggregates sharing a command name is ordinary.
1122
+ let dcbCommandOwners: dict<array<string>> = Dict.make()
1123
+ stateChangeSlices->Array.forEach((module(SCS: ReventlessInfra.StateChangeSlice.T)) =>
1124
+ Reventless.DcbTag.extractAllVariantNames(
1125
+ SCS.Spec.commandSchema->S.castToUnknown,
1126
+ )->Array.forEach(cmd => {
1127
+ let owners = dcbCommandOwners->Dict.get(cmd)->Option.getOr([])
1128
+ dcbCommandOwners->Dict.set(cmd, Array.concat(owners, [SCS.Spec.name]))
1129
+ })
1130
+ )
1131
+ let clashes =
1132
+ dcbCommandOwners
1133
+ ->Dict.toArray
1134
+ ->Array.filter(((_, owners)) => owners->Array.length > 1)
1135
+ ->Array.toSorted((((a, _)), ((b, _))) => String.compare(a, b))
1136
+ if clashes->Array.length > 0 {
1137
+ JsError.throwWithMessage(
1138
+ `${name}: ` ++
1139
+ clashes
1140
+ ->Array.map((((cmd, owners))) =>
1141
+ `${owners->Array.join(" and ")} both declare the command "${cmd}"`
1142
+ )
1143
+ ->Array.join("; ") ++
1144
+ `.\n A DCB plugin routes a command by its bare name, so one of these would never run. ` ++
1145
+ `Qualify them — the shipped traits' emitters prefix every name with the host they are ` ++
1146
+ `grafted onto, for exactly this reason.`,
1147
+ )
1148
+ }
1149
+
1150
+ // One list feeds both the store collection and the lint below, so a field is
1151
+ // read exactly once by either.
1142
1152
  let storeDeclarationSites: array<(string, S.t<unknown>)> =
1143
1153
  [
1144
1154
  aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) => [
@@ -1165,9 +1175,7 @@ let make = (
1165
1175
  ) => (ITS.Spec.name, ITS.Spec.commandSchema->S.castToUnknown)),
1166
1176
  ]->Array.flat
1167
1177
 
1168
- // Sorted so the emitted manifest is deterministic; identical triples collapse
1169
- // (one store named by a component's command field and again by its event
1170
- // field under the same field name is one declaration site).
1178
+ // Sorted for a deterministic manifest; identical triples collapse.
1171
1179
  let requiredStoreDeclarations =
1172
1180
  storeDeclarationSites
1173
1181
  ->Array.flatMap(((component, schema)) => storesFromSchema(~component, schema))
@@ -1185,9 +1193,8 @@ let make = (
1185
1193
  ->Belt.Set.String.fromArray
1186
1194
  ->Belt.Set.String.toArray
1187
1195
 
1188
- // Two stores one edit apart are a typo that would provision twice. A hard
1189
- // failure, unlike the heuristic lint below: nothing downstream can see the
1190
- // mistake, and by deploy time both buckets exist.
1196
+ // Two stores one edit apart provision twice. Hard failure, unlike the lint
1197
+ // below: by deploy time both buckets exist.
1191
1198
  switch Capability_Inference.collisions(requiredStoreDeclarations) {
1192
1199
  | [] => ()
1193
1200
  | found =>
@@ -1197,6 +1204,69 @@ let make = (
1197
1204
  )
1198
1205
  }
1199
1206
 
1207
+ // Capabilities a slice declares its `translate` reaches for. Sorted and
1208
+ // deduplicated for the same reason the store declarations are: the manifest is
1209
+ // committed, so it must not churn on a re-build. Only outbound translation
1210
+ // slices are handed `Capabilities.t`, so only they can declare.
1211
+ let requiredCapabilities =
1212
+ outboundTranslationSlices
1213
+ ->Array.flatMap((module(OTS: ReventlessInfra.OutboundTranslationSlice.T)) =>
1214
+ OTS.Spec.capabilityNeeds->Array.map(need => (
1215
+ {
1216
+ Reventless.Plugin.capability: Reventless.CapabilityNeed.toString(need),
1217
+ component: OTS.Spec.name,
1218
+ }: Reventless.Plugin.requiredCapabilityDeclaration
1219
+ ))
1220
+ )
1221
+ ->Array.toSorted((a, b) =>
1222
+ a.capability == b.capability
1223
+ ? String.compare(a.component, b.component)
1224
+ : String.compare(a.capability, b.capability)
1225
+ )
1226
+ ->Array.reduce([], (acc, d) =>
1227
+ switch acc->Array.last {
1228
+ | Some(prev) if prev == d => acc
1229
+ | _ => acc->Array.concat([d])
1230
+ }
1231
+ )
1232
+
1233
+ // The traits grafted into this plugin, one entry per declaring component. The
1234
+ // component name is added here rather than declared: a trait cannot know which
1235
+ // component a host grafted it onto, and a host writing it down would be the one
1236
+ // hand-typed string this whole mechanism exists to avoid. Sorted and
1237
+ // deduplicated for the same reason the two declarations above are.
1238
+ let traitDeclarations = {
1239
+ let entry = (~component, decl: Reventless.Trait.t) => (
1240
+ {
1241
+ Reventless.Plugin.trait: decl.trait,
1242
+ version: decl.version,
1243
+ posture: Reventless.Trait.postureToString(decl.posture),
1244
+ component,
1245
+ }: Reventless.Plugin.traitDeclaration
1246
+ )
1247
+ [
1248
+ aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) =>
1249
+ A.Spec.traits->Array.map(entry(~component=A.Spec.name, ...))
1250
+ ),
1251
+ stateChangeSlices->Array.flatMap((module(SCS: ReventlessInfra.StateChangeSlice.T)) =>
1252
+ SCS.Spec.traits->Array.map(entry(~component=SCS.Spec.name, ...))
1253
+ ),
1254
+ outboundTranslationSlices->Array.flatMap((
1255
+ module(OTS: ReventlessInfra.OutboundTranslationSlice.T),
1256
+ ) => OTS.Spec.traits->Array.map(entry(~component=OTS.Spec.name, ...))),
1257
+ ]
1258
+ ->Array.flat
1259
+ ->Array.toSorted((a, b) =>
1260
+ a.trait == b.trait ? String.compare(a.component, b.component) : String.compare(a.trait, b.trait)
1261
+ )
1262
+ ->Array.reduce([], (acc, d) =>
1263
+ switch acc->Array.last {
1264
+ | Some(prev) if prev == d => acc
1265
+ | _ => acc->Array.concat([d])
1266
+ }
1267
+ )
1268
+ }
1269
+
1200
1270
  // Heuristic-only matches — a field *named* like a stored-object ref with no
1201
1271
  // `@storageRef` — warn and provision nothing. Declaration outranks inference;
1202
1272
  // the warning names the annotation that would settle it.
@@ -1208,12 +1278,8 @@ let make = (
1208
1278
 
1209
1279
  // ── Build queryable defs ───────────────────────────────────────────────────
1210
1280
  //
1211
- // Internal ReadModels and StateViewSlices (marked `@@reventless.visibility(Internal)`)
1212
- // are CARRIED in pluginStructure, tagged via `queryableDef.visibility` (`None` = Public,
1213
- // `Some("Internal")` = Internal). Developer tools — the `reventless-gwt` / VSCode domain
1214
- // graph and dead-code analysis — read them so an Internal view still shows up there. The
1215
- // deployed AutoUI's consumers (Platform_ComponentDefinitionsApi menu/pages) re-filter on
1216
- // the tag so the live UI keeps hiding them — see Visibility.res, which documents this contract.
1281
+ // Internal views are carried here, tagged via `queryableDef.visibility`, so
1282
+ // developer tools see them; AutoUI's consumers re-filter on the tag.
1217
1283
  // View name -> the states its lifecycle field can hold, collected as the view
1218
1284
  // defs are built so the transition check below has both sides in one place.
1219
1285
  let lifecycleStatesByView: dict<array<string>> = Dict.make()
@@ -1250,10 +1316,7 @@ let make = (
1250
1316
  ~entityName=R.Spec.name,
1251
1317
  stateSchema,
1252
1318
  )
1253
- // The events this read model projects from, qualified to the plugin's event ids so
1254
- // they match the producers' nodes in the graph. Was empty — which dropped projection
1255
- // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
1256
- // aggregate→view link is also drawn from the producer's linkedViews, deduped downstream).
1319
+ // Qualified to the plugin's event ids so they match the producers' nodes.
1257
1320
  let consumed = qualify(~prefix=name, R.consumedEventNames)
1258
1321
  recordRetired(~entityName=R.Spec.name, stateSchema)
1259
1322
  recordLifecycle(~entityName=R.Spec.name, stateSchema)
@@ -1338,6 +1401,7 @@ let make = (
1338
1401
  ~variant=variantName,
1339
1402
  ),
1340
1403
  ~commandAuthorization=SCS.Spec.commandAuthorization->Obj.magic,
1404
+ ~commandTransition=SCS.Spec.commandTransition->Obj.magic,
1341
1405
  SCS.Spec.commandSchema->S.castToUnknown,
1342
1406
  ),
1343
1407
  producedEventTypes: produced,
@@ -1362,6 +1426,7 @@ let make = (
1362
1426
  ~isAggregate=true,
1363
1427
  ~mutationFieldFor=variantName => Api_Naming.aggregateMutationField(~plugin=name, ~aggregate=A.Spec.name, ~command=variantName),
1364
1428
  ~commandAuthorization=A.Spec.commandAuthorization->Obj.magic,
1429
+ ~commandTransition=A.Spec.commandTransition->Obj.magic,
1365
1430
  A.Spec.commandSchema->S.castToUnknown,
1366
1431
  ),
1367
1432
  producedEventTypes: produced,
@@ -1422,30 +1487,112 @@ let make = (
1422
1487
 
1423
1488
  // ── Extensions ───────────────────────────────────────────────────────────
1424
1489
 
1490
+ let tableFailures = []
1491
+ let tableWarnings = []
1492
+ let pushAll = (into, xs) => xs->Array.forEach(x => into->Array.push(x)->ignore)
1493
+
1494
+ // Every constructor, payload-less included: a declaration is checked against
1495
+ // what the author can write, not the payload-filtered subset the edges use.
1496
+ let allVariantNames = schema => Reventless.DcbTag.extractAllVariantNames(schema)
1497
+
1425
1498
  let extensionDefs =
1426
1499
  extensions->Array.map((module(E: ReventlessInfra.Extension.Blueprint)) => {
1427
1500
  let delegateNames = E.mappings->Array.map((module(M: E.Mapping)) => M.delegateName)
1501
+ let epEventNames = allVariantNames(E.Spec.eventSchema)
1502
+ let epCommandNames = allVariantNames(E.Spec.commandSchema)
1503
+
1504
+ // Mappings sharing one EP union their tables — the same event may route to
1505
+ // a different delegate's command in each.
1506
+ let commandsByEvent: Dict.t<array<string>> = Dict.make()
1507
+ let eventsByCommand: Dict.t<array<string>> = Dict.make()
1508
+ E.mappings->Array.forEach((module(M: E.Mapping)) => {
1509
+ let label = `${E.Spec.name} → ${M.delegateName}`
1510
+ pushAll(
1511
+ tableFailures,
1512
+ handledTableFailures(
1513
+ ~label,
1514
+ ~declared=M.handledEvents,
1515
+ ~eventNames=epEventNames,
1516
+ ~commandNames=Array.concat(M.delegateCommandNames, epCommandNames),
1517
+ ),
1518
+ )
1519
+ pushAll(
1520
+ tableFailures,
1521
+ commandTableFailures(
1522
+ ~label,
1523
+ ~keyed="issuedCommands",
1524
+ ~valueKind="comes from",
1525
+ ~rows=M.issuedCommands->Array.map(({name, fromEventTypes}) => (name, fromEventTypes)),
1526
+ ~keyNames=epCommandNames,
1527
+ ~valueNames=M.delegateEventNames,
1528
+ ),
1529
+ )
1530
+ M.issuedCommands->Array.forEach(({name: commandName, fromEventTypes}) => {
1531
+ let key = `${E.Spec.name}.${commandName}`
1532
+ eventsByCommand->Dict.set(
1533
+ key,
1534
+ Array.concat(
1535
+ eventsByCommand->Dict.get(key)->Option.getOr([]),
1536
+ qualify(~prefix=name, fromEventTypes),
1537
+ ),
1538
+ )
1539
+ })
1540
+ M.handledEvents->Array.forEach(({name: eventName, toCommandTypes}) => {
1541
+ // The qualifier says which way the command goes: plugin-qualified
1542
+ // inward, EP-qualified back to the port.
1543
+ let qualified =
1544
+ toCommandTypes->Array.map(cmd =>
1545
+ M.delegateCommandNames->Array.includes(cmd)
1546
+ ? `${name}.${cmd}`
1547
+ : `${E.Spec.name}.${cmd}`
1548
+ )
1549
+ let key = `${E.Spec.name}.${eventName}`
1550
+ commandsByEvent->Dict.set(
1551
+ key,
1552
+ Array.concat(commandsByEvent->Dict.get(key)->Option.getOr([]), qualified),
1553
+ )
1554
+ })
1555
+ })
1556
+
1428
1557
  ({
1429
1558
  Reventless.Plugin.name: E.Spec.name,
1430
1559
  delegateNames,
1431
1560
  eventTypes: qualify(~prefix=E.Spec.name, eventVariantNames(E.Spec.eventSchema)),
1432
1561
  commandTypes: qualify(~prefix=E.Spec.name, commandVariantNames(E.Spec.commandSchema)),
1562
+ handledEvents: Some(
1563
+ commandsByEvent
1564
+ ->Dict.toArray
1565
+ ->Array.map(((eventName, cmds)) => ({
1566
+ Reventless.Plugin.name: eventName,
1567
+ toCommandTypes: dedupe(cmds),
1568
+ }: Reventless.Plugin.handledEventDef)),
1569
+ ),
1570
+ issuedCommands: Some(
1571
+ eventsByCommand
1572
+ ->Dict.toArray
1573
+ ->Array.map(((commandName, evs)) => ({
1574
+ Reventless.Plugin.name: commandName,
1575
+ fromEventTypes: dedupe(evs),
1576
+ }: Reventless.Plugin.issuedCommandDef)),
1577
+ ),
1433
1578
  }: Reventless.Plugin.extensionDef)
1434
1579
  })
1435
1580
 
1436
1581
  // ── Extension points (producer side) ──────────────────────────────────────
1437
1582
  //
1438
- // The mapping modules connect one Delegate (an aggregate / DCB event log) to
1439
- // one extension point. Several mappings can target the SAME extension point
1440
- // (Make2 / Make3 / MakeMulti), so group by the EP's dotted spec name and union
1441
- // each delegate's name + its source event types. Source events are qualified
1442
- // with the plugin name to match `producedEventTypes` on the write-sides, so the
1443
- // event graph can draw producing-write-side → event → extension-point.
1444
-
1445
- let dedupe = (xs: array<string>) =>
1446
- xs->Belt.Set.String.fromArray->Belt.Set.String.toArray
1583
+ // Several mappings can target one EP, so group by its dotted name and union each
1584
+ // delegate's name and source events. Source events are plugin-qualified to match
1585
+ // `producedEventTypes`, so the graph can draw write-side → event → EP.
1447
1586
 
1448
1587
  let epByName: Dict.t<(array<string>, array<string>, array<string>)> = Dict.make()
1588
+ // Per EP: published event → the internal events producing it, unioned over
1589
+ // every mapping targeting it.
1590
+ let epPublished: Dict.t<Dict.t<array<string>>> = Dict.make()
1591
+ // The command direction's mirror: arriving command → the delegate commands it
1592
+ // routes to. Unioned the same way — one port's inbound protocol is split across
1593
+ // its mappings, so a command handled by a sibling is not dead surface.
1594
+ let epAccepted: Dict.t<Dict.t<array<string>>> = Dict.make()
1595
+
1449
1596
  extensionPoints->Array.forEach((module(M: ReventlessInfra.ExtensionPointMapping.Mapping)) => {
1450
1597
  let epName = M.ExtensionPoint.name
1451
1598
  let sourceEvents = qualify(~prefix=name, eventVariantNames(M.Delegate.eventSchema->S.castToUnknown))
@@ -1457,7 +1604,217 @@ let make = (
1457
1604
  epName,
1458
1605
  (Array.concat(dels, [M.Delegate.name]), Array.concat(evs, sourceEvents), Array.concat(cmds, commands)),
1459
1606
  )
1607
+
1608
+ let label = `${epName} ← ${M.Delegate.name}`
1609
+ let publishedNames = allVariantNames(M.ExtensionPoint.eventSchema)
1610
+ let sourceNames = allVariantNames(M.Delegate.eventSchema->S.castToUnknown)
1611
+ pushAll(
1612
+ tableFailures,
1613
+ translationTableFailures(~label, ~declared=M.publishedEvents, ~publishedNames, ~sourceNames),
1614
+ )
1615
+
1616
+ // ── The command direction ────────────────────────────────────────────────
1617
+ let acceptedNames = allVariantNames(M.ExtensionPoint.commandSchema)
1618
+ let delegateCommandNames = allVariantNames(M.Delegate.commandSchema)
1619
+ pushAll(
1620
+ tableFailures,
1621
+ commandTableFailures(
1622
+ ~label,
1623
+ ~keyed="acceptedCommands",
1624
+ ~valueKind="routes to",
1625
+ ~rows=M.acceptedCommands->Array.map(({name, toCommandTypes}) => (name, toCommandTypes)),
1626
+ ~keyNames=acceptedNames,
1627
+ ~valueNames=delegateCommandNames,
1628
+ ),
1629
+ )
1630
+
1631
+ // The mirror of the published-event probe: one synthesised EP command per
1632
+ // constructor, through the author's own mapIncomingCommand. Cheaper than the
1633
+ // event probe — the signature reaches no query engine.
1634
+ let acceptedObserved = []
1635
+ acceptedNames->Array.forEach(cmd => {
1636
+ let synthesised = Reventless.DcbTag.isVariantPayloadBearing(
1637
+ M.ExtensionPoint.commandSchema->S.castToUnknown,
1638
+ cmd,
1639
+ )
1640
+ ? Dict.fromArray([("TAG", JSON.Encode.string(cmd))])->JSON.Encode.object
1641
+ : JSON.Encode.string(cmd)
1642
+ switch (
1643
+ try {
1644
+ let command =
1645
+ Reventless.Message.fillMissingDefaults(
1646
+ M.ExtensionPoint.commandSchema,
1647
+ synthesised,
1648
+ [],
1649
+ )->Reventless.Util_Sury.fromJson(M.ExtensionPoint.commandSchema)
1650
+ let decodedAs =
1651
+ command
1652
+ ->Reventless.Message.encode(M.ExtensionPoint.commandSchema)
1653
+ ->Reventless.Message.variantNameOfJson
1654
+ decodedAs != cmd
1655
+ ? Error(`a synthesised "${cmd}" decoded as "${decodedAs}"`)
1656
+ : Ok(M.mapIncomingCommand(probeId, command, probeMeta))
1657
+ } catch {
1658
+ | _ => Error(`the mapping raised on a synthesised "${cmd}"`)
1659
+ }
1660
+ ) {
1661
+ | Ok(actions) =>
1662
+ actions->Array.forEach(action =>
1663
+ switch action {
1664
+ | ReventlessInfra.ExtensionPointMapping.PublishCommand(_, routed) =>
1665
+ acceptedObserved
1666
+ ->Array.push((
1667
+ cmd,
1668
+ routed
1669
+ ->Reventless.Message.encode(M.Delegate.commandSchema)
1670
+ ->Reventless.Message.variantNameOfJson,
1671
+ ))
1672
+ ->ignore
1673
+ | HandleDirective(_, _) => ()
1674
+ }
1675
+ )
1676
+ | Error(reason) =>
1677
+ tableWarnings->Array.push(`${label}: not checked against the arms — ${reason}.`)->ignore
1678
+ }
1679
+ })
1680
+ acceptedObserved->Array.forEach(((cmd, routed)) =>
1681
+ if (
1682
+ !(
1683
+ M.acceptedCommands->Array.some(({name, toCommandTypes}) =>
1684
+ name == cmd && toCommandTypes->Array.includes(routed)
1685
+ )
1686
+ )
1687
+ ) {
1688
+ tableFailures
1689
+ ->Array.push(
1690
+ `${label}: "${cmd}" routes to "${routed}", which acceptedCommands does not declare.`,
1691
+ )
1692
+ ->ignore
1693
+ }
1694
+ )
1695
+
1696
+ let acceptedTable = epAccepted->Dict.get(epName)->Option.getOr(Dict.make())
1697
+ M.acceptedCommands->Array.forEach(({name: accepted, toCommandTypes}) => {
1698
+ let key = `${epName}.${accepted}`
1699
+ acceptedTable->Dict.set(
1700
+ key,
1701
+ Array.concat(
1702
+ acceptedTable->Dict.get(key)->Option.getOr([]),
1703
+ qualify(~prefix=name, toCommandTypes),
1704
+ ),
1705
+ )
1706
+ })
1707
+ epAccepted->Dict.set(epName, acceptedTable)
1708
+
1709
+ switch M.mapOutgoingEvent {
1710
+ | None =>
1711
+ if Array.length(M.publishedEvents) > 0 {
1712
+ tableFailures
1713
+ ->Array.push(
1714
+ `${label}: publishedEvents declares ${M.publishedEvents
1715
+ ->Array.length
1716
+ ->Int.toString} event(s), but the mapping has no mapOutgoingEvent and ` ++
1717
+ `publishes nothing.`,
1718
+ )
1719
+ ->ignore
1720
+ }
1721
+ | Some(mapOutgoing) =>
1722
+ // One synthesised event per Delegate constructor, through the author's own
1723
+ // function — not the compiled one, which logs and re-encodes.
1724
+ let observed = []
1725
+ let followed = []
1726
+ sourceNames->Array.forEach(src => {
1727
+ let synthesised = Reventless.DcbTag.isVariantPayloadBearing(
1728
+ M.Delegate.eventSchema->S.castToUnknown,
1729
+ src,
1730
+ )
1731
+ ? Dict.fromArray([("TAG", JSON.Encode.string(src))])->JSON.Encode.object
1732
+ : JSON.Encode.string(src)
1733
+ let outcome = try {
1734
+ // Filled directly rather than through `parseJsonTolerant`, which warns
1735
+ // about inventing values — here the invention is the point.
1736
+ let event =
1737
+ Reventless.Message.fillMissingDefaults(M.Delegate.eventSchema, synthesised, [])
1738
+ ->Reventless.Util_Sury.fromJson(M.Delegate.eventSchema)
1739
+ // A fabricated payload can decode as a sibling constructor; only a
1740
+ // value that round-trips is judged.
1741
+ let decodedAs =
1742
+ event
1743
+ ->Reventless.Message.encode(M.Delegate.eventSchema)
1744
+ ->Reventless.Message.variantNameOfJson
1745
+ if decodedAs != src {
1746
+ Error(`a synthesised "${src}" decoded as "${decodedAs}"`)
1747
+ } else {
1748
+ let actions = mapOutgoing(probeId, event, probeMeta, probeQueryEngine)
1749
+ actions->Array.forEach(action =>
1750
+ switch action {
1751
+ | ReventlessInfra.ExtensionPointMapping.PublishEvent(_, published) =>
1752
+ observed
1753
+ ->Array.push((
1754
+ src,
1755
+ published
1756
+ ->Reventless.Message.encode(M.ExtensionPoint.eventSchema)
1757
+ ->Reventless.Message.variantNameOfJson,
1758
+ ))
1759
+ ->ignore
1760
+ | PublishEventAsync(_) | HandleDirective(_, _) => ()
1761
+ }
1762
+ )
1763
+ // A promise hides what it will publish — leave the arm unjudged.
1764
+ actions->Array.some(action =>
1765
+ switch action {
1766
+ | PublishEventAsync(_) => true
1767
+ | PublishEvent(_, _) | HandleDirective(_, _) => false
1768
+ }
1769
+ )
1770
+ ? Error(`"${src}" publishes behind a promise`)
1771
+ : Ok()
1772
+ }
1773
+ } catch {
1774
+ | _ => Error(`the mapping raised on a synthesised "${src}"`)
1775
+ }
1776
+ switch outcome {
1777
+ | Ok() => followed->Array.push(src)->ignore
1778
+ | Error(reason) =>
1779
+ tableWarnings->Array.push(`${label}: not checked against the arms — ${reason}.`)->ignore
1780
+ }
1781
+ })
1782
+
1783
+ let (failures, warnings) = translationTableDrift(
1784
+ ~label,
1785
+ ~declared=M.publishedEvents,
1786
+ ~observed,
1787
+ ~followed,
1788
+ )
1789
+ pushAll(tableFailures, failures)
1790
+ pushAll(tableWarnings, warnings)
1791
+ }
1792
+
1793
+ // Dead protocol surface: an event of the published contract that no arm
1794
+ // produces, which a subscriber may already be routing.
1795
+ let declaredNames = M.publishedEvents->Array.map(p => p.name)
1796
+ publishedNames
1797
+ ->Array.filter(p => !(declaredNames->Array.includes(p)))
1798
+ ->Array.forEach(p =>
1799
+ tableWarnings
1800
+ ->Array.push(`${label}: the extension point publishes "${p}", which no arm produces.`)
1801
+ ->ignore
1802
+ )
1803
+
1804
+ let table = epPublished->Dict.get(epName)->Option.getOr(Dict.make())
1805
+ M.publishedEvents->Array.forEach(({name: published, fromEventTypes}) => {
1806
+ let key = `${epName}.${published}`
1807
+ table->Dict.set(
1808
+ key,
1809
+ Array.concat(
1810
+ table->Dict.get(key)->Option.getOr([]),
1811
+ qualify(~prefix=name, fromEventTypes),
1812
+ ),
1813
+ )
1814
+ })
1815
+ epPublished->Dict.set(epName, table)
1460
1816
  })
1817
+
1461
1818
  let extensionPointDefs =
1462
1819
  epByName
1463
1820
  ->Dict.toArray
@@ -1466,8 +1823,52 @@ let make = (
1466
1823
  delegateNames: dedupe(dels),
1467
1824
  sourceEventTypes: dedupe(evs),
1468
1825
  commandTypes: Some(dedupe(cmds)),
1826
+ publishedEvents: Some(
1827
+ epPublished
1828
+ ->Dict.get(epName)
1829
+ ->Option.getOr(Dict.make())
1830
+ ->Dict.toArray
1831
+ ->Array.map(((published, sources)) => ({
1832
+ Reventless.Plugin.name: published,
1833
+ fromEventTypes: dedupe(sources),
1834
+ }: Reventless.Plugin.publishedEventDef)),
1835
+ ),
1836
+ acceptedCommands: Some(
1837
+ epAccepted
1838
+ ->Dict.get(epName)
1839
+ ->Option.getOr(Dict.make())
1840
+ ->Dict.toArray
1841
+ ->Array.map(((accepted, routed)) => ({
1842
+ Reventless.Plugin.name: accepted,
1843
+ toCommandTypes: dedupe(routed),
1844
+ }: Reventless.Plugin.acceptedCommandDef)),
1845
+ ),
1469
1846
  }: Reventless.Plugin.extensionPointDef))
1470
1847
 
1848
+ // Dead inbound surface, judged only after every mapping on an EP has been seen:
1849
+ // one port's inbound protocol is split across its mappings, so a command the
1850
+ // Plugin mapping ignores may be the UiFragment mapping's whole job.
1851
+ epByName
1852
+ ->Dict.toArray
1853
+ ->Array.forEach(((epName, (_, _, cmds))) => {
1854
+ let handled =
1855
+ epAccepted->Dict.get(epName)->Option.getOr(Dict.make())->Dict.keysToArray
1856
+ cmds
1857
+ ->dedupe
1858
+ ->Array.forEach(cmd =>
1859
+ if !(handled->Array.includes(cmd)) {
1860
+ tableWarnings
1861
+ ->Array.push(
1862
+ `${epName}: the extension point accepts "${cmd}", which no arm handles — a ` ++
1863
+ `sender gets no error and nothing happens.`,
1864
+ )
1865
+ ->ignore
1866
+ }
1867
+ )
1868
+ })
1869
+
1870
+ reportTranslationTables(~pluginName=name, ~failures=tableFailures, ~warnings=tableWarnings)
1871
+
1471
1872
  // Second pass, on purpose: commands are built well before `linkedViews` is
1472
1873
  // assembled, so the check cannot run inline where the defs are made.
1473
1874
  checkDeclaredTransitions(
@@ -1497,5 +1898,7 @@ let make = (
1497
1898
  extensionPoints: Some(extensionPointDefs),
1498
1899
  requiredStores: Some(requiredStores),
1499
1900
  requiredStoreDeclarations: Some(requiredStoreDeclarations),
1901
+ traitDeclarations: Some(traitDeclarations),
1902
+ requiredCapabilities: Some(requiredCapabilities),
1500
1903
  }
1501
1904
  }