@reventlessdev/reventless-spec 3.0.0-alpha.123 → 3.0.0-alpha.124

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.
@@ -6,16 +6,8 @@ type name = string
6
6
  @schema
7
7
  type version = string
8
8
 
9
- /**
10
- Classifies the business role of a plugin, carried on `pluginDefinition` so the
11
- plugin-lifecycle read model can segregate infrastructure/commercial/marketplace
12
- plugins from domain plugins in the admin Plugins view. Absent (on definitions
13
- persisted before this field existed) is read as `Domain`.
14
-
15
- Canonical here in `reventless-spec` because `pluginDefinition` (a `@schema` type
16
- nested in the lifecycle Message union) needs the sury schema; `ReventlessCore.Plugin_BuiltHook`
17
- re-exports this same type for its deploy-time metadata registry.
18
- */
9
+ /** A plugin's business role, used by the admin Plugins view to segregate
10
+ infrastructure from domain plugins. Absent is read as `Domain`. */
19
11
  @schema
20
12
  type pluginKind =
21
13
  | Domain
@@ -43,22 +35,13 @@ Included in the plugin's `pluginDefinition` for use by the host.
43
35
  type extensionDefinition = {
44
36
  name: string,
45
37
  extensionPointName: string,
46
- /**
47
- DCB EventLog source names this extension consumes. Convention: each entry is
48
- the `name` field of a peer plugin's `dcbEventLogDefinition` (i.e. `${peer}DcbEventLog`).
49
- The admin uses these to provision cross-plugin SNS subscriptions from peer DCB
50
- EventTopics → this plugin's EventCollector. `[]` for extensions that only consume
51
- ExtensionPoint EventTopics.
52
- */
38
+ /** Peer `dcbEventLogDefinition.name`s this extension consumes (`${peer}DcbEventLog`),
39
+ which the admin turns into cross-plugin SNS subscriptions. */
53
40
  dcbSources: array<string>,
54
41
  }
55
42
 
56
- /**
57
- Describes a DCB EventLog exposed by a plugin.
58
- Included in the plugin's `pluginDefinition` so the admin can provision SNS
59
- subscriptions from this plugin's DCB EventTopic to any peer plugin whose
60
- extension references the DCB log by name.
61
- */
43
+ /** A DCB EventLog exposed by a plugin, so the admin can subscribe peer plugins
44
+ that reference it by name. */
62
45
  @schema
63
46
  type dcbEventLogDefinition = {
64
47
  /** Service name carried in event meta — convention: `${plugin.name}DcbEventLog`. */
@@ -67,15 +50,8 @@ type dcbEventLogDefinition = {
67
50
  eventTopicArn: string,
68
51
  }
69
52
 
70
- /**
71
- Protocol version declaration for a single extension point connection.
72
-
73
- Carried in the `ConnectPlugin` handshake so the host can validate schema
74
- compatibility before accepting the extension. Use `[]` when version
75
- negotiation is not needed.
76
- */
77
- // Protocol version declaration for a single extension point connection.
78
- // Carried in the ConnectPlugin handshake so the host can validate compatibility.
53
+ /** Protocol versions for one extension point connection, carried in the
54
+ `ConnectPlugin` handshake. `[]` when negotiation is not needed. */
79
55
  @schema
80
56
  type extensionProtocol = {
81
57
  extensionPointName: string,
@@ -92,22 +68,15 @@ Encoded as JSON for transport; protocol identifies the schema format (e.g. "grap
92
68
  @schema
93
69
  type apiSchemaFragment = {encoded: string, protocol: string}
94
70
 
95
- /**
96
- The API a plugin's GraphQL fields are stitched into: the Domain API (the default —
97
- application plugins) or the Platform API (platform-level plugins such as an inspector,
98
- which contribute fields alongside the admin base). Serializes as the bare string
99
- "Domain" / "Platform". Consumed by the schema-fragment registry to maintain one
100
- cumulative schema per API.
101
- */
71
+ /** Which API a plugin's GraphQL fields are stitched into. Serializes as the bare
72
+ string "Domain" / "Platform". */
102
73
  @schema
103
74
  type apiTarget = Domain | Platform
104
75
 
105
- // Sury's nullableAsOption creates T | undefined | null which fails jsonableValidation
106
- // inside union variant payloads. js_nullable creates T | null (no undefined) which is
107
- // JSON-safe and passes jsonableValidation in all contexts.
76
+ // js_nullable (T | null) is the only optional that passes jsonableValidation inside
77
+ // union variant payloads; nullableAsOption adds `undefined` and fails it.
108
78
  let apiSchemaFragmentOffloadSchema = Offload.optionSchema(~store="pluginApiFragments", apiSchemaFragmentSchema)
109
79
  let dcbEventLogOptionSchema = dcbEventLogDefinitionSchema->S.nullAsOption
110
- // js_nullable creates T | null which passes sury's jsonableValidation inside union variant payloads.
111
80
  let stringOptionSchema = S.string->S.nullAsOption
112
81
  let stringArrayOptionSchema = S.array(S.string)->S.nullAsOption
113
82
  let boolOptionSchema = S.bool->S.nullAsOption
@@ -168,60 +137,19 @@ type commandDef = {
168
137
  aggregateIdField: @s.matches(stringOptionSchema) option<string>,
169
138
  mutationField: string,
170
139
  references: array<fieldReference>,
171
- /**
172
- Lifecycle states under which this command is meaningful. `None` means the command
173
- is always available (back-compat default). `Some([…])` lets AutoUI hide the
174
- command on rows whose lifecycle field is not in the set — see
175
- `queryableDef.lifecycleField` for how the row's state is located. `Some([])` is
176
- the defensive "never show" form.
177
- */
140
+ /** The `@transition` *from* set — lifecycle states this command is meaningful in.
141
+ `None` means always available; `Some([])` means never show. */
178
142
  allowedStates: @s.matches(stringArrayOptionSchema) option<array<string>>,
179
- /**
180
- The single lifecycle state this command's handler writes — the command's *to*
181
- state, sibling of `allowedStates`' *from* set. Source: the
182
- target of the `@transition(([Placed]) => Shipped)` command-variant annotation.
183
- `Some("Shipped")` lets a resolver move a row by a declared transition instead
184
- of a guess. `None` means no target was declared — and note that this is two
185
- different statements depending on `allowedStates`: with a from-set present it
186
- is the command declaring it does not move the row, and with none it is simply
187
- an unannotated command. js_nullable for JSON safety, same as `allowedStates`.
188
- */
143
+ /** The `@transition` *to* state this command's handler writes. `None` with a
144
+ from-set present means the command does not move the row. */
189
145
  targetState: @s.matches(stringOptionSchema) option<string>,
190
- /**
191
- Whether this command variant is exposed in the generated API (a non-`@noApi`
192
- variant of a non-`@noApi` command). Dev tooling badges API-exposed commands in
193
- the event graph. js_nullable (T | null) so it stays JSON-safe inside the
194
- persisted/lifecycle payloads; absent on defs written before this field existed
195
- (read as None) — those stores must be reset. See [[sury-optional-field-absent-vs-null]].
196
- */
146
+ /** Whether the variant is exposed in the generated API (non-`@noApi`). */
197
147
  apiExposed: @s.matches(boolOptionSchema) option<bool>,
198
- /**
199
- Access keys a caller must hold — any one of them — to be *offered* this command,
200
- derived from the authorization rule the server already enforces. `None` (or `[]`)
201
- means the rule asks for nothing a client can check.
202
-
203
- A hint, never a boundary: the rule in the resolver is what refuses a call, and a
204
- caller who edits this list gains nothing. It exists so a client stops advertising
205
- what the server would refuse — an offered command that always fails is a worse
206
- answer than no command at all. Derived rather than authored, so it cannot drift
207
- from the rule it describes. js_nullable, so defs written before this field
208
- existed decode as None.
209
- */
148
+ /** Access keys — any one of them — a caller needs to be *offered* this command.
149
+ A hint derived from the server's rule, never the refusal itself. */
210
150
  requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
211
- /**
212
- Name of the command field the server stamps with the caller's own identity, when
213
- the command declares one (`@owner`). A client should omit it from a generated
214
- form for a caller who is not elevated: whatever it collects there is discarded
215
- and replaced, so offering the field asks a question whose answer is ignored.
216
-
217
- Derived from the annotation, never authored, so it cannot disagree with what the
218
- write path actually does. js_nullable for the same JSON-safety reason as
219
- `requiredAccess`.
220
-
221
- ⚠️ A client cannot decide the *elevated* half from this alone — the manifest
222
- states which field carries the owner, and the caller's own identity says whether
223
- they are exempt. Both are needed, and neither is derivable from the other.
224
- */
151
+ /** The `@owner` command field the server stamps with the caller's identity; a
152
+ client omits it from a form, since whatever it collects is discarded. */
225
153
  ownerField: @s.matches(stringOptionSchema) option<string>,
226
154
  }
227
155
 
@@ -232,209 +160,53 @@ type queryableDef = {
232
160
  schema: string,
233
161
  consumedEventTypes: array<string>,
234
162
  linkedWriteSide: array<string>,
235
- /**
236
- The field on this entity that carries the human-readable label.
237
- When the entity's state schema declares one or more `@displayName` annotations,
238
- this resolves to `"displayName"` (the projected column). Otherwise it falls back
239
- to the first non-`id` string property, or `"id"` as a last resort.
240
- */
163
+ /** The field carrying the human-readable label: `"displayName"` when the state
164
+ declares `@displayName`, else the first non-`id` string, else `"id"`. */
241
165
  labelField: string,
242
- /**
243
- Fields appropriate for label-oriented text search.
244
- Mirrors `labelField` when the entity uses the fallback or single-field label.
245
- For composite `@displayName` annotations, lists the *raw* underlying source
246
- fields (so clients with substring indexes can target them directly).
247
- */
166
+ /** Fields for label-oriented text search — the *raw* source fields behind a
167
+ composite `@displayName`, else `labelField`. */
248
168
  searchableFields: array<string>,
249
- /**
250
- Which rung of the `labelField` ladder produced it, so a consumer with a name
251
- rule of its own can tell a declaration from a guess before ranking the two:
252
-
253
- - `"annotation"` — a `@displayName` spec. The author said which field names the
254
- record; nothing a client infers locally outranks it.
255
- - `"convention"` — a field literally named `name`/`title`/`label`/`displayName`.
256
- A guess, and the one guess a client can independently arrive at.
257
- - `"position"` — the first candidate in declaration order. A guess, and a fact
258
- only this side knows; a client's own conventional-name rule is the better
259
- answer where the two differ.
260
- - `"fallback"` — no candidate at all, so `labelField` is `"id"`. The state
261
- saying it has no human-readable field.
262
-
263
- `None` means not stated — defs persisted before this field existed, and
264
- hand-rolled defs that decline to say. Distinct from `Some("fallback")`, which
265
- is this side stating that it looked. js_nullable for the same JSON-safety
266
- reason as `lifecycleField`.
267
- */
169
+ /** Which rung produced `labelField`, so a consumer can rank it against its own
170
+ rule: `"annotation"` | `"convention"` | `"position"` | `"fallback"`. */
268
171
  labelFieldSource: @s.matches(stringOptionSchema) option<string>,
269
- /**
270
- Name of the state field whose value identifies the row's lifecycle, used by
271
- AutoUI together with `commandDef.allowedStates` to filter the per-row command
272
- menu. Resolution order (codegen): (1) field annotated `@lifecycle`; (2) a field
273
- literally named `"lifecycle"` whose shape is an enum; (3) `None`. Spec authors
274
- that hand-roll a `queryableDef` set this explicitly.
275
- */
172
+ /** The state field holding the row's lifecycle, paired with
173
+ `commandDef.allowedStates`. From `@lifecycle`, else an enum named `lifecycle`. */
276
174
  lifecycleField: @s.matches(stringOptionSchema) option<string>,
277
- /**
278
- Name of the state field that ties a row to the principal owning it (`@owner`),
279
- when the view declares one. Two consequences for a client: reads of this view
280
- are narrowed server-side to a non-elevated caller's own rows, and the column is
281
- constant for such a caller and so carries no information in a list.
282
-
283
- Derived from the annotation. As on `commandDef.ownerField`, this states which
284
- field carries the owner and not whether the current caller is exempt — that is
285
- the caller's own identity to answer.
286
- */
175
+ /** The `@owner` state field. Reads of this view are narrowed server-side to a
176
+ non-elevated caller's own rows. */
287
177
  ownerField: @s.matches(stringOptionSchema) option<string>,
288
- /**
289
- Name of the boolean state field that withdraws a row from ordinary reads
290
- (`@retired`), when the view declares one. Reads of this view exclude rows whose
291
- flag is true for callers outside `OwnerScope.elevatedGroups`, on the list door
292
- and the single-entity door alike; an exempt caller reaches them by asking for
293
- them.
294
-
295
- Derived from the annotation and from nothing else — deliberately no fallback to
296
- a conventionally-named boolean, unlike `lifecycleField`. A field named `archived`
297
- that nobody annotated must not start hiding rows the day this ships, and the
298
- cost of guessing wrong here is data disappearing rather than a menu filtering
299
- oddly.
300
-
301
- The label the flag reads as is not here. It travels on the state schema, which
302
- every consumer of this def already holds, and a second copy is a second thing
303
- to keep in step.
304
- */
178
+ /** The `@retired` state field withdrawing a row from ordinary reads. From the
179
+ annotation only — no fallback by name, since guessing hides data. */
305
180
  retiredField: @s.matches(stringOptionSchema) option<string>,
306
- /**
307
- The states a row is retired *in*, when the view declares the state form of
308
- `@retired` — `Some(["Archived", "Discontinued"])` beside
309
- `retiredField: Some("shelfStatus")`. `None` is the boolean form, where the
310
- excluded value is always `true` and naming it would be a field that can only
311
- hold one thing.
312
-
313
- A set: a lifecycle may be withdrawn by more than one state, which exclude
314
- identically and differ only in the way back. Retired iff the field's value is in
315
- it, and one member is the ordinary case rather than a special one.
316
-
317
- Published beside the field rather than left for a consumer to re-derive from the
318
- state schema: a client holding this def holds the whole predicate, and two places
319
- deriving one comparison is how they come to disagree about it.
320
-
321
- The state form is also what lets a command's `@transition` answer applicability
322
- when retired, with no annotation beyond the one — retirement expressed in the
323
- vocabulary a command's stance is already written in.
324
- */
181
+ /** The states a row is retired *in* (state form of `@retired`); `None` is the
182
+ boolean form, where the excluded value is always `true`. */
325
183
  retiredValues: @s.matches(stringArrayOptionSchema) option<array<string>>,
326
- /**
327
- Whether this view publishes a **reference door**: the by-ids read that names a
328
- retired row for any caller holding a pointer to it, projected to the row's id,
329
- its `labelField` and the value of `retiredField`. Declared with
330
- `@namedWhenRetired` on the state record.
331
-
332
- Published so a client knows the door exists without probing for it — a query
333
- against a field the schema does not have is a validation error, not an empty
334
- answer, so "ask and see" is not a usable fallback here.
335
-
336
- Nullable rather than a bare required bool, which is the rule this schema's own
337
- tripwire enforces: a definition stored before the field existed would otherwise
338
- decode with an invented value and a runtime warning. Absent and `false` mean the
339
- same thing to every reader — the archive stays shut — but only one of them is
340
- something the platform actually said.
341
-
342
- It is never `true` without `retiredField`: the PPX refuses the annotation on a
343
- record with no retirement.
344
- */
184
+ /** Whether the view publishes the by-ids reference door that names a retired row
185
+ to any caller holding a pointer (`@namedWhenRetired`). Never true without
186
+ `retiredField`. */
345
187
  namedWhenRetired: @s.matches(boolOptionSchema) option<bool>,
346
- /**
347
- Component visibility hint (`@@reventless.visibility`). `Some("Internal")` marks a
348
- ReadModel / StateViewSlice that the deployed AutoUI hides from its menu, drill-down
349
- pages, web event graph and cross-plugin edges. `None` (absent) means Public. Internal
350
- components are still CARRIED in `pluginStructure` (tagged here) so developer tools — the
351
- `reventless-gwt` / VSCode domain graph and dead-code analysis — can see them, per
352
- Visibility.res. Optional for back-compat: definitions persisted before this field
353
- existed decode as `None` (Public).
354
- */
188
+ /** `@@reventless.visibility`. `Some("Internal")` hides the component from AutoUI;
189
+ it is still carried here for developer tooling. `None` means Public. */
355
190
  visibility: @s.matches(stringOptionSchema) option<string>,
356
- /**
357
- Intra-plugin grouping band (the "chapter") this component belongs to, captured at
358
- build time from its source folder by the plugin generator: the first path segment
359
- under the plugin's `src/` that is not a recognised kind-folder
360
- (`src/<Chapter>/…/<Component>.res` → `Some("<Chapter>")`; a component directly under a
361
- kind-folder → `None`). Lets a consumer that renders the event graph from the
362
- *deployed* plugin structure group components into chapter sub-containers identically
363
- to the authoring tooling, with no workspace/disk access — the renderer already
364
- supports the bands (`DomainGraphD2 ~chapters`); only this datum was missing on the
365
- deployed side. `None` (absent) renders flat. js_nullable (T | null) keeps it JSON-safe
366
- inside the lifecycle Message union; always written (None → null), so defs persisted
367
- before this field existed must be reset/re-emitted. See [[deployed-chapter-grouping]].
368
- */
191
+ /** Intra-plugin grouping band, the first non-kind path segment under `src/`.
192
+ `None` renders flat. */
369
193
  chapter: @s.matches(stringOptionSchema) option<string>,
370
- /**
371
- The singular counterpart of `queryField`: the generated single-entity query
372
- (`Plugin_Order(id: ID!)` beside the list field `Plugin_Orders`), and — because
373
- `Api_Naming` returns the same string for both — the prefix of the queryable's
374
- generated input types (`Plugin_OrderFilter`, `Plugin_OrderOrderBy`). One field
375
- rather than two, so the two uses cannot drift apart.
376
-
377
- Published because it is not derivable from `queryField` without re-implementing
378
- `Api_Naming.singularize`: a consumer that strips a trailing `s` turns
379
- `Plugin_Categories` into `Plugin_Categorie`, a name the schema does not serve,
380
- and fails at query time against that one view. Sourced from the naming module
381
- itself, never re-derived.
382
-
383
- `None` means not stated — defs persisted before this field existed, and
384
- hand-rolled defs that decline to say; a consumer falls back to its own
385
- derivation there. js_nullable for the same JSON-safety reason as `lifecycleField`.
386
- */
194
+ /** The singular counterpart of `queryField` (`Plugin_Order`), also the prefix of
195
+ the generated input types. Not derivable without `Api_Naming.singularize`. */
387
196
  singleQueryField: @s.matches(stringOptionSchema) option<string>,
388
- /**
389
- The state field that identifies a row — the queryable's own key, as opposed to
390
- a reference to some other entity. `Products` carries `productId` and
391
- `categoryId`; this says which of the two the row is about.
392
-
393
- `None` means unresolved: a state with several `*Id` fields and no name match,
394
- or with none at all. Such a component gets no key-derived filter or sort until
395
- its spec declares `@id`. Also `None` on defs persisted before this field
396
- existed. js_nullable for the same JSON-safety reason as `lifecycleField`.
397
- */
197
+ /** The state field identifying a row, as opposed to a reference to another entity.
198
+ `None` means unresolved — no key-derived filter or sort until `@id` is declared. */
398
199
  idField: @s.matches(stringOptionSchema) option<string>,
399
- /**
400
- Which rung produced `idField`, so a consumer can tell a declaration from a
401
- guess — the same reason `labelFieldSource` exists:
402
-
403
- - `"annotation"` — the state declares `@id`. The author said which field keys
404
- the row; nothing inferred outranks it.
405
- - `"convention"` — a field named `<singular component name>Id` exists
406
- (`Products` → `productId`). A guess, and the one guess a client can make for
407
- itself.
408
- - `"sole"` — the state has exactly one `*Id` field, so there is nothing else
409
- the key could be (`AvailableProducts` → `productId`). A guess, and one that
410
- needs the state's full field list to make.
411
-
412
- `None` whenever `idField` is `None`, and on defs that predate the field.
413
- */
200
+ /** Which rung produced `idField`, as `labelFieldSource` does: `"annotation"` |
201
+ `"convention"` | `"sole"`. */
414
202
  idFieldSource: @s.matches(stringOptionSchema) option<string>,
415
- /**
416
- Access keys a caller must hold — any one of them — to be *offered* this view,
417
- derived from the component's module-level authorization rule. Same terms as
418
- `commandDef.requiredAccess`: a hint that keeps a client from advertising a
419
- surface the server would refuse, never the refusal itself.
420
-
421
- Worth stating for reads in particular: a denied query does not error, it comes
422
- back empty, so a client that offers a view it may not read renders a confident
423
- blank table rather than a visible failure.
424
- */
203
+ /** Access keys — any one of them — a caller needs to be *offered* this view. A
204
+ denied read comes back empty rather than erroring, hence the hint. */
425
205
  requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
426
206
  }
427
207
 
428
- /**
429
- One emitted event of a write side, with its field schema. Mirrors `commandDef`
430
- but for the past-tense facts a write side produces: `name` is the event variant
431
- name (e.g. `OrderPlaced`), `schema` is the JSON Schema of that variant's payload
432
- (same serialization as `commandDef.schema`: `SuryToJsonSchema.deriveObjectSchema`,
433
- so field-level `x-reventless-*` extensions are carried and the variant's `TAG`
434
- discriminator is not — the constructor name is already `name`), `references` its
435
- cross-entity field links.
436
- Carried so developer tools (the `reventless-dev` / VSCode domain graph) can show
437
- event field rows — AutoUI ignores it. */
208
+ /** One emitted event of a write side. `name` is the variant name, `schema` its
209
+ payload's JSON Schema (no `TAG` — the constructor name is already `name`). */
438
210
  @schema
439
211
  type eventDef = {
440
212
  name: string,
@@ -442,17 +214,8 @@ type eventDef = {
442
214
  references: array<fieldReference>,
443
215
  }
444
216
 
445
- /**
446
- One declared error of a write side, with its field schema. Same shape and same
447
- derivation as `eventDef` — a refusal is a variant of `Spec.errorSchema` exactly as
448
- an emitted fact is a variant of `Spec.eventSchema`, so a consumer reading an
449
- error's payload walks it with the code path it already uses for an event. `name`
450
- is the variant name (e.g. `CategoryNotFound`) — the same string the runtime puts
451
- on `errorCode` when a decision is rejected (see `CommandTopic_Helpers`), so a
452
- caller can match what it reads here against what it receives. Payload-less
453
- variants (the common case for errors) carry an empty `schema` object and no
454
- references.
455
- */
217
+ /** One declared error of a write side, derived exactly as `eventDef`. `name` is the
218
+ string the runtime puts on `errorCode`; payload-less variants carry `{}`. */
456
219
  @schema
457
220
  type errorDef = {
458
221
  name: string,
@@ -468,23 +231,11 @@ type writableDef = {
468
231
  consumedEventTypes: array<string>,
469
232
  linkedViews: array<string>,
470
233
  consistencyRead: @s.matches(stringOptionSchema) option<string>,
471
- /** Emitted-event field schemas. Required like the other write-side arrays; `[]`
472
- when there are none. */
234
+ /** Emitted-event field schemas; `[]` when there are none. */
473
235
  events: array<eventDef>,
474
- /** Declared-error field schemas — what this component can refuse a command with.
475
- Required, on the same reasoning as `events`: the persisted copy is never decoded
476
- through this schema (the event log carries it as an offload reference, and the
477
- serving path reads it as raw JSON), so `[]` honestly means "declares no errors"
478
- rather than "an older deploy could not say".
479
-
480
- Being required does NOT make it safe to add such a field without a read-path
481
- shim. A structure is re-derived on every build, but it is only RE-REGISTERED
482
- when a plugin re-runs the connect handshake — which a plugin whose version never
483
- changes may not do for a long time. Until then the serving path reads a
484
- persisted structure that has no key for the new field, and against a `[T!]!` SDL
485
- field that null propagates to the root and answers the whole query with `data:
486
- null`. Both admin resolvers therefore heal absent required lists to `[]` on
487
- read; a new one has to be added to that list too. */
236
+ /** Declared-error field schemas. Required, but a persisted structure predating a
237
+ required list still lacks the key — both admin resolvers heal absent ones to
238
+ `[]` on read, and a new one must be added there too. */
488
239
  errors: array<errorDef>,
489
240
  /** Chapter grouping band — see `queryableDef.chapter`. */
490
241
  chapter: @s.matches(stringOptionSchema) option<string>,
@@ -523,37 +274,72 @@ type inboundTranslationSliceDef = {
523
274
  chapter: @s.matches(stringOptionSchema) option<string>,
524
275
  }
525
276
 
277
+ /** A published event of an extension point and the internal events producing it.
278
+ `name` is EP-qualified, `fromEventTypes` plugin-qualified; `[]` means published
279
+ from a path the declaration cannot name. */
280
+ @schema
281
+ type publishedEventDef = {
282
+ name: string,
283
+ fromEventTypes: array<string>,
284
+ }
285
+
286
+ let publishedEventDefArrayOptionSchema = S.array(publishedEventDefSchema)->S.nullAsOption
287
+
288
+ /** The command direction's producer half: a command an extension point takes and
289
+ the delegate commands it routes to. `name` is EP-qualified, `toCommandTypes`
290
+ plugin-qualified. */
291
+ @schema
292
+ type acceptedCommandDef = {
293
+ name: string,
294
+ toCommandTypes: array<string>,
295
+ }
296
+
297
+ let acceptedCommandDefArrayOptionSchema = S.array(acceptedCommandDefSchema)->S.nullAsOption
298
+
299
+ /** The subscriber's half: a published event and the commands it routes to. A
300
+ delegate command is plugin-qualified, one sent back to the EP is EP-qualified. */
301
+ @schema
302
+ type handledEventDef = {
303
+ name: string,
304
+ toCommandTypes: array<string>,
305
+ }
306
+
307
+ let handledEventDefArrayOptionSchema = S.array(handledEventDefSchema)->S.nullAsOption
308
+
309
+ /** The command direction's subscriber half: a command sent back to the port and
310
+ the internal events producing it. `name` is EP-qualified, `fromEventTypes`
311
+ plugin-qualified. */
312
+ @schema
313
+ type issuedCommandDef = {
314
+ name: string,
315
+ fromEventTypes: array<string>,
316
+ }
317
+
318
+ let issuedCommandDefArrayOptionSchema = S.array(issuedCommandDefSchema)->S.nullAsOption
319
+
526
320
  @schema
527
321
  type extensionDef = {
528
322
  name: string,
529
323
  delegateNames: array<string>,
530
324
  eventTypes: array<string>,
531
325
  commandTypes: array<string>,
326
+ /** Which published event routes to which commands. js_nullable like
327
+ `extensionPointDef.commandTypes`; re-emit definitions persisted before it. */
328
+ handledEvents: @s.matches(handledEventDefArrayOptionSchema) option<array<handledEventDef>>,
329
+ /** Which internal event sends which command back to the port. `None` means a
330
+ definition persisted before the field, NOT an extension that issues nothing —
331
+ a reader joining the two halves must keep them apart. */
332
+ issuedCommands: @s.matches(issuedCommandDefArrayOptionSchema) option<array<issuedCommandDef>>,
532
333
  }
533
334
 
534
335
  /**
535
- Describes an extension point owned by a plugin, from the *producer* side.
536
-
537
- `sourceEventTypes` are the owner-plugin internal events (the `Delegate`'s events)
538
- that feed this extension point's published protocol — plugin-qualified to match
539
- `writableDef.producedEventTypes`, so the event graph can link a producing
540
- write-side to the extension point it ultimately feeds. `delegateNames` are the
541
- connected targets (one per `ExtensionPointMapping`).
542
-
543
- `commandTypes` are the EP's *inbound* command protocol (the variants of its
544
- `command` type). It is empty (None, read as []) when the EP declares
545
- `command = unit` — a notification-only, events-out boundary that accepts nothing
546
- inward. The event graph uses this to decide whether the EP routes any command (an
547
- empty list means no `routesTo` edge: there is nothing for the EP to route).
548
-
549
- Optional (None for a `command = unit` EP). Uses the `js_nullable` pattern (T | null).
550
- A sury field cannot be BOTH absent-tolerant on decode AND JSON-encodable (proven:
551
- S.option = `T|undefined`, nullableAsOption = `T|undefined|null` both decode an absent
552
- key but fail jsonableValidation; js_nullable = `T|null` is the only JSON-safe form but
553
- rejects an absent key). This def is nested in the JSON-encoded lifecycle Message union
554
- (Connect/Heartbeat), so jsonability wins → js_nullable. It always writes the field
555
- (None → null), so it is present-required on decode; a plugin definition persisted
556
- before this field existed must be reset/re-emitted. Read with `->Option.getOr([])`.
336
+ An extension point owned by a plugin, from the producer side.
337
+
338
+ `sourceEventTypes` are the `Delegate`'s events feeding the published protocol,
339
+ plugin-qualified to match `writableDef.producedEventTypes`. `commandTypes` is the
340
+ EP's inbound protocol — None (read as []) for a `command = unit` EP, which routes
341
+ nothing. js_nullable is the only JSON-safe optional here (this def is nested in the
342
+ lifecycle Message union); definitions persisted before a field must be re-emitted.
557
343
  */
558
344
  @schema
559
345
  type extensionPointDef = {
@@ -561,6 +347,13 @@ type extensionPointDef = {
561
347
  delegateNames: array<string>,
562
348
  sourceEventTypes: array<string>,
563
349
  commandTypes: @s.matches(stringArrayOptionSchema) option<array<string>>,
350
+ /** Which internal event becomes which published event. */
351
+ publishedEvents: @s.matches(publishedEventDefArrayOptionSchema)
352
+ option<array<publishedEventDef>>,
353
+ /** Which arriving command becomes which delegate command. `None` means a
354
+ definition persisted before the field, NOT a port that accepts nothing. */
355
+ acceptedCommands: @s.matches(acceptedCommandDefArrayOptionSchema)
356
+ option<array<acceptedCommandDef>>,
564
357
  }
565
358
 
566
359
  // js_nullable creates `array | null` (not `| undefined`), which passes sury's
@@ -570,30 +363,11 @@ let extensionPointDefArrayOptionSchema = S.array(extensionPointDefSchema)->S.nul
570
363
  /**
571
364
  One field's store requirement, with its provenance.
572
365
 
573
- `store` is the same qualified `{plugin}.{store}` string `requiredStores`
574
- carries; `component` and `field` name the declaration site. The site matters
575
- because a requirement only ever changes by editing a field — when a rename
576
- removes a store from the manifest, the diff has to say which field caused it.
577
-
578
- `annotation` is the store exactly as the field spells it — bare for a store
579
- the declaring plugin owns, qualified for a foreign one. It is recorded rather
580
- than reconstructed: only here is the owning plugin unambiguous, so anything
581
- downstream would have to infer it by comparing a registered plugin name with
582
- whatever name a deploy manifest happened to use, and those were never required
583
- to match.
584
-
585
- Optional for the same reason `CapabilityManifest.provenance` and
586
- `PlatformCodegen.provenance` — the two places this value travels onward to —
587
- already declare it optional: an event stored before the field existed cannot
588
- say what the source said, and a reader that cannot say omits the claim rather
589
- than inventing one. Every definition emitted now carries it.
590
-
591
- That is not a stylistic preference. It was first added here as a required
592
- `string` while events written without it were already stored, and since the
593
- lifecycle aggregate replays its own log before every decision, those events
594
- stopped decoding and the plugin's registration froze for two days. `None` is
595
- also the honest value: `""` would assert the author wrote an empty annotation.
596
- See the schema-evolution note on `pluginStructure` below.
366
+ `store` is the qualified `{plugin}.{store}` string `requiredStores` carries;
367
+ `component` and `field` name the declaration site, so a diff can say which field
368
+ added or removed a store. `annotation` is the store as the field spells it —
369
+ recorded, not reconstructed, since only here is the owning plugin unambiguous.
370
+ Optional because an event stored before it cannot say (`""` would claim it did).
597
371
  */
598
372
  @schema
599
373
  type requiredStoreDeclaration = {
@@ -607,30 +381,12 @@ let requiredStoreDeclarationArrayOptionSchema =
607
381
  S.array(requiredStoreDeclarationSchema)->S.nullAsOption
608
382
 
609
383
  /**
610
- Adding a field here? It has to be a shape a stale event can be healed into.
611
-
612
- Everything reachable from `pluginDefinition` is persisted in the Plugin lifecycle
613
- aggregate's event log, and that aggregate replays its own log before every
614
- decision. Events already written do not have your new field, so if decoding one
615
- of them throws, the aggregate cannot process ANY command for that plugin — it
616
- stops answering the deploy handshake and its registration silently freezes at
617
- whatever version connected last.
618
-
619
- `Message.parseJsonTolerant` heals a stale event on read, but only for shapes it
620
- can supply a value for: a `T | null` union (→ `None`), an array (→ `[]`), a
621
- mandatory enum (→ first variant), a nested object (→ recursively filled), and a
622
- scalar (→ `""` / `0` / `false`, logged as a warning because it is a fabricated
623
- value, not a derived one).
624
-
625
- So: **prefer `js_nullable` for anything genuinely optional**, and expect a scalar
626
- addition to show up as a warning in the logs of every deployment that still holds
627
- older events. A field that can be absent should say so in its type rather than
628
- lean on the healer.
629
-
630
- The regression suite for this is `PluginLifecycleCorpusTest` in reventless-core,
631
- which decodes frozen payloads captured off a deployed log. If it goes red naming
632
- your field, re-shape the field — do not re-cut the fixtures. Background:
633
- `docs/analysis/plugin-definition-schema-evolution-wedge.md`.
384
+ Adding a field here? It must be a shape a stale event can be healed into — the
385
+ lifecycle aggregate replays its own log before every decision, so one event that
386
+ fails to decode freezes that plugin's registration. `Message.parseJsonTolerant`
387
+ heals `T | null`, arrays, enums and nested objects; a bare scalar is fabricated
388
+ and warned about. Prefer `js_nullable`. Regression suite:
389
+ `PluginLifecycleCorpusTest` — if it goes red, re-shape the field, not the fixtures.
634
390
  */
635
391
  @schema
636
392
  type pluginStructure = {
@@ -642,32 +398,15 @@ type pluginStructure = {
642
398
  outboundTranslationSlices: array<outboundTranslationSliceDef>,
643
399
  inboundTranslationSlices: array<inboundTranslationSliceDef>,
644
400
  extensions: array<extensionDef>,
645
- // Extension points owned by this plugin (producer side). Optional so plugin
646
- // definitions persisted before this field existed still decode (absent → None,
647
- // read as []). js_nullable keeps it JSON-safe inside union variant payloads.
401
+ // Extension points owned by this plugin (producer side). Optional so older
402
+ // definitions still decode (absent → None, read as []).
648
403
  extensionPoints: @s.matches(extensionPointDefArrayOptionSchema)
649
404
  option<array<extensionPointDef>>,
650
- /**
651
- The object stores this plugin's fields declare they need, deduplicated and
652
- fully qualified as `{plugin}.{store}`.
653
-
654
- A field typed as a storage ref states a *requirement*: the deployment needs
655
- that store to exist. Collecting the requirement here is what lets it be read
656
- without re-walking every component's schema — the same reason
657
- `producedEventTypes` is carried rather than recomputed.
658
-
659
- Qualified even for the common same-plugin case, so one entry has one shape
660
- and the string is directly the store's identity. Optional and js_nullable for
661
- the same reason as `extensionPoints`: definitions persisted before this field
662
- existed still decode (absent → None, read as []).
663
- */
405
+ /** The object stores this plugin's fields declare they need, deduplicated and
406
+ qualified as `{plugin}.{store}` even for the same-plugin case. */
664
407
  requiredStores: @s.matches(stringArrayOptionSchema) option<array<string>>,
665
- /**
666
- Provenance for `requiredStores`: one entry per declaring `(component, field)`
667
- site, with `store` matching the qualified key above. `requiredStores` is
668
- derived from this list, so the two cannot disagree. Optional and js_nullable
669
- for the same reason as `extensionPoints`.
670
- */
408
+ /** Provenance for `requiredStores`: one entry per declaring `(component, field)`.
409
+ `requiredStores` is derived from it, so the two cannot disagree. */
671
410
  requiredStoreDeclarations: @s.matches(requiredStoreDeclarationArrayOptionSchema)
672
411
  option<array<requiredStoreDeclaration>>,
673
412
  }
@@ -689,34 +428,22 @@ type pluginDefinition = {
689
428
  extensionPoints: array<extensionPointDefinition>,
690
429
  extensions: array<extensionDefinition>,
691
430
  mutable eventCollector: string,
692
- // Protocol version declarations for each extension point this plugin connects to.
693
- // Use [] when the plugin does not need version negotiation.
431
+ // [] when the plugin does not need version negotiation.
694
432
  extensionProtocols: array<extensionProtocol>,
695
- // GraphQL schema fragment contributed by this plugin (optional, set at build time).
696
433
  // Offloadable: a large SDL fragment is content-addressed to the pluginApiFragments
697
- // store by the client and carried by reference; a small one stays Inline. optionSchema
698
- // wraps the untagged codec in js_nullable (T | null, not T | undefined | null) so it
699
- // passes jsonableValidation inside the lifecycle Message union, and marks the store.
434
+ // store and carried by reference; a small one stays Inline.
700
435
  apiSchemaFragment: @s.matches(apiSchemaFragmentOffloadSchema) option<Offload.payload<apiSchemaFragment>>,
701
- // API target for schema routing in split-API mode.
702
- // None/"Domain" → fragment goes to the DomainApi (default).
703
- // Some("Platform") → fragment goes to the PlatformApi; excluded from DomainApi runtime schema.
704
- // Uses @s.matches(stringOptionSchema) — js_nullable creates string | null (not string | undefined),
705
- // which passes sury's jsonableValidation inside union variant payloads.
436
+ // Schema routing in split-API mode: None/"Domain" → DomainApi, Some("Platform") →
437
+ // PlatformApi (and excluded from the DomainApi runtime schema).
706
438
  apiTarget: @s.matches(stringOptionSchema) option<string>,
707
- // Component graph metadata — populated by makePluginDefinition; absent for older protocol versions.
708
- // Offloadable: the large structure is content-addressed to the pluginStructures store by
709
- // the client and carried by reference; a small one stays Inline (see apiSchemaFragment).
439
+ // Component graph metadata, offloadable like apiSchemaFragment. Absent for older
440
+ // protocol versions.
710
441
  structure: @s.matches(pluginStructureOffloadSchema) option<Offload.payload<pluginStructure>>,
711
- // DCB EventLog definition for plugins that bundle a DcbEventLog component.
712
- // Carries the EventTopic ARN so the admin can provision cross-plugin SNS
713
- // subscriptions from this plugin's DCB topic → peer EventCollectors.
714
- // None for plugins without a DCB EventLog.
442
+ // EventTopic ARN of a bundled DcbEventLog, so the admin can subscribe peer
443
+ // EventCollectors to it. None for plugins without one.
715
444
  dcbEventLog: @s.matches(dcbEventLogOptionSchema) option<dcbEventLogDefinition>,
716
- // Business role of this plugin. Mandatory: `Domain` is the default kind, resolved once
717
- // at the deploy-metadata → definition boundary (Plugin_Builder). PlatformInfrastructure
718
- // plugins are segregated out of the admin Plugins list. Payload-less variant → serialises
719
- // as a bare JSON string, so it is JSON-safe inside the lifecycle Message union without js_nullable.
445
+ // Mandatory; `Domain` is resolved as the default in Plugin_Builder. Payload-less
446
+ // variant → a bare JSON string, so JSON-safe without js_nullable.
720
447
  kind: pluginKind,
721
448
  }
722
449