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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,58 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.129 (2026-09-04)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **logging:** let a runtime declare whether a person reads its logs ([df7a6cb](https://github.com/ReventlessDev/reventless-core/commit/df7a6cbdff62b856ea6b9d1f169b913563ba13a1))
11
+ * feat(spec)!: one optional encoding on the wire, with no annotation ([320f91d](https://github.com/ReventlessDev/reventless-core/commit/320f91daa8bd90812a6e82069e7a1cb473041930))
12
+
13
+ ### BREAKING CHANGES
14
+
15
+ * the two encodings cannot read each other. Stored
16
+ pluginDefinition / pluginStructure payloads and already-deployed plugins must
17
+ go — wipe the platform scope (SEED_RESET_SCOPE=platform), quiesce, and
18
+ redeploy the fleet from one commit. Domain plugin data is untouched.
19
+
20
+ Two guards had to learn the new shape, both of which defined "optional" as
21
+ has.null and so mistook an omitted key for something to invent:
22
+
23
+ - Message.fillMissingDefaults reached `return undefined` only below two arms
24
+ that fire first — an enum's first const, and an object member filled with
25
+ zeros. Absent option<record> therefore healed to a zero-filled record, not
26
+ None: on the real schema, dcbEventLog became Some({name: "", eventTopicArn:
27
+ ""}), which manageSubscriptions would have read as a peer to subscribe to.
28
+ One line, mirroring the has.null guard, above both arms.
29
+ - PluginDefinitionScalars' walker reported 16 optional fields as newly-added
30
+ bare required scalars. With both encodings understood, the golden list is
31
+ unchanged.
32
+
33
+ The frozen lifecycle corpus holds five real payloads in the old encoding.
34
+ Null-valued keys were stripped mechanically — the rewrite round-trips each
35
+ file unchanged before editing, and a key-by-key diff shows null removals and
36
+ nothing else. The README records it beside the account-id redaction and says
37
+ why it is not a regeneration: no fixture was rebuilt from ReScript types, so
38
+ every generation in its table is still pinned.
39
+
40
+ check:graphql is unchanged, as expected: SchemaType.fromSury collapses Null
41
+ and Undefined to the same Nullable, so the emitted schema never distinguished
42
+ them.
43
+
44
+
45
+
46
+ # 3.0.0-alpha.128 (2026-09-04)
47
+
48
+ ### Features
49
+
50
+ * **notifications:** the wording is a table of values, not a switch ([bfcc939](https://github.com/ReventlessDev/reventless-core/commit/bfcc93940b81060d201fd231447fb14dcf48d80e))
51
+ * **plugin:** a slice publishes which topics it subscribes to ([c689695](https://github.com/ReventlessDev/reventless-core/commit/c6896957ecb636204678222ac5a26b30870439cb))
52
+ * **spec,traits:** an image carries the text that goes with it, and a set's first member is its primary ([e4e5845](https://github.com/ReventlessDev/reventless-core/commit/e4e58458aee7b3db5564727d358a3a9767362ca4))
53
+ * **spec:** a field can say it selects one of the values its row already holds ([2ae50c3](https://github.com/ReventlessDev/reventless-core/commit/2ae50c34deee48508a9f1f39e3eef6a5d2f5df00))
54
+ * **spec:** a field can say its value must not be rendered into a message ([3183f53](https://github.com/ReventlessDev/reventless-core/commit/3183f53a0ae667f794bbd1a3d77acf362dfa8e57))
55
+ * **spec:** a message template renders a payload through its own schema ([39e3f61](https://github.com/ReventlessDev/reventless-core/commit/39e3f6126bd831a316323b4663bc37c50cdfc704))
56
+
57
+
6
58
  # 3.0.0-alpha.127 (2026-09-02)
7
59
 
8
60
  * feat(aws)!: the messaging sender is configuration, and a stack can choose to only log ([23b8b4b](https://github.com/ReventlessDev/reventless-core/commit/23b8b4bfe9c70555de4d74266ca686cb427485ca))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.127",
3
+ "version": "3.0.0-alpha.129",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "sury": "11.0.0-rc.2",
25
25
  "sury-ppx": "11.0.0-rc.2",
26
26
  "yaml": "^2.8.3",
27
- "@reventlessdev/rescript-node": "2.0.0-alpha.8"
27
+ "@reventlessdev/rescript-node": "2.0.0-alpha.9"
28
28
  },
29
29
  "devDependencies": {
30
30
  "rescript": "12.3.0",
@@ -134,6 +134,7 @@ type Platform_IssuedCommandDef {
134
134
  type Platform_OutboundTranslationSliceDef {
135
135
  chapter: String
136
136
  consumedEventTypes: [String!]!
137
+ consumedSources: [String!]
137
138
  externalSystem: String
138
139
  inboundCommandTypes: [String!]!
139
140
  name: String!
package/src/AnsiStyle.res CHANGED
@@ -11,14 +11,23 @@
11
11
  @val external _isTty: option<bool> = "process.stdout.isTTY"
12
12
  @val external _logFormat: option<string> = "process.env.REVENTLESS_LOG_FORMAT"
13
13
 
14
- // "json" | "text". Explicit REVENTLESS_LOG_FORMAT override always wins; otherwise
15
- // a TTY stdout ⇒ human-readable text, everything else (Lambda, Fargate, ECS,
16
- // Azure, GCP, Docker, CI runners, piped stdout) ⇒ structured JSON.
14
+ // What this runtime is, for when stdout cannot say. A local dev platform's logs
15
+ // are read by a person even when its stdout is a pipe — `concurrently`, `tsx
16
+ // watch` and an IDE terminal all pipe — while a Lambda's are read by a collector
17
+ // through a pipe that looks identical. TTY-ness is only a proxy for "a person is
18
+ // reading this", and a pipe is exactly where the proxy fails, so a runtime that
19
+ // knows which it is says so instead of being guessed at.
20
+ let _default: ref<option<string>> = ref(None)
21
+
22
+ // "json" | "text", in precedence order: an explicit REVENTLESS_LOG_FORMAT, then
23
+ // what the runtime declared itself to be, then the TTY probe — a TTY stdout ⇒
24
+ // text, everything else (Lambda, Fargate, ECS, Docker, CI) ⇒ structured JSON.
17
25
  let _resolveFormat = (): string =>
18
- switch (_logFormat, _isTty) {
19
- | (Some("json"), _) => "json"
20
- | (Some("text"), _) => "text"
21
- | (_, Some(true)) => "text"
26
+ switch (_logFormat, _default.contents, _isTty) {
27
+ | (Some("json"), _, _) => "json"
28
+ | (Some("text"), _, _) => "text"
29
+ | (_, Some(declared), _) => declared
30
+ | (_, None, Some(true)) => "text"
22
31
  | _ => "json"
23
32
  }
24
33
 
@@ -27,6 +36,21 @@ let _resolveFormat = (): string =>
27
36
  // keeps every log call off `process.env`.
28
37
  let _format = ref(_resolveFormat())
29
38
 
39
+ /** Declares what this runtime is, for the pipe case the TTY probe gets wrong.
40
+ Call it at module init, before anything logs. An explicit
41
+ REVENTLESS_LOG_FORMAT still wins, so a developer can always ask for the other
42
+ one. A runtime that says nothing keeps the TTY behaviour. */
43
+ let setDefaultFormat = (format: [#text | #json]) => {
44
+ _default :=
45
+ Some(
46
+ switch format {
47
+ | #text => "text"
48
+ | #json => "json"
49
+ },
50
+ )
51
+ _format := _resolveFormat()
52
+ }
53
+
30
54
  /** Test-only: re-evaluate REVENTLESS_LOG_FORMAT / process.stdout.isTTY. */
31
55
  let reload = () => _format := _resolveFormat()
32
56
 
@@ -1,9 +1,14 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
 
4
+ let _default = {
5
+ contents: undefined
6
+ };
7
+
4
8
  function _resolveFormat() {
5
9
  let match = process.env.REVENTLESS_LOG_FORMAT;
6
- let match$1 = process.stdout.isTTY;
10
+ let match$1 = _default.contents;
11
+ let match$2 = process.stdout.isTTY;
7
12
  if (match !== undefined) {
8
13
  switch (match) {
9
14
  case "json" :
@@ -12,7 +17,9 @@ function _resolveFormat() {
12
17
  return "text";
13
18
  }
14
19
  }
15
- if (match$1 !== undefined && match$1) {
20
+ if (match$1 !== undefined) {
21
+ return match$1;
22
+ } else if (match$2 !== undefined && match$2) {
16
23
  return "text";
17
24
  } else {
18
25
  return "json";
@@ -23,6 +30,11 @@ let _format = {
23
30
  contents: _resolveFormat()
24
31
  };
25
32
 
33
+ function setDefaultFormat(format) {
34
+ _default.contents = format === "text" ? "text" : "json";
35
+ _format.contents = _resolveFormat();
36
+ }
37
+
26
38
  function reload() {
27
39
  _format.contents = _resolveFormat();
28
40
  }
@@ -44,8 +56,10 @@ function bold(s) {
44
56
  }
45
57
 
46
58
  export {
59
+ _default,
47
60
  _resolveFormat,
48
61
  _format,
62
+ setDefaultFormat,
49
63
  reload,
50
64
  isJsonSink,
51
65
  useAnsi,
@@ -73,13 +73,13 @@ type apiSchemaFragment = {encoded: string, protocol: string}
73
73
  @schema
74
74
  type apiTarget = Domain | Platform
75
75
 
76
- // js_nullable (T | null) is the only optional that passes jsonableValidation inside
77
- // union variant payloads; nullableAsOption adds `undefined` and fails it.
76
+ // Every optional below carries sury's default `option` encoding: the key is omitted,
77
+ // never written as `null`. That is the shape the rest of the repo already uses
78
+ // (`Message.meta`, `storedEvent.tags`), so there is one optional form on the wire and
79
+ // nothing to annotate. The `T | null` these fields used to carry worked around a sury
80
+ // bug — undefined failing jsonableValidation inside a union variant payload — fixed in
81
+ // 11.0.0-alpha.11. `Offload` below is not an optional wrapper but an either-or codec.
78
82
  let apiSchemaFragmentOffloadSchema = Offload.optionSchema(~store="pluginApiFragments", apiSchemaFragmentSchema)
79
- let dcbEventLogOptionSchema = dcbEventLogDefinitionSchema->S.nullAsOption
80
- let stringOptionSchema = S.string->S.nullAsOption
81
- let stringArrayOptionSchema = S.array(S.string)->S.nullAsOption
82
- let boolOptionSchema = S.bool->S.nullAsOption
83
83
 
84
84
  // ── UI fragment manifest types ────────────────────────────────────────────────
85
85
 
@@ -89,14 +89,14 @@ type panelManifestEntry = {
89
89
  title: string,
90
90
  description: string,
91
91
  positions: array<string>,
92
- requiredAccess: @s.matches(stringOptionSchema) option<string>,
92
+ requiredAccess: option<string>,
93
93
  }
94
94
 
95
95
  @schema
96
96
  type menuEntry = {
97
97
  label: string,
98
- icon: @s.matches(stringOptionSchema) option<string>,
99
- group: @s.matches(stringOptionSchema) option<string>,
98
+ icon: option<string>,
99
+ group: option<string>,
100
100
  sortOrder: int,
101
101
  }
102
102
 
@@ -105,7 +105,7 @@ type pageManifestEntry = {
105
105
  fragmentId: string,
106
106
  title: string,
107
107
  menuEntry: menuEntry,
108
- requiredAccess: @s.matches(stringOptionSchema) option<string>,
108
+ requiredAccess: option<string>,
109
109
  }
110
110
 
111
111
  @schema
@@ -115,7 +115,7 @@ type uiFragmentManifest = {
115
115
  pages: array<pageManifestEntry>,
116
116
  }
117
117
 
118
- let uiFragmentManifestOptionSchema = uiFragmentManifestSchema->S.nullAsOption
118
+ let uiFragmentManifestOptionSchema = S.option(uiFragmentManifestSchema)
119
119
 
120
120
  // ── Plugin structure types (component metadata for Auto UI and event graph) ──
121
121
 
@@ -126,7 +126,7 @@ type commandLevel = Collection | Instance
126
126
  type fieldReference = {
127
127
  fieldName: string,
128
128
  entity: string,
129
- plugin: @s.matches(stringOptionSchema) option<string>,
129
+ plugin: option<string>,
130
130
  }
131
131
 
132
132
  @schema
@@ -134,23 +134,23 @@ type commandDef = {
134
134
  name: string,
135
135
  schema: string,
136
136
  level: commandLevel,
137
- aggregateIdField: @s.matches(stringOptionSchema) option<string>,
137
+ aggregateIdField: option<string>,
138
138
  mutationField: string,
139
139
  references: array<fieldReference>,
140
140
  /** The declared *from* set — lifecycle states this command is meaningful in.
141
141
  `None` means always available; `Some([])` means never show. */
142
- allowedStates: @s.matches(stringArrayOptionSchema) option<array<string>>,
142
+ allowedStates: option<array<string>>,
143
143
  /** The declared *to* state this command's handler writes. `None` with a
144
144
  from-set present means the command does not move the row. */
145
- targetState: @s.matches(stringOptionSchema) option<string>,
145
+ targetState: option<string>,
146
146
  /** Whether the variant is exposed in the generated API (non-`@noApi`). */
147
- apiExposed: @s.matches(boolOptionSchema) option<bool>,
147
+ apiExposed: option<bool>,
148
148
  /** Access keys — any one of them — a caller needs to be *offered* this command.
149
149
  A hint derived from the server's rule, never the refusal itself. */
150
- requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
150
+ requiredAccess: option<array<string>>,
151
151
  /** The `@owner` command field the server stamps with the caller's identity; a
152
152
  client omits it from a form, since whatever it collects is discarded. */
153
- ownerField: @s.matches(stringOptionSchema) option<string>,
153
+ ownerField: option<string>,
154
154
  }
155
155
 
156
156
  @schema
@@ -168,41 +168,41 @@ type queryableDef = {
168
168
  searchableFields: array<string>,
169
169
  /** Which rung produced `labelField`, so a consumer can rank it against its own
170
170
  rule: `"annotation"` | `"convention"` | `"position"` | `"fallback"`. */
171
- labelFieldSource: @s.matches(stringOptionSchema) option<string>,
171
+ labelFieldSource: option<string>,
172
172
  /** The state field holding the row's lifecycle, paired with
173
173
  `commandDef.allowedStates`. From `@lifecycle`, else an enum named `lifecycle`. */
174
- lifecycleField: @s.matches(stringOptionSchema) option<string>,
174
+ lifecycleField: option<string>,
175
175
  /** The `@owner` state field. Reads of this view are narrowed server-side to a
176
176
  non-elevated caller's own rows. */
177
- ownerField: @s.matches(stringOptionSchema) option<string>,
177
+ ownerField: option<string>,
178
178
  /** The `@retired` state field withdrawing a row from ordinary reads. From the
179
179
  annotation only — no fallback by name, since guessing hides data. */
180
- retiredField: @s.matches(stringOptionSchema) option<string>,
180
+ retiredField: option<string>,
181
181
  /** The states a row is retired *in* (state form of `@retired`); `None` is the
182
182
  boolean form, where the excluded value is always `true`. */
183
- retiredValues: @s.matches(stringArrayOptionSchema) option<array<string>>,
183
+ retiredValues: option<array<string>>,
184
184
  /** Whether the view publishes the by-ids reference door that names a retired row
185
185
  to any caller holding a pointer (`@namedWhenRetired`). Never true without
186
186
  `retiredField`. */
187
- namedWhenRetired: @s.matches(boolOptionSchema) option<bool>,
187
+ namedWhenRetired: option<bool>,
188
188
  /** `@@reventless.visibility`. `Some("Internal")` hides the component from AutoUI;
189
189
  it is still carried here for developer tooling. `None` means Public. */
190
- visibility: @s.matches(stringOptionSchema) option<string>,
190
+ visibility: option<string>,
191
191
  /** Intra-plugin grouping band, the first non-kind path segment under `src/`.
192
192
  `None` renders flat. */
193
- chapter: @s.matches(stringOptionSchema) option<string>,
193
+ chapter: option<string>,
194
194
  /** The singular counterpart of `queryField` (`Plugin_Order`), also the prefix of
195
195
  the generated input types. Not derivable without `Api_Naming.singularize`. */
196
- singleQueryField: @s.matches(stringOptionSchema) option<string>,
196
+ singleQueryField: option<string>,
197
197
  /** The state field identifying a row, as opposed to a reference to another entity.
198
198
  `None` means unresolved — no key-derived filter or sort until `@id` is declared. */
199
- idField: @s.matches(stringOptionSchema) option<string>,
199
+ idField: option<string>,
200
200
  /** Which rung produced `idField`, as `labelFieldSource` does: `"annotation"` |
201
201
  `"convention"` | `"sole"`. */
202
- idFieldSource: @s.matches(stringOptionSchema) option<string>,
202
+ idFieldSource: option<string>,
203
203
  /** Access keys — any one of them — a caller needs to be *offered* this view. A
204
204
  denied read comes back empty rather than erroring, hence the hint. */
205
- requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
205
+ requiredAccess: option<array<string>>,
206
206
  }
207
207
 
208
208
  /** One emitted event of a write side. `name` is the variant name, `schema` its
@@ -230,7 +230,7 @@ type writableDef = {
230
230
  producedEventTypes: array<string>,
231
231
  consumedEventTypes: array<string>,
232
232
  linkedViews: array<string>,
233
- consistencyRead: @s.matches(stringOptionSchema) option<string>,
233
+ consistencyRead: option<string>,
234
234
  /** Emitted-event field schemas; `[]` when there are none. */
235
235
  events: array<eventDef>,
236
236
  /** Declared-error field schemas. Required, but a persisted structure predating a
@@ -238,7 +238,7 @@ type writableDef = {
238
238
  `[]` on read, and a new one must be added there too. */
239
239
  errors: array<errorDef>,
240
240
  /** Chapter grouping band — see `queryableDef.chapter`. */
241
- chapter: @s.matches(stringOptionSchema) option<string>,
241
+ chapter: option<string>,
242
242
  }
243
243
 
244
244
  @schema
@@ -248,7 +248,7 @@ type automationSliceDef = {
248
248
  producedCommandTypes: array<string>,
249
249
  targetName: string,
250
250
  /** Chapter grouping band — see `queryableDef.chapter`. */
251
- chapter: @s.matches(stringOptionSchema) option<string>,
251
+ chapter: option<string>,
252
252
  }
253
253
 
254
254
  @schema
@@ -256,11 +256,19 @@ type outboundTranslationSliceDef = {
256
256
  name: string,
257
257
  consumedEventTypes: array<string>,
258
258
  inboundCommandTypes: array<string>,
259
- targetName: @s.matches(stringOptionSchema) option<string>,
259
+ targetName: option<string>,
260
260
  // Foreign system this slice publishes to — drives the external box (Event Graph).
261
- externalSystem: @s.matches(stringOptionSchema) option<string>,
261
+ externalSystem: option<string>,
262
262
  /** Chapter grouping band — see `queryableDef.chapter`. */
263
- chapter: @s.matches(stringOptionSchema) option<string>,
263
+ chapter: option<string>,
264
+ /** The topics this slice subscribes to, as `Spec.sourceNames` declares them —
265
+ an Aggregate's `Spec.name` or a DCB source name. `Some([])` is the declared
266
+ default and means this plugin's own DCB log; `None` is an older structure
267
+ that did not publish the field. A different fact from `consumedEventTypes`,
268
+ which names event types and not where they came from — two topics carrying
269
+ an event of the same name are indistinguishable there. Optional so an older
270
+ reader ignores it. */
271
+ consumedSources: option<array<string>>,
264
272
  }
265
273
 
266
274
  @schema
@@ -269,9 +277,9 @@ type inboundTranslationSliceDef = {
269
277
  commandTypes: array<string>,
270
278
  targetName: string,
271
279
  // Foreign system this slice receives from — drives the external box (Event Graph).
272
- externalSystem: @s.matches(stringOptionSchema) option<string>,
280
+ externalSystem: option<string>,
273
281
  /** Chapter grouping band — see `queryableDef.chapter`. */
274
- chapter: @s.matches(stringOptionSchema) option<string>,
282
+ chapter: option<string>,
275
283
  }
276
284
 
277
285
  /** A published event of an extension point and the internal events producing it.
@@ -283,8 +291,6 @@ type publishedEventDef = {
283
291
  fromEventTypes: array<string>,
284
292
  }
285
293
 
286
- let publishedEventDefArrayOptionSchema = S.array(publishedEventDefSchema)->S.nullAsOption
287
-
288
294
  /** The command direction's producer half: a command an extension point takes and
289
295
  the delegate commands it routes to. `name` is EP-qualified, `toCommandTypes`
290
296
  plugin-qualified. */
@@ -294,8 +300,6 @@ type acceptedCommandDef = {
294
300
  toCommandTypes: array<string>,
295
301
  }
296
302
 
297
- let acceptedCommandDefArrayOptionSchema = S.array(acceptedCommandDefSchema)->S.nullAsOption
298
-
299
303
  /** The subscriber's half: a published event and the commands it routes to. A
300
304
  delegate command is plugin-qualified, one sent back to the EP is EP-qualified. */
301
305
  @schema
@@ -304,8 +308,6 @@ type handledEventDef = {
304
308
  toCommandTypes: array<string>,
305
309
  }
306
310
 
307
- let handledEventDefArrayOptionSchema = S.array(handledEventDefSchema)->S.nullAsOption
308
-
309
311
  /** The command direction's subscriber half: a command sent back to the port and
310
312
  the internal events producing it. `name` is EP-qualified, `fromEventTypes`
311
313
  plugin-qualified. */
@@ -315,21 +317,19 @@ type issuedCommandDef = {
315
317
  fromEventTypes: array<string>,
316
318
  }
317
319
 
318
- let issuedCommandDefArrayOptionSchema = S.array(issuedCommandDefSchema)->S.nullAsOption
319
-
320
320
  @schema
321
321
  type extensionDef = {
322
322
  name: string,
323
323
  delegateNames: array<string>,
324
324
  eventTypes: array<string>,
325
325
  commandTypes: array<string>,
326
- /** Which published event routes to which commands. js_nullable like
326
+ /** Which published event routes to which commands. Optional like
327
327
  `extensionPointDef.commandTypes`; re-emit definitions persisted before it. */
328
- handledEvents: @s.matches(handledEventDefArrayOptionSchema) option<array<handledEventDef>>,
328
+ handledEvents: option<array<handledEventDef>>,
329
329
  /** Which internal event sends which command back to the port. `None` means a
330
330
  definition persisted before the field, NOT an extension that issues nothing —
331
331
  a reader joining the two halves must keep them apart. */
332
- issuedCommands: @s.matches(issuedCommandDefArrayOptionSchema) option<array<issuedCommandDef>>,
332
+ issuedCommands: option<array<issuedCommandDef>>,
333
333
  }
334
334
 
335
335
  /**
@@ -338,28 +338,21 @@ An extension point owned by a plugin, from the producer side.
338
338
  `sourceEventTypes` are the `Delegate`'s events feeding the published protocol,
339
339
  plugin-qualified to match `writableDef.producedEventTypes`. `commandTypes` is the
340
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.
341
+ nothing. Definitions persisted before a field was added must be re-emitted.
343
342
  */
344
343
  @schema
345
344
  type extensionPointDef = {
346
345
  name: string,
347
346
  delegateNames: array<string>,
348
347
  sourceEventTypes: array<string>,
349
- commandTypes: @s.matches(stringArrayOptionSchema) option<array<string>>,
348
+ commandTypes: option<array<string>>,
350
349
  /** Which internal event becomes which published event. */
351
- publishedEvents: @s.matches(publishedEventDefArrayOptionSchema)
352
- option<array<publishedEventDef>>,
350
+ publishedEvents: option<array<publishedEventDef>>,
353
351
  /** Which arriving command becomes which delegate command. `None` means a
354
352
  definition persisted before the field, NOT a port that accepts nothing. */
355
- acceptedCommands: @s.matches(acceptedCommandDefArrayOptionSchema)
356
- option<array<acceptedCommandDef>>,
353
+ acceptedCommands: option<array<acceptedCommandDef>>,
357
354
  }
358
355
 
359
- // js_nullable creates `array | null` (not `| undefined`), which passes sury's
360
- // jsonableValidation inside the pluginStructure union variant payload.
361
- let extensionPointDefArrayOptionSchema = S.array(extensionPointDefSchema)->S.nullAsOption
362
-
363
356
  /**
364
357
  One field's store requirement, with its provenance.
365
358
 
@@ -374,12 +367,9 @@ type requiredStoreDeclaration = {
374
367
  store: string,
375
368
  component: string,
376
369
  field: string,
377
- annotation: @s.matches(stringOptionSchema) option<string>,
370
+ annotation: option<string>,
378
371
  }
379
372
 
380
- let requiredStoreDeclarationArrayOptionSchema =
381
- S.array(requiredStoreDeclarationSchema)->S.nullAsOption
382
-
383
373
  /**
384
374
  One component's capability requirement, with its provenance.
385
375
 
@@ -392,9 +382,6 @@ expressible as an annotation on one, which is why the need is declared.
392
382
  @schema
393
383
  type requiredCapabilityDeclaration = {capability: string, component: string}
394
384
 
395
- let requiredCapabilityDeclarationArrayOptionSchema =
396
- S.array(requiredCapabilityDeclarationSchema)->S.nullAsOption
397
-
398
385
  /**
399
386
  One graft's provenance: which trait, at which version, on which component.
400
387
 
@@ -419,14 +406,13 @@ type traitDeclaration = {
419
406
  component: string,
420
407
  }
421
408
 
422
- let traitDeclarationArrayOptionSchema = S.array(traitDeclarationSchema)->S.nullAsOption
423
-
424
409
  /**
425
410
  Adding a field here? It must be a shape a stale event can be healed into — the
426
411
  lifecycle aggregate replays its own log before every decision, so one event that
427
- fails to decode freezes that plugin's registration. `Message.parseJsonTolerant`
428
- heals `T | null`, arrays, enums and nested objects; a bare scalar is fabricated
429
- and warned about. Prefer `js_nullable`. Regression suite:
412
+ fails to decode freezes that plugin's registration. Make it `option`, which
413
+ `Message.parseJsonTolerant` heals to `None` whatever the inner type is. A required
414
+ array heals to `[]` and a required enum to its first variant; a required *scalar*
415
+ is fabricated and warned about, so it is the one shape to avoid. Regression suite:
430
416
  `PluginLifecycleCorpusTest` — if it goes red, re-shape the field, not the fixtures.
431
417
  */
432
418
  @schema
@@ -441,27 +427,23 @@ type pluginStructure = {
441
427
  extensions: array<extensionDef>,
442
428
  // Extension points owned by this plugin (producer side). Optional so older
443
429
  // definitions still decode (absent → None, read as []).
444
- extensionPoints: @s.matches(extensionPointDefArrayOptionSchema)
445
- option<array<extensionPointDef>>,
430
+ extensionPoints: option<array<extensionPointDef>>,
446
431
  /** The object stores this plugin's fields declare they need, deduplicated and
447
432
  qualified as `{plugin}.{store}` even for the same-plugin case. */
448
- requiredStores: @s.matches(stringArrayOptionSchema) option<array<string>>,
433
+ requiredStores: option<array<string>>,
449
434
  /** Provenance for `requiredStores`: one entry per declaring `(component, field)`.
450
435
  `requiredStores` is derived from it, so the two cannot disagree. */
451
- requiredStoreDeclarations: @s.matches(requiredStoreDeclarationArrayOptionSchema)
452
- option<array<requiredStoreDeclaration>>,
436
+ requiredStoreDeclarations: option<array<requiredStoreDeclaration>>,
453
437
  /** The platform capabilities this plugin's components declare they need, one
454
438
  entry per declaring component. Object stores are not here — a store need is
455
439
  a field's, and travels as `requiredStores`. Absent → None, read as []. */
456
- requiredCapabilities: @s.matches(requiredCapabilityDeclarationArrayOptionSchema)
457
- option<array<requiredCapabilityDeclaration>>,
440
+ requiredCapabilities: option<array<requiredCapabilityDeclaration>>,
458
441
  /** The domain traits grafted into this plugin, one entry per declaring
459
442
  component. Absent → None, read as []. The only signal a graft leaves that
460
443
  survives into a deployed plugin — every other one (the dependency, the
461
444
  variant spread, the rules alias, the conformance binding) is source-side.
462
445
  A claim about origin, never about behaviour: see `Trait`. */
463
- traitDeclarations: @s.matches(traitDeclarationArrayOptionSchema)
464
- option<array<traitDeclaration>>,
446
+ traitDeclarations: option<array<traitDeclaration>>,
465
447
  }
466
448
 
467
449
  let pluginStructureOffloadSchema = Offload.optionSchema(~store="pluginStructures", pluginStructureSchema)
@@ -488,15 +470,15 @@ type pluginDefinition = {
488
470
  apiSchemaFragment: @s.matches(apiSchemaFragmentOffloadSchema) option<Offload.payload<apiSchemaFragment>>,
489
471
  // Schema routing in split-API mode: None/"Domain" → DomainApi, Some("Platform") →
490
472
  // PlatformApi (and excluded from the DomainApi runtime schema).
491
- apiTarget: @s.matches(stringOptionSchema) option<string>,
473
+ apiTarget: option<string>,
492
474
  // Component graph metadata, offloadable like apiSchemaFragment. Absent for older
493
475
  // protocol versions.
494
476
  structure: @s.matches(pluginStructureOffloadSchema) option<Offload.payload<pluginStructure>>,
495
477
  // EventTopic ARN of a bundled DcbEventLog, so the admin can subscribe peer
496
478
  // EventCollectors to it. None for plugins without one.
497
- dcbEventLog: @s.matches(dcbEventLogOptionSchema) option<dcbEventLogDefinition>,
479
+ dcbEventLog: option<dcbEventLogDefinition>,
498
480
  // Mandatory; `Domain` is resolved as the default in Plugin_Builder. Payload-less
499
- // variant → a bare JSON string, so JSON-safe without js_nullable.
481
+ // variant → a bare JSON string.
500
482
  kind: pluginKind,
501
483
  }
502
484