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

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.
@@ -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,184 @@ 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.
296
- //
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.
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
+ // `@transition` names states belonging to another component's enum, which the PPX
377
+ // only ever sees as names — so a misspelling compiles clean and produces a command
378
+ // legal in a state no row is in. Both sides are in hand here. A state the linked
379
+ // views do not declare raises (this runs at assembly, never in a Lambda, so that is
380
+ // a failed deploy); no resolvable view only warns. Checked against the UNION of the
381
+ // linked views: a slice feeding two is not claiming which one.
301
382
  let checkDeclaredTransitions = (
302
383
  ~pluginName: string,
303
384
  ~writables: array<Reventless.Plugin.writableDef>,
@@ -362,24 +443,10 @@ let checkDeclaredTransitions = (
362
443
  }
363
444
  }
364
445
 
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.
446
+ // Whether the declared transitions AGREE across an entity — a graph of valid names
447
+ // can still leave a state nothing reaches. Warnings only: a smell is not a mistake,
448
+ // and one that stops a deploy gets silenced rather than fixed. Pure, so the rule
449
+ // tests without a log; `checkLifecycleTopology` reports.
383
450
  let lifecycleTopologyFindings = (
384
451
  ~writables: array<Reventless.Plugin.writableDef>,
385
452
  ~lifecycleStatesByView: dict<array<string>>,
@@ -388,20 +455,15 @@ let lifecycleTopologyFindings = (
388
455
  lifecycleStatesByView
389
456
  ->Dict.toArray
390
457
  ->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.
458
+ // Arrival only, so a creating command (a target and no from-set) counts
459
+ // towards reachability like any other edge.
396
460
  let reachable = writables->Array.reduce([], (acc, w) =>
397
461
  w.linkedViews->Array.includes(view)
398
462
  ? Array.concat(acc, w.commands->Array.filterMap(cmd => cmd.targetState))
399
463
  : acc
400
464
  )
401
465
  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.
466
+ // Rows start in the first declared state, so nothing pointing at it is fine.
405
467
  let initial = states->Array.get(0)
406
468
 
407
469
  states->Array.forEach(state => {
@@ -414,23 +476,9 @@ let lifecycleTopologyFindings = (
414
476
  ))
415
477
  ->ignore
416
478
  }
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.
479
+ // NOT checked: a state with no way out. Terminal states are ordinary,
480
+ // and the only fix a lint could suggest — `@retired` — withdraws the
481
+ // rows from reads rather than marking an ending.
434
482
  })
435
483
  }
436
484
  })
@@ -446,44 +494,12 @@ let checkLifecycleTopology = (
446
494
  log.warn(~comp="Plugin_Structure", `${pluginName}/${view}: ${message}`)
447
495
  )
448
496
 
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.
497
+ // NOT here: an event-consumption completeness check. `consumedEventTypes` drops
498
+ // payload-less variants, which is exactly what a lifecycle-moving event usually
499
+ // is — so the events such a rule is about are the ones this metadata omits.
500
+
501
+ // Which rung produced the label, published as `labelFieldSource`: a consumer with
502
+ // a name rule of its own has to rank a declaration (rung 1) against a guess.
487
503
  type labelFieldSource =
488
504
  | Annotation
489
505
  | Convention
@@ -504,22 +520,9 @@ type labelResolution = {
504
520
  source: labelFieldSource,
505
521
  }
506
522
 
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.
523
+ // The field a state is named by: `@displayName`, else a candidate named
524
+ // name/title/label/displayName, else the first candidate, else `id` with a
525
+ // warning. A candidate is a non-TAG field, not named `id`, passing `isLabelShape`.
523
526
  let labelFieldsFromStateSchema = (
524
527
  ~entityName: string,
525
528
  stateSchema: S.t<unknown>,
@@ -559,18 +562,13 @@ let labelFieldsFromStateSchema = (
559
562
  }
560
563
  }
561
564
 
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.
565
+ // ── Per-variant event / error field extraction ───────────────────────────────
566
+ // Mirrors the command walk for emitted events. Module-level so Platform_Admin,
567
+ // whose components never pass through `make`, derives its defs the same way.
568
+
569
+ // A variant's cross-entity references, shared by the command and event walks.
570
+ // `getFieldTarget`, not `getTarget`: an `array<string>` field declares it on the
571
+ // element schema.
574
572
  let extractReferences = (properties: dict<S.t<unknown>>): array<
575
573
  Reventless.Plugin.fieldReference,
576
574
  > =>
@@ -607,10 +605,8 @@ let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
607
605
  | _ => None
608
606
  }
609
607
  )
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.
608
+ // Payload-less variants compile to a bare string literal. Kept here (though not
609
+ // in produced/consumedEventTypes) so the graph can draw the orphan node.
614
610
  | String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties=Dict.make()))
615
611
  | _ => None
616
612
  }
@@ -622,21 +618,16 @@ let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.even
622
618
  | _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
623
619
  }
624
620
 
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.
621
+ // Errors walk identically to events; only the def type differs, so a consumer
622
+ // can tell a refusal from a fact. Shared rather than copied, so they cannot drift.
630
623
  let extractErrorDefs = (errorSchema: S.t<unknown>): array<Reventless.Plugin.errorDef> =>
631
624
  extractEventDefs(errorSchema)->Array.map(({name, schema, references}) => (
632
625
  {name, schema, references}: Reventless.Plugin.errorDef
633
626
  ))
634
627
 
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.
628
+ // Module-level like the event walk: the synthetic Platform_Admin structure has no
629
+ // `module(Aggregate.T)` to hand `make`, and a second copy of this walk is what let
630
+ // its metadata drift from the SDL.
640
631
 
641
632
  // Aggregate commands that initialize a new aggregate instance are Collection-level
642
633
  // (shown as table-top buttons); all others are Instance-level (shown per-row).
@@ -677,32 +668,18 @@ let commandLevelAndId = (~isAggregate, ~variantName, properties: dict<S.t<unknow
677
668
  }
678
669
  }
679
670
 
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.
671
+ // The server's rule as keys a client checks against `identity.groups ++
672
+ // config.accessTiers`; an any-of, since `isAllowed` is `some`. The rules asking
673
+ // for nothing checkable — and `DenyAll` — publish no keys rather than an
674
+ // unsatisfiable one.
693
675
  let accessKeysFor = (rule: Reventless.Authorization.permission): option<array<string>> =>
694
676
  switch rule {
695
677
  | AllowGroups(groups) if groups->Array.length > 0 => Some(groups)
696
678
  | AllowGroups(_) | AllowAuthenticated | AllowAnonymous | DenyAll => None
697
679
  }
698
680
 
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.
681
+ // Each mutation argument's GraphQL type onto its property, so a consumer declares
682
+ // the variable the server expects. Mutates the freshly derived schema in place.
706
683
  let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
707
684
  schema
708
685
  ->JSON.Decode.object
@@ -738,10 +715,7 @@ let toCommandDef = (
738
715
  let mkDef = (~variantName, ~properties) => {
739
716
  let (level, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
740
717
  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.
718
+ // Per-variant, but attached by the PPX to the *parent* schema as one dict.
745
719
  let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
746
720
  // The `@transition` target (the command's *to* status), read the same way
747
721
  // as allowedStates. None ⇒ AutoUI's board resolver falls back to its
@@ -780,28 +754,13 @@ let toCommandDef = (
780
754
  }
781
755
  ({
782
756
  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`.
757
+ // The derived schema, not sury's raw one, which drops every `x-reventless-*`
758
+ // marker the PPX put on the fields. Also carries `x-reventless-graphql-type`.
793
759
  schema: annotatedSchema->JSON.stringify,
794
760
  level,
795
761
  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.
762
+ // Empty for a `@noApi` variant: `mutationFieldFor` would resolve it to a
763
+ // sibling's field, which reads as callable. Exposed variants are unchanged.
805
764
  mutationField,
806
765
  references,
807
766
  allowedStates,
@@ -871,24 +830,10 @@ let visibilityTag = (v: Reventless.Visibility.t): option<string> =>
871
830
  }
872
831
 
873
832
  /**
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.
833
+ A read model's `queryableDef` assembled from its spec, for the platform's own
834
+ components: `make` takes `module(ReadModel.T)` values, and at structure-assembly
835
+ time the platform has no built module to hand itself. Every extractor here is the
836
+ one `make` calls, so the two cannot drift — a hand-written record did, four times.
892
837
  */
893
838
  let queryableDefFromSpec = (
894
839
  ~plugin: string,
@@ -940,23 +885,18 @@ let make = (
940
885
  ~inboundTranslationSlices: array<module(ReventlessInfra.InboundTranslationSlice.T)>=[],
941
886
  ~extensions: array<module(ReventlessInfra.Extension.Blueprint)>=[],
942
887
  ~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.
888
+ // Component name → chapter, captured from each component's source folder by the
889
+ // plugin generator. Keyed by `Spec.name`; no entry renders flat.
949
890
  ~componentChapters: dict<string>=Dict.make(),
950
891
  ): Reventless.Plugin.pluginStructure => {
951
892
  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.
893
+ // Payload-less variants dropped: the graph must not claim an edge a DCB lookup
894
+ // cannot WHERE-clause on.
955
895
  let eventVariantNames = schema => Reventless.DcbTag.extractVariantNames(schema)
956
- // Command schemas: keep every constructor (including payload-less) so the
957
- // GraphQL mutation surface stays addressable.
896
+ // Every constructor, so the mutation surface stays addressable.
958
897
  let commandVariantNames = schema => Reventless.DcbTag.extractAllVariantNames(schema)
959
898
  let qualify = (~prefix, names) => names->Array.map(n => prefix ++ "." ++ n)
899
+ let dedupe = (xs: array<string>) => xs->Belt.Set.String.fromArray->Belt.Set.String.toArray
960
900
 
961
901
  // ── Per-component event type extraction ────────────────────────────────────
962
902
 
@@ -1058,21 +998,10 @@ let make = (
1058
998
 
1059
999
  // ── Declared object stores ─────────────────────────────────────────────────
1060
1000
  //
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.
1001
+ // A storage-ref field states the deployment needs that store. Collected here so
1002
+ // the requirement travels with the structure, qualified to `{plugin}.{store}`
1003
+ // and deduplicated, each entry keeping its `(component, field)` site.
1004
+ // `requiredStores` is derived from the same walk, so the two cannot disagree.
1076
1005
 
1077
1006
  let storesFromProperties = (~component, properties: dict<S.t<unknown>>): array<
1078
1007
  Reventless.Plugin.requiredStoreDeclaration,
@@ -1088,11 +1017,8 @@ let make = (
1088
1017
  Reventless.Plugin.store: target.plugin->Option.getOr(name) ++ "." ++ target.store,
1089
1018
  component,
1090
1019
  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.
1020
+ // The source text, not a guess: `target.plugin` is `None` exactly when the
1021
+ // field left the store unqualified, and only here does that survive.
1096
1022
  annotation: Some(
1097
1023
  switch target.plugin {
1098
1024
  | None => target.store
@@ -1135,10 +1061,8 @@ let make = (
1135
1061
  }
1136
1062
  }
1137
1063
 
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.
1064
+ // One list feeds both the store collection and the lint below, so a field is
1065
+ // read exactly once by either.
1142
1066
  let storeDeclarationSites: array<(string, S.t<unknown>)> =
1143
1067
  [
1144
1068
  aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) => [
@@ -1165,9 +1089,7 @@ let make = (
1165
1089
  ) => (ITS.Spec.name, ITS.Spec.commandSchema->S.castToUnknown)),
1166
1090
  ]->Array.flat
1167
1091
 
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).
1092
+ // Sorted for a deterministic manifest; identical triples collapse.
1171
1093
  let requiredStoreDeclarations =
1172
1094
  storeDeclarationSites
1173
1095
  ->Array.flatMap(((component, schema)) => storesFromSchema(~component, schema))
@@ -1185,9 +1107,8 @@ let make = (
1185
1107
  ->Belt.Set.String.fromArray
1186
1108
  ->Belt.Set.String.toArray
1187
1109
 
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.
1110
+ // Two stores one edit apart provision twice. Hard failure, unlike the lint
1111
+ // below: by deploy time both buckets exist.
1191
1112
  switch Capability_Inference.collisions(requiredStoreDeclarations) {
1192
1113
  | [] => ()
1193
1114
  | found =>
@@ -1208,12 +1129,8 @@ let make = (
1208
1129
 
1209
1130
  // ── Build queryable defs ───────────────────────────────────────────────────
1210
1131
  //
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.
1132
+ // Internal views are carried here, tagged via `queryableDef.visibility`, so
1133
+ // developer tools see them; AutoUI's consumers re-filter on the tag.
1217
1134
  // View name -> the states its lifecycle field can hold, collected as the view
1218
1135
  // defs are built so the transition check below has both sides in one place.
1219
1136
  let lifecycleStatesByView: dict<array<string>> = Dict.make()
@@ -1250,10 +1167,7 @@ let make = (
1250
1167
  ~entityName=R.Spec.name,
1251
1168
  stateSchema,
1252
1169
  )
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).
1170
+ // Qualified to the plugin's event ids so they match the producers' nodes.
1257
1171
  let consumed = qualify(~prefix=name, R.consumedEventNames)
1258
1172
  recordRetired(~entityName=R.Spec.name, stateSchema)
1259
1173
  recordLifecycle(~entityName=R.Spec.name, stateSchema)
@@ -1422,30 +1336,112 @@ let make = (
1422
1336
 
1423
1337
  // ── Extensions ───────────────────────────────────────────────────────────
1424
1338
 
1339
+ let tableFailures = []
1340
+ let tableWarnings = []
1341
+ let pushAll = (into, xs) => xs->Array.forEach(x => into->Array.push(x)->ignore)
1342
+
1343
+ // Every constructor, payload-less included: a declaration is checked against
1344
+ // what the author can write, not the payload-filtered subset the edges use.
1345
+ let allVariantNames = schema => Reventless.DcbTag.extractAllVariantNames(schema)
1346
+
1425
1347
  let extensionDefs =
1426
1348
  extensions->Array.map((module(E: ReventlessInfra.Extension.Blueprint)) => {
1427
1349
  let delegateNames = E.mappings->Array.map((module(M: E.Mapping)) => M.delegateName)
1350
+ let epEventNames = allVariantNames(E.Spec.eventSchema)
1351
+ let epCommandNames = allVariantNames(E.Spec.commandSchema)
1352
+
1353
+ // Mappings sharing one EP union their tables — the same event may route to
1354
+ // a different delegate's command in each.
1355
+ let commandsByEvent: Dict.t<array<string>> = Dict.make()
1356
+ let eventsByCommand: Dict.t<array<string>> = Dict.make()
1357
+ E.mappings->Array.forEach((module(M: E.Mapping)) => {
1358
+ let label = `${E.Spec.name} → ${M.delegateName}`
1359
+ pushAll(
1360
+ tableFailures,
1361
+ handledTableFailures(
1362
+ ~label,
1363
+ ~declared=M.handledEvents,
1364
+ ~eventNames=epEventNames,
1365
+ ~commandNames=Array.concat(M.delegateCommandNames, epCommandNames),
1366
+ ),
1367
+ )
1368
+ pushAll(
1369
+ tableFailures,
1370
+ commandTableFailures(
1371
+ ~label,
1372
+ ~keyed="issuedCommands",
1373
+ ~valueKind="comes from",
1374
+ ~rows=M.issuedCommands->Array.map(({name, fromEventTypes}) => (name, fromEventTypes)),
1375
+ ~keyNames=epCommandNames,
1376
+ ~valueNames=M.delegateEventNames,
1377
+ ),
1378
+ )
1379
+ M.issuedCommands->Array.forEach(({name: commandName, fromEventTypes}) => {
1380
+ let key = `${E.Spec.name}.${commandName}`
1381
+ eventsByCommand->Dict.set(
1382
+ key,
1383
+ Array.concat(
1384
+ eventsByCommand->Dict.get(key)->Option.getOr([]),
1385
+ qualify(~prefix=name, fromEventTypes),
1386
+ ),
1387
+ )
1388
+ })
1389
+ M.handledEvents->Array.forEach(({name: eventName, toCommandTypes}) => {
1390
+ // The qualifier says which way the command goes: plugin-qualified
1391
+ // inward, EP-qualified back to the port.
1392
+ let qualified =
1393
+ toCommandTypes->Array.map(cmd =>
1394
+ M.delegateCommandNames->Array.includes(cmd)
1395
+ ? `${name}.${cmd}`
1396
+ : `${E.Spec.name}.${cmd}`
1397
+ )
1398
+ let key = `${E.Spec.name}.${eventName}`
1399
+ commandsByEvent->Dict.set(
1400
+ key,
1401
+ Array.concat(commandsByEvent->Dict.get(key)->Option.getOr([]), qualified),
1402
+ )
1403
+ })
1404
+ })
1405
+
1428
1406
  ({
1429
1407
  Reventless.Plugin.name: E.Spec.name,
1430
1408
  delegateNames,
1431
1409
  eventTypes: qualify(~prefix=E.Spec.name, eventVariantNames(E.Spec.eventSchema)),
1432
1410
  commandTypes: qualify(~prefix=E.Spec.name, commandVariantNames(E.Spec.commandSchema)),
1411
+ handledEvents: Some(
1412
+ commandsByEvent
1413
+ ->Dict.toArray
1414
+ ->Array.map(((eventName, cmds)) => ({
1415
+ Reventless.Plugin.name: eventName,
1416
+ toCommandTypes: dedupe(cmds),
1417
+ }: Reventless.Plugin.handledEventDef)),
1418
+ ),
1419
+ issuedCommands: Some(
1420
+ eventsByCommand
1421
+ ->Dict.toArray
1422
+ ->Array.map(((commandName, evs)) => ({
1423
+ Reventless.Plugin.name: commandName,
1424
+ fromEventTypes: dedupe(evs),
1425
+ }: Reventless.Plugin.issuedCommandDef)),
1426
+ ),
1433
1427
  }: Reventless.Plugin.extensionDef)
1434
1428
  })
1435
1429
 
1436
1430
  // ── Extension points (producer side) ──────────────────────────────────────
1437
1431
  //
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
1432
+ // Several mappings can target one EP, so group by its dotted name and union each
1433
+ // delegate's name and source events. Source events are plugin-qualified to match
1434
+ // `producedEventTypes`, so the graph can draw write-side → event → EP.
1447
1435
 
1448
1436
  let epByName: Dict.t<(array<string>, array<string>, array<string>)> = Dict.make()
1437
+ // Per EP: published event → the internal events producing it, unioned over
1438
+ // every mapping targeting it.
1439
+ let epPublished: Dict.t<Dict.t<array<string>>> = Dict.make()
1440
+ // The command direction's mirror: arriving command → the delegate commands it
1441
+ // routes to. Unioned the same way — one port's inbound protocol is split across
1442
+ // its mappings, so a command handled by a sibling is not dead surface.
1443
+ let epAccepted: Dict.t<Dict.t<array<string>>> = Dict.make()
1444
+
1449
1445
  extensionPoints->Array.forEach((module(M: ReventlessInfra.ExtensionPointMapping.Mapping)) => {
1450
1446
  let epName = M.ExtensionPoint.name
1451
1447
  let sourceEvents = qualify(~prefix=name, eventVariantNames(M.Delegate.eventSchema->S.castToUnknown))
@@ -1457,7 +1453,217 @@ let make = (
1457
1453
  epName,
1458
1454
  (Array.concat(dels, [M.Delegate.name]), Array.concat(evs, sourceEvents), Array.concat(cmds, commands)),
1459
1455
  )
1456
+
1457
+ let label = `${epName} ← ${M.Delegate.name}`
1458
+ let publishedNames = allVariantNames(M.ExtensionPoint.eventSchema)
1459
+ let sourceNames = allVariantNames(M.Delegate.eventSchema->S.castToUnknown)
1460
+ pushAll(
1461
+ tableFailures,
1462
+ translationTableFailures(~label, ~declared=M.publishedEvents, ~publishedNames, ~sourceNames),
1463
+ )
1464
+
1465
+ // ── The command direction ────────────────────────────────────────────────
1466
+ let acceptedNames = allVariantNames(M.ExtensionPoint.commandSchema)
1467
+ let delegateCommandNames = allVariantNames(M.Delegate.commandSchema)
1468
+ pushAll(
1469
+ tableFailures,
1470
+ commandTableFailures(
1471
+ ~label,
1472
+ ~keyed="acceptedCommands",
1473
+ ~valueKind="routes to",
1474
+ ~rows=M.acceptedCommands->Array.map(({name, toCommandTypes}) => (name, toCommandTypes)),
1475
+ ~keyNames=acceptedNames,
1476
+ ~valueNames=delegateCommandNames,
1477
+ ),
1478
+ )
1479
+
1480
+ // The mirror of the published-event probe: one synthesised EP command per
1481
+ // constructor, through the author's own mapIncomingCommand. Cheaper than the
1482
+ // event probe — the signature reaches no query engine.
1483
+ let acceptedObserved = []
1484
+ acceptedNames->Array.forEach(cmd => {
1485
+ let synthesised = Reventless.DcbTag.isVariantPayloadBearing(
1486
+ M.ExtensionPoint.commandSchema->S.castToUnknown,
1487
+ cmd,
1488
+ )
1489
+ ? Dict.fromArray([("TAG", JSON.Encode.string(cmd))])->JSON.Encode.object
1490
+ : JSON.Encode.string(cmd)
1491
+ switch (
1492
+ try {
1493
+ let command =
1494
+ Reventless.Message.fillMissingDefaults(
1495
+ M.ExtensionPoint.commandSchema,
1496
+ synthesised,
1497
+ [],
1498
+ )->Reventless.Util_Sury.fromJson(M.ExtensionPoint.commandSchema)
1499
+ let decodedAs =
1500
+ command
1501
+ ->Reventless.Message.encode(M.ExtensionPoint.commandSchema)
1502
+ ->Reventless.Message.variantNameOfJson
1503
+ decodedAs != cmd
1504
+ ? Error(`a synthesised "${cmd}" decoded as "${decodedAs}"`)
1505
+ : Ok(M.mapIncomingCommand(probeId, command, probeMeta))
1506
+ } catch {
1507
+ | _ => Error(`the mapping raised on a synthesised "${cmd}"`)
1508
+ }
1509
+ ) {
1510
+ | Ok(actions) =>
1511
+ actions->Array.forEach(action =>
1512
+ switch action {
1513
+ | ReventlessInfra.ExtensionPointMapping.PublishCommand(_, routed) =>
1514
+ acceptedObserved
1515
+ ->Array.push((
1516
+ cmd,
1517
+ routed
1518
+ ->Reventless.Message.encode(M.Delegate.commandSchema)
1519
+ ->Reventless.Message.variantNameOfJson,
1520
+ ))
1521
+ ->ignore
1522
+ | HandleDirective(_, _) => ()
1523
+ }
1524
+ )
1525
+ | Error(reason) =>
1526
+ tableWarnings->Array.push(`${label}: not checked against the arms — ${reason}.`)->ignore
1527
+ }
1528
+ })
1529
+ acceptedObserved->Array.forEach(((cmd, routed)) =>
1530
+ if (
1531
+ !(
1532
+ M.acceptedCommands->Array.some(({name, toCommandTypes}) =>
1533
+ name == cmd && toCommandTypes->Array.includes(routed)
1534
+ )
1535
+ )
1536
+ ) {
1537
+ tableFailures
1538
+ ->Array.push(
1539
+ `${label}: "${cmd}" routes to "${routed}", which acceptedCommands does not declare.`,
1540
+ )
1541
+ ->ignore
1542
+ }
1543
+ )
1544
+
1545
+ let acceptedTable = epAccepted->Dict.get(epName)->Option.getOr(Dict.make())
1546
+ M.acceptedCommands->Array.forEach(({name: accepted, toCommandTypes}) => {
1547
+ let key = `${epName}.${accepted}`
1548
+ acceptedTable->Dict.set(
1549
+ key,
1550
+ Array.concat(
1551
+ acceptedTable->Dict.get(key)->Option.getOr([]),
1552
+ qualify(~prefix=name, toCommandTypes),
1553
+ ),
1554
+ )
1555
+ })
1556
+ epAccepted->Dict.set(epName, acceptedTable)
1557
+
1558
+ switch M.mapOutgoingEvent {
1559
+ | None =>
1560
+ if Array.length(M.publishedEvents) > 0 {
1561
+ tableFailures
1562
+ ->Array.push(
1563
+ `${label}: publishedEvents declares ${M.publishedEvents
1564
+ ->Array.length
1565
+ ->Int.toString} event(s), but the mapping has no mapOutgoingEvent and ` ++
1566
+ `publishes nothing.`,
1567
+ )
1568
+ ->ignore
1569
+ }
1570
+ | Some(mapOutgoing) =>
1571
+ // One synthesised event per Delegate constructor, through the author's own
1572
+ // function — not the compiled one, which logs and re-encodes.
1573
+ let observed = []
1574
+ let followed = []
1575
+ sourceNames->Array.forEach(src => {
1576
+ let synthesised = Reventless.DcbTag.isVariantPayloadBearing(
1577
+ M.Delegate.eventSchema->S.castToUnknown,
1578
+ src,
1579
+ )
1580
+ ? Dict.fromArray([("TAG", JSON.Encode.string(src))])->JSON.Encode.object
1581
+ : JSON.Encode.string(src)
1582
+ let outcome = try {
1583
+ // Filled directly rather than through `parseJsonTolerant`, which warns
1584
+ // about inventing values — here the invention is the point.
1585
+ let event =
1586
+ Reventless.Message.fillMissingDefaults(M.Delegate.eventSchema, synthesised, [])
1587
+ ->Reventless.Util_Sury.fromJson(M.Delegate.eventSchema)
1588
+ // A fabricated payload can decode as a sibling constructor; only a
1589
+ // value that round-trips is judged.
1590
+ let decodedAs =
1591
+ event
1592
+ ->Reventless.Message.encode(M.Delegate.eventSchema)
1593
+ ->Reventless.Message.variantNameOfJson
1594
+ if decodedAs != src {
1595
+ Error(`a synthesised "${src}" decoded as "${decodedAs}"`)
1596
+ } else {
1597
+ let actions = mapOutgoing(probeId, event, probeMeta, probeQueryEngine)
1598
+ actions->Array.forEach(action =>
1599
+ switch action {
1600
+ | ReventlessInfra.ExtensionPointMapping.PublishEvent(_, published) =>
1601
+ observed
1602
+ ->Array.push((
1603
+ src,
1604
+ published
1605
+ ->Reventless.Message.encode(M.ExtensionPoint.eventSchema)
1606
+ ->Reventless.Message.variantNameOfJson,
1607
+ ))
1608
+ ->ignore
1609
+ | PublishEventAsync(_) | HandleDirective(_, _) => ()
1610
+ }
1611
+ )
1612
+ // A promise hides what it will publish — leave the arm unjudged.
1613
+ actions->Array.some(action =>
1614
+ switch action {
1615
+ | PublishEventAsync(_) => true
1616
+ | PublishEvent(_, _) | HandleDirective(_, _) => false
1617
+ }
1618
+ )
1619
+ ? Error(`"${src}" publishes behind a promise`)
1620
+ : Ok()
1621
+ }
1622
+ } catch {
1623
+ | _ => Error(`the mapping raised on a synthesised "${src}"`)
1624
+ }
1625
+ switch outcome {
1626
+ | Ok() => followed->Array.push(src)->ignore
1627
+ | Error(reason) =>
1628
+ tableWarnings->Array.push(`${label}: not checked against the arms — ${reason}.`)->ignore
1629
+ }
1630
+ })
1631
+
1632
+ let (failures, warnings) = translationTableDrift(
1633
+ ~label,
1634
+ ~declared=M.publishedEvents,
1635
+ ~observed,
1636
+ ~followed,
1637
+ )
1638
+ pushAll(tableFailures, failures)
1639
+ pushAll(tableWarnings, warnings)
1640
+ }
1641
+
1642
+ // Dead protocol surface: an event of the published contract that no arm
1643
+ // produces, which a subscriber may already be routing.
1644
+ let declaredNames = M.publishedEvents->Array.map(p => p.name)
1645
+ publishedNames
1646
+ ->Array.filter(p => !(declaredNames->Array.includes(p)))
1647
+ ->Array.forEach(p =>
1648
+ tableWarnings
1649
+ ->Array.push(`${label}: the extension point publishes "${p}", which no arm produces.`)
1650
+ ->ignore
1651
+ )
1652
+
1653
+ let table = epPublished->Dict.get(epName)->Option.getOr(Dict.make())
1654
+ M.publishedEvents->Array.forEach(({name: published, fromEventTypes}) => {
1655
+ let key = `${epName}.${published}`
1656
+ table->Dict.set(
1657
+ key,
1658
+ Array.concat(
1659
+ table->Dict.get(key)->Option.getOr([]),
1660
+ qualify(~prefix=name, fromEventTypes),
1661
+ ),
1662
+ )
1663
+ })
1664
+ epPublished->Dict.set(epName, table)
1460
1665
  })
1666
+
1461
1667
  let extensionPointDefs =
1462
1668
  epByName
1463
1669
  ->Dict.toArray
@@ -1466,8 +1672,52 @@ let make = (
1466
1672
  delegateNames: dedupe(dels),
1467
1673
  sourceEventTypes: dedupe(evs),
1468
1674
  commandTypes: Some(dedupe(cmds)),
1675
+ publishedEvents: Some(
1676
+ epPublished
1677
+ ->Dict.get(epName)
1678
+ ->Option.getOr(Dict.make())
1679
+ ->Dict.toArray
1680
+ ->Array.map(((published, sources)) => ({
1681
+ Reventless.Plugin.name: published,
1682
+ fromEventTypes: dedupe(sources),
1683
+ }: Reventless.Plugin.publishedEventDef)),
1684
+ ),
1685
+ acceptedCommands: Some(
1686
+ epAccepted
1687
+ ->Dict.get(epName)
1688
+ ->Option.getOr(Dict.make())
1689
+ ->Dict.toArray
1690
+ ->Array.map(((accepted, routed)) => ({
1691
+ Reventless.Plugin.name: accepted,
1692
+ toCommandTypes: dedupe(routed),
1693
+ }: Reventless.Plugin.acceptedCommandDef)),
1694
+ ),
1469
1695
  }: Reventless.Plugin.extensionPointDef))
1470
1696
 
1697
+ // Dead inbound surface, judged only after every mapping on an EP has been seen:
1698
+ // one port's inbound protocol is split across its mappings, so a command the
1699
+ // Plugin mapping ignores may be the UiFragment mapping's whole job.
1700
+ epByName
1701
+ ->Dict.toArray
1702
+ ->Array.forEach(((epName, (_, _, cmds))) => {
1703
+ let handled =
1704
+ epAccepted->Dict.get(epName)->Option.getOr(Dict.make())->Dict.keysToArray
1705
+ cmds
1706
+ ->dedupe
1707
+ ->Array.forEach(cmd =>
1708
+ if !(handled->Array.includes(cmd)) {
1709
+ tableWarnings
1710
+ ->Array.push(
1711
+ `${epName}: the extension point accepts "${cmd}", which no arm handles — a ` ++
1712
+ `sender gets no error and nothing happens.`,
1713
+ )
1714
+ ->ignore
1715
+ }
1716
+ )
1717
+ })
1718
+
1719
+ reportTranslationTables(~pluginName=name, ~failures=tableFailures, ~warnings=tableWarnings)
1720
+
1471
1721
  // Second pass, on purpose: commands are built well before `linkedViews` is
1472
1722
  // assembled, so the check cannot run inline where the defs are made.
1473
1723
  checkDeclaredTransitions(