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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +5 -2
  3. package/run-certify-trait.mjs +2 -0
  4. package/run-graft-trait.mjs +2 -0
  5. package/run-trait-manifest.mjs +2 -0
  6. package/schema/platform-api.graphql +17 -3
  7. package/src/components/Aggregate.res +22 -0
  8. package/src/components/AutomationSlice.res +29 -3
  9. package/src/components/CapabilityManifest.res +52 -24
  10. package/src/components/CapabilityManifest.res.mjs +35 -11
  11. package/src/components/InboundTranslationSlice.res +14 -0
  12. package/src/components/OutboundTranslationSlice.res +24 -0
  13. package/src/components/Plugin.res +53 -0
  14. package/src/components/Plugin.res.mjs +23 -1
  15. package/src/components/StateChangeSlice.res +22 -0
  16. package/src/components/TraitCertificate.res +105 -0
  17. package/src/components/TraitCertificate.res.mjs +65 -0
  18. package/src/components/TraitManifest.res +90 -0
  19. package/src/components/TraitManifest.res.mjs +48 -0
  20. package/src/generator/CertifyTrait.res +190 -0
  21. package/src/generator/CertifyTrait.res.mjs +154 -0
  22. package/src/generator/GraftTrait.res +230 -0
  23. package/src/generator/GraftTrait.res.mjs +193 -0
  24. package/src/generator/PlatformCodegen.res +44 -30
  25. package/src/generator/PlatformCodegen.res.mjs +36 -17
  26. package/src/generator/TraitManifestCli.res +138 -0
  27. package/src/generator/TraitManifestCli.res.mjs +105 -0
  28. package/src/semantic/Capabilities.res +19 -3
  29. package/src/semantic/Capabilities.res.mjs +18 -2
  30. package/src/semantic/CapabilityNeed.res +81 -0
  31. package/src/semantic/CapabilityNeed.res.mjs +46 -0
  32. package/src/semantic/Messaging.res +127 -0
  33. package/src/semantic/Messaging.res.mjs +57 -0
  34. package/src/types/Trait.res +62 -0
  35. package/src/types/Trait.res.mjs +18 -0
  36. package/src/types/Transition.res +71 -0
  37. package/src/types/Transition.res.mjs +36 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,31 @@
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.125 (2026-09-01)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **automation:** a mapping is handed the envelope's id ([9ee482c](https://github.com/ReventlessDev/reventless-core/commit/9ee482c5ad0aae09efd1259eeb7f39b781867b91))
11
+ * **capabilities:** a plugin's geocoding need was declared nowhere and failed silently ([67917dd](https://github.com/ReventlessDev/reventless-core/commit/67917dd504b43fa78b7c6a51644c9eae656b7f6b))
12
+ * feat(example)!: model product and category images as attachment sets ([6ae18d8](https://github.com/ReventlessDev/reventless-core/commit/6ae18d896215b448177ba0516e74bfda5f88d2db))
13
+ ### Features
14
+
15
+ * **capabilities:** a plugin can send a message without naming a provider ([b001a1e](https://github.com/ReventlessDev/reventless-core/commit/b001a1e9361a0f4d0affe228cb4c08c38a5a995e))
16
+ * **plugin:** the admin lifecycle commands name their argument ([ea552ec](https://github.com/ReventlessDev/reventless-core/commit/ea552ec64e5db0b3dc465c0d9a80cac89627f727))
17
+ * **spec:** a command declares its lifecycle edge as a value ([40eee9f](https://github.com/ReventlessDev/reventless-core/commit/40eee9f7723dc05e418be680528f01967d074da4))
18
+ * **spec:** a graft leaves a trace the deployed plugin can read ([c08ff6c](https://github.com/ReventlessDev/reventless-core/commit/c08ff6c0f6177d58603e7ae1e5cec392d9bac16a))
19
+ * **spec:** graft-trait, a CLI that runs a trait's emitter ([1b87399](https://github.com/ReventlessDev/reventless-core/commit/1b87399987033765d6a1ee8ea22ab7f02056e9c0))
20
+ * **traits:** a conformance run leaves something a machine can read ([cd9cb81](https://github.com/ReventlessDev/reventless-core/commit/cd9cb81aa643f8e30ccf072df458fdc136897746))
21
+ * **traits:** a listing reads a trait instead of being told about it ([8a23219](https://github.com/ReventlessDev/reventless-core/commit/8a23219c5a69011ef9310ebf8bfcbf9315a577ba))
22
+
23
+ ### BREAKING CHANGES
24
+
25
+ * ProductAdded/CategoryAdded lose their image field and
26
+ ChangeProductImage/ChangeCategoryImage are replaced; the alpha event log is
27
+ wiped on the next deploy.
28
+
29
+
30
+
6
31
  # 3.0.0-alpha.124 (2026-08-27)
7
32
 
8
33
  * feat(spec)!: reflect the command direction across a port, both halves ([f2fe258](https://github.com/ReventlessDev/reventless-core/commit/f2fe258d195b74f4a61488edee305665341020ea))
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.124",
3
+ "version": "3.0.0-alpha.125",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
7
7
  "generate-plugin": "./run-generator.mjs",
8
- "generate-platform": "./run-platform-generator.mjs"
8
+ "generate-platform": "./run-platform-generator.mjs",
9
+ "graft-trait": "./run-graft-trait.mjs",
10
+ "certify-trait": "./run-certify-trait.mjs",
11
+ "trait-manifest": "./run-trait-manifest.mjs"
9
12
  },
10
13
  "jest": {
11
14
  "testMatch": [
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "./src/generator/CertifyTrait.res.mjs"
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "./src/generator/GraftTrait.res.mjs"
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "./src/generator/TraitManifestCli.res.mjs"
@@ -18,9 +18,9 @@ union CommandResult = CommandAccepted | CommandPending | CommandRejected
18
18
 
19
19
  type Mutation {
20
20
  Platform_PluginStatusChanged(pluginId: ID!, status: PluginStatus!): PluginStatusChangeEvent
21
- Platform_Plugin_Activate(_0: String!, id: ID!): CommandResult!
22
- Platform_Plugin_Deactivate(_0: String!, id: ID!): CommandResult!
23
- Platform_Plugin_Retire(_0: String!, id: ID!): CommandResult!
21
+ Platform_Plugin_Activate(id: ID!, version: String!): CommandResult!
22
+ Platform_Plugin_Deactivate(id: ID!, version: String!): CommandResult!
23
+ Platform_Plugin_Retire(id: ID!, version: String!): CommandResult!
24
24
  Platform_UIFragmentDeregistered(pluginId: ID!): UIFragmentChangeEvent
25
25
  Platform_UIFragmentRegistered(manifest: String, pluginId: ID!): UIFragmentChangeEvent
26
26
  Platform_UIFragmentUpdated(manifest: String, pluginId: ID!): UIFragmentChangeEvent
@@ -225,10 +225,12 @@ type Platform_PluginStructureEntry {
225
225
  outboundTranslationSlices: [Platform_OutboundTranslationSliceDef!]!
226
226
  pluginId: String!
227
227
  readModels: [Platform_ReadSideDef!]!
228
+ requiredCapabilities: [Platform_RequiredCapabilityDeclaration!]
228
229
  requiredStoreDeclarations: [Platform_RequiredStoreDeclaration!]
229
230
  requiredStores: [String!]
230
231
  stateChangeSlices: [Platform_WriteSideDef!]!
231
232
  stateViewSlices: [Platform_ReadSideDef!]!
233
+ traitDeclarations: [Platform_TraitDeclaration!]
232
234
  }
233
235
 
234
236
  type Platform_PublishedEventDef {
@@ -258,6 +260,11 @@ type Platform_ReadSideDef {
258
260
  visibility: String
259
261
  }
260
262
 
263
+ type Platform_RequiredCapabilityDeclaration {
264
+ capability: String!
265
+ component: String!
266
+ }
267
+
261
268
  type Platform_RequiredStoreDeclaration {
262
269
  annotation: String
263
270
  component: String!
@@ -265,6 +272,13 @@ type Platform_RequiredStoreDeclaration {
265
272
  store: String!
266
273
  }
267
274
 
275
+ type Platform_TraitDeclaration {
276
+ component: String!
277
+ posture: String!
278
+ trait: String!
279
+ version: String!
280
+ }
281
+
268
282
  type Platform_UIFragmentEntry {
269
283
  pages: [Platform_UIPage!]!
270
284
  panels: [Platform_UIPanel!]!
@@ -61,4 +61,26 @@ module type Spec = {
61
61
  `AllowAuthenticated`; override at the file/module level with
62
62
  `@@reventless.authorize(<rule>)`. */
63
63
  let commandAuthorization: command => Authorization.permission
64
+
65
+ /** The lifecycle enum this component's commands move a row through — the
66
+ linked view's own, e.g. `type lifecycleState = Customers.accountStatus`.
67
+ Auto-injected as `unit` alongside the default below; a host that declares
68
+ `commandTransition` declares this too, and the pair is what makes every
69
+ edge name one lifecycle. */
70
+ type lifecycleState
71
+
72
+ /** The lifecycle edge each command owns, read while the plugin structure is
73
+ assembled. Auto-injected as `_ => Unrestricted` by `@@reventless.spec`,
74
+ which leaves `@transition` in charge; a host that writes the switch by
75
+ hand takes charge instead, and gets an exhaustive one over typed states.
76
+ See `Transition`. */
77
+ let commandTransition: command => Transition.t<lifecycleState>
78
+
79
+ /** The domain traits grafted into this component, as values the trait packages
80
+ export — `[TraitAttachments.Attachments.declaration]`. Auto-injected as `[]`
81
+ by `@@reventless.spec`, so a component that is nobody's graft says so without
82
+ a line. A graft names its trait here and the structure records it, which is
83
+ the only way a deployed plugin can answer "where did this come from". See
84
+ `Trait`. */
85
+ let traits: array<Trait.t>
64
86
  }
@@ -83,6 +83,16 @@ module type Spec = {
83
83
  // validation step run before each command publish. `process` remains shared in
84
84
  // the slice's `Automation` module since it operates on `todoItem` regardless of
85
85
  // the originating source.
86
+ //
87
+ // `collect` is handed `~sourceId` alongside the event, so a mapping over an
88
+ // Aggregate source can key its todo item even though the payload does not repeat
89
+ // the id that addressed it — `Registered({email, address})` is the case this
90
+ // exists for. A DCB event usually names its own subject and can ignore it.
91
+ //
92
+ // An `OutboundTranslationSlice` remains the better shape for a relay that simply
93
+ // forwards: its item completes when the command is published rather than when an
94
+ // answering event arrives, so the target command is free to be idempotent instead
95
+ // of having to emit a fact whose only job is closing the row.
86
96
 
87
97
  /**
88
98
  Ambient deployment context plumbed to every mapping function.
@@ -125,7 +135,15 @@ module type Mapping = {
125
135
  type command
126
136
  let sourceEventSchema: S.t<sourceEvent>
127
137
  let sourceName: string
128
- let collect: (sourceEvent, context) => array<(string, todoItem)>
138
+ /**
139
+ `~sourceId` is the id of the entity the event was published for — the
140
+ envelope's `id`, not part of the event payload. A DCB event usually names its
141
+ own subject (`OrderPlaced({orderId, …})`) and can ignore this; an Aggregate's
142
+ event generally does not, because the aggregate id is what addressed it in the
143
+ first place. Without it a mapping over `Registered({email, address})` would
144
+ have no way to say which customer its todo item is for.
145
+ */
146
+ let collect: (sourceEvent, ~sourceId: string, context) => array<(string, todoItem)>
129
147
  let resolve: sourceEvent => option<string>
130
148
  }
131
149
 
@@ -199,7 +217,15 @@ module type MappingImpl = {
199
217
  type sourceEvent
200
218
  type todoItem
201
219
  type command
202
- let collect: (sourceEvent, context) => array<(string, todoItem)>
220
+ /**
221
+ `~sourceId` is the id of the entity the event was published for — the
222
+ envelope's `id`, not part of the event payload. A DCB event usually names its
223
+ own subject (`OrderPlaced({orderId, …})`) and can ignore this; an Aggregate's
224
+ event generally does not, because the aggregate id is what addressed it in the
225
+ first place. Without it a mapping over `Registered({email, address})` would
226
+ have no way to say which customer its todo item is for.
227
+ */
228
+ let collect: (sourceEvent, ~sourceId: string, context) => array<(string, todoItem)>
203
229
  let resolve: sourceEvent => option<string>
204
230
  }
205
231
 
@@ -242,7 +268,7 @@ module FromOrderShipped = Reventless.AutomationSlice.Mapping.Make(
242
268
  OrderSpec, // Source: Aggregate spec module
243
269
  AutoFulfillmentSpec, // Target: this slice's spec
244
270
  {
245
- let collect = (event, _ctx) =>
271
+ let collect = (event, ~sourceId as _, _ctx) =>
246
272
  switch event {
247
273
  | OrderSpec.OrderShipped({orderId, productId}) =>
248
274
  [(orderId ++ ":" ++ productId, {AutoFulfillmentSpec.orderId, productId})]
@@ -2,29 +2,38 @@
2
2
  The per-plugin capability manifest — `capabilities.json`, written beside the
3
3
  generated `Plugin.res` by the plugin package's build.
4
4
 
5
- Each entry states one capability the plugin's fields declare they need, keyed
6
- by the store's qualified `{plugin}.{store}` identity, with the declaring
7
- `(component, field)` sites as provenance. The keys are taken verbatim from
8
- `pluginStructure.requiredStores` — the manifest is a rendering of the
9
- structure, never a second scan of the sources, so the two cannot spell one
10
- fact differently.
5
+ Each entry states one capability the plugin declares it needs, keyed by that
6
+ capability's identity — the qualified `{plugin}.{store}` for an object store, the
7
+ capability's own name for everything else — with the declaring sites as
8
+ provenance. The keys are taken verbatim from `pluginStructure`: the manifest is a
9
+ rendering of the structure, never a second scan of the sources, so the two cannot
10
+ spell one fact differently.
11
11
 
12
12
  The platform generator unions these files across a deployment's plugins and
13
13
  emits the platform's capability list from them.
14
14
  */
15
15
 
16
16
  @schema
17
- type kind = ObjectStore
17
+ type kind =
18
+ | ObjectStore
19
+ /** Address geocoding, reached through `Capabilities.geocode`. Declared by a
20
+ slice rather than by a field, so its entry carries no `field`. */
21
+ | Geocoding
22
+ /** Sending a message, reached through `Capabilities.messaging`. Slice-declared
23
+ like `Geocoding`, and for the same reason carries no `field`. */
24
+ | Messaging
18
25
 
19
- /** The declaration site: the component's spec name and the field carrying the
20
- `@storageRef` annotation, plus the store exactly as that field spells it.
26
+ /** The declaration site: the component's spec name, and — for a store — the field
27
+ carrying the `@storageRef` annotation plus the store exactly as that field
28
+ spells it.
21
29
 
22
- `annotation` is optional only to keep reading a manifest emitted before it
23
- existed: a plugin built earlier still parses, and a reader that cannot say
24
- what the source says omits the claim rather than inventing one. Every
25
- manifest emitted now carries it. */
30
+ `field` and `annotation` are optional. `annotation` because a manifest emitted
31
+ before it existed must still parse, and a reader that cannot say what the
32
+ source says omits the claim rather than inventing one; `field` because a
33
+ capability a slice declares has no declaring field, and naming one would be a
34
+ fiction. Every store entry emitted now carries both. */
26
35
  @schema
27
- type provenance = {component: string, field: string, annotation?: string}
36
+ type provenance = {component: string, field?: string, annotation?: string}
28
37
 
29
38
  @schema
30
39
  type entry = {
@@ -39,17 +48,18 @@ type t = {capabilities: array<entry>}
39
48
  /**
40
49
  Build the manifest from a plugin's structure.
41
50
 
42
- Keys iterate `requiredStores` itself, so a manifest's key set is byte-identical
43
- to what the deployed plugin reports at runtime — the contract the deploy-time
44
- coverage assertion checks against. A structure with no declarations yields an
45
- empty `capabilities` list, not an absent file: "declares nothing" is a
46
- statement, and the generator reading the manifests must be able to tell it
47
- apart from "was never built".
51
+ Store keys iterate `requiredStores` itself, so a manifest's key set is
52
+ byte-identical to what the deployed plugin reports at runtime — the contract the
53
+ deploy-time coverage assertion checks against. Capability entries follow, one per
54
+ distinct capability, ordered by name so the file does not churn. A structure with
55
+ no declarations yields an empty `capabilities` list, not an absent file:
56
+ "declares nothing" is a statement, and the generator reading the manifests must
57
+ be able to tell it apart from "was never built".
48
58
  */
49
59
  let fromStructure = (structure: Plugin.pluginStructure): t => {
50
60
  let declarations = structure.requiredStoreDeclarations->Option.getOr([])
51
- {
52
- capabilities: structure.requiredStores
61
+ let stores =
62
+ structure.requiredStores
53
63
  ->Option.getOr([])
54
64
  ->Array.map(key => {
55
65
  kind: ObjectStore,
@@ -59,8 +69,26 @@ let fromStructure = (structure: Plugin.pluginStructure): t => {
59
69
  ? Some({component: d.component, field: d.field, annotation: ?d.annotation})
60
70
  : None
61
71
  ),
62
- }),
63
- }
72
+ })
73
+ let needs = structure.requiredCapabilities->Option.getOr([])
74
+ let capabilityKeys =
75
+ needs->Array.map(d => d.capability)->Belt.Set.String.fromArray->Belt.Set.String.toArray
76
+ let capabilities = capabilityKeys->Array.filterMap(key =>
77
+ // An unrecognised capability is dropped rather than passed through: the
78
+ // generator downstream renders a real `Platform.capability` arm, and a name
79
+ // this build cannot map has no arm to render.
80
+ CapabilityNeed.fromString(key)->Option.map(need => {
81
+ kind: switch need {
82
+ | Geocoding => Geocoding
83
+ | Messaging => Messaging
84
+ },
85
+ key,
86
+ declaredBy: needs->Array.filterMap(d =>
87
+ d.capability == key ? Some({component: d.component}) : None
88
+ ),
89
+ })
90
+ )
91
+ {capabilities: Array.concat(stores, capabilities)}
64
92
  }
65
93
 
66
94
  /** Deterministic rendering: 2-space indent, trailing newline. Rebuilding with
@@ -3,13 +3,19 @@
3
3
  import * as Sury from "sury";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
6
7
  import * as Util_Sury$Reventless from "../util/Util_Sury.res.mjs";
8
+ import * as CapabilityNeed$Reventless from "../semantic/CapabilityNeed.res.mjs";
7
9
 
8
- let kindSchema = Sury.literal("ObjectStore");
10
+ let kindSchema = Sury.union([
11
+ Sury.literal("ObjectStore"),
12
+ Sury.literal("Geocoding"),
13
+ Sury.literal("Messaging")
14
+ ]);
9
15
 
10
16
  let provenanceSchema = Sury.$schema(s => ({
11
17
  component: s.m(Sury.string),
12
- field: s.m(Sury.string),
18
+ field: s.m(Sury.$option(Sury.string)),
13
19
  annotation: s.m(Sury.$option(Sury.string))
14
20
  }));
15
21
 
@@ -25,20 +31,38 @@ let schema = Sury.$schema(s => ({
25
31
 
26
32
  function fromStructure(structure) {
27
33
  let declarations = Stdlib_Option.getOr(structure.requiredStoreDeclarations, []);
28
- return {
29
- capabilities: Stdlib_Option.getOr(structure.requiredStores, []).map(key => ({
30
- kind: "ObjectStore",
34
+ let stores = Stdlib_Option.getOr(structure.requiredStores, []).map(key => ({
35
+ kind: "ObjectStore",
36
+ key: key,
37
+ declaredBy: Stdlib_Array.filterMap(declarations, d => {
38
+ if (d.store === key) {
39
+ return {
40
+ component: d.component,
41
+ field: d.field,
42
+ annotation: d.annotation
43
+ };
44
+ }
45
+ })
46
+ }));
47
+ let needs = Stdlib_Option.getOr(structure.requiredCapabilities, []);
48
+ let capabilityKeys = Belt_SetString.toArray(Belt_SetString.fromArray(needs.map(d => d.capability)));
49
+ let capabilities = Stdlib_Array.filterMap(capabilityKeys, key => Stdlib_Option.map(CapabilityNeed$Reventless.fromString(key), need => {
50
+ let tmp;
51
+ tmp = need === "Geocoding" ? "Geocoding" : "Messaging";
52
+ return {
53
+ kind: tmp,
31
54
  key: key,
32
- declaredBy: Stdlib_Array.filterMap(declarations, d => {
33
- if (d.store === key) {
55
+ declaredBy: Stdlib_Array.filterMap(needs, d => {
56
+ if (d.capability === key) {
34
57
  return {
35
- component: d.component,
36
- field: d.field,
37
- annotation: d.annotation
58
+ component: d.component
38
59
  };
39
60
  }
40
61
  })
41
- }))
62
+ };
63
+ }));
64
+ return {
65
+ capabilities: stores.concat(capabilities)
42
66
  };
43
67
  }
44
68
 
@@ -62,6 +62,20 @@ module type Spec = {
62
62
  on structurally-detected inline spec modules — defaults to
63
63
  `AllowAuthenticated`. */
64
64
  let commandAuthorization: command => Authorization.permission
65
+
66
+ /** The lifecycle enum this component's commands move a row through — the
67
+ linked view's own, e.g. `type lifecycleState = Customers.accountStatus`.
68
+ Auto-injected as `unit` alongside the default below; a host that declares
69
+ `commandTransition` declares this too, and the pair is what makes every
70
+ edge name one lifecycle. */
71
+ type lifecycleState
72
+
73
+ /** The lifecycle edge each command owns, read while the plugin structure is
74
+ assembled. Auto-injected as `_ => Unrestricted` by `@@reventless.spec`,
75
+ which leaves `@transition` in charge; a host that writes the switch by
76
+ hand takes charge instead, and gets an exhaustive one over typed states.
77
+ See `Transition`. */
78
+ let commandTransition: command => Transition.t<lifecycleState>
65
79
  }
66
80
 
67
81
  /**
@@ -106,6 +106,30 @@ module type Spec = {
106
106
  in the Event Graph / Context Map.
107
107
  Auto-injected by `@@reventless.spec` defaulting to `None` — set it to name the box. */
108
108
  let externalSystem: option<string>
109
+
110
+ /**
111
+ The platform capabilities this slice's `translate` reaches for.
112
+
113
+ `[]` — the common case — means `translate` calls a service the framework does
114
+ not broker, and the deployment provisions nothing on its behalf. Naming a
115
+ capability makes the need a checked fact: it reaches `capabilities.json`, the
116
+ platform's generated capability list, and the deploy-time gate, which refuses a
117
+ plugin whose platform provisions none of it.
118
+
119
+ Declared rather than inferred because what `translate` reads off
120
+ `Capabilities.t` is only visible in its body, and provisioning infrastructure
121
+ from a guess at a function body is not a service. A trait exports the value for
122
+ its host to name, so grafting one cannot leave the need unstated.
123
+ */
124
+ let capabilityNeeds: array<CapabilityNeed.t>
125
+
126
+ /** The domain traits grafted into this component, as values the trait packages
127
+ export — `[TraitAttachments.Attachments.declaration]`. Auto-injected as `[]`
128
+ by `@@reventless.spec`, so a component that is nobody's graft says so without
129
+ a line. A graft names its trait here and the structure records it, which is
130
+ the only way a deployed plugin can answer "where did this come from". See
131
+ `Trait`. */
132
+ let traits: array<Trait.t>
109
133
  }
110
134
 
111
135
  /**
@@ -380,6 +380,47 @@ type requiredStoreDeclaration = {
380
380
  let requiredStoreDeclarationArrayOptionSchema =
381
381
  S.array(requiredStoreDeclarationSchema)->S.nullAsOption
382
382
 
383
+ /**
384
+ One component's capability requirement, with its provenance.
385
+
386
+ `capability` is `CapabilityNeed.toString` — a string rather than an enum so a
387
+ plugin built against a newer framework still decodes here; `component` names the
388
+ slice that declared it, so a diff can say which component added or removed the
389
+ need. Unlike a store there is no field: what a `translate` reaches for is not
390
+ expressible as an annotation on one, which is why the need is declared.
391
+ */
392
+ @schema
393
+ type requiredCapabilityDeclaration = {capability: string, component: string}
394
+
395
+ let requiredCapabilityDeclarationArrayOptionSchema =
396
+ S.array(requiredCapabilityDeclarationSchema)->S.nullAsOption
397
+
398
+ /**
399
+ One graft's provenance: which trait, at which version, on which component.
400
+
401
+ Strings rather than the `Trait.t` variant for `posture`, on the same rule the
402
+ capability above follows — a plugin built against a newer framework, naming a
403
+ posture this one has never heard of, still decodes here rather than failing the
404
+ whole structure.
405
+
406
+ `component` is not declared by the trait or by the host: the structure fills it in
407
+ while it walks the components, because it is the only party that knows which one
408
+ carried the declaration. Nothing in this record is a string a developer typed.
409
+
410
+ It records ORIGIN, not behaviour — a grafted file is the host's to edit
411
+ afterwards. What answers "does it still behave like the trait" is the trait's own
412
+ conformance suite, which runs in the consumer's build and is not this field.
413
+ */
414
+ @schema
415
+ type traitDeclaration = {
416
+ trait: string,
417
+ version: string,
418
+ posture: string,
419
+ component: string,
420
+ }
421
+
422
+ let traitDeclarationArrayOptionSchema = S.array(traitDeclarationSchema)->S.nullAsOption
423
+
383
424
  /**
384
425
  Adding a field here? It must be a shape a stale event can be healed into — the
385
426
  lifecycle aggregate replays its own log before every decision, so one event that
@@ -409,6 +450,18 @@ type pluginStructure = {
409
450
  `requiredStores` is derived from it, so the two cannot disagree. */
410
451
  requiredStoreDeclarations: @s.matches(requiredStoreDeclarationArrayOptionSchema)
411
452
  option<array<requiredStoreDeclaration>>,
453
+ /** The platform capabilities this plugin's components declare they need, one
454
+ entry per declaring component. Object stores are not here — a store need is
455
+ a field's, and travels as `requiredStores`. Absent → None, read as []. */
456
+ requiredCapabilities: @s.matches(requiredCapabilityDeclarationArrayOptionSchema)
457
+ option<array<requiredCapabilityDeclaration>>,
458
+ /** The domain traits grafted into this plugin, one entry per declaring
459
+ component. Absent → None, read as []. The only signal a graft leaves that
460
+ survives into a deployed plugin — every other one (the dependency, the
461
+ variant spread, the rules alias, the conformance binding) is source-side.
462
+ A claim about origin, never about behaviour: see `Trait`. */
463
+ traitDeclarations: @s.matches(traitDeclarationArrayOptionSchema)
464
+ option<array<traitDeclaration>>,
412
465
  }
413
466
 
414
467
  let pluginStructureOffloadSchema = Offload.optionSchema(~store="pluginStructures", pluginStructureSchema)
@@ -240,6 +240,22 @@ let requiredStoreDeclarationSchema = Sury.$schema(s => ({
240
240
 
241
241
  let requiredStoreDeclarationArrayOptionSchema = Sury.$nullAsOption(Sury.array(requiredStoreDeclarationSchema));
242
242
 
243
+ let requiredCapabilityDeclarationSchema = Sury.$schema(s => ({
244
+ capability: s.m(Sury.string),
245
+ component: s.m(Sury.string)
246
+ }));
247
+
248
+ let requiredCapabilityDeclarationArrayOptionSchema = Sury.$nullAsOption(Sury.array(requiredCapabilityDeclarationSchema));
249
+
250
+ let traitDeclarationSchema = Sury.$schema(s => ({
251
+ trait: s.m(Sury.string),
252
+ version: s.m(Sury.string),
253
+ posture: s.m(Sury.string),
254
+ component: s.m(Sury.string)
255
+ }));
256
+
257
+ let traitDeclarationArrayOptionSchema = Sury.$nullAsOption(Sury.array(traitDeclarationSchema));
258
+
243
259
  let pluginStructureSchema = Sury.$schema(s => ({
244
260
  readModels: s.m(Sury.array(queryableDefSchema)),
245
261
  stateViewSlices: s.m(Sury.array(queryableDefSchema)),
@@ -251,7 +267,9 @@ let pluginStructureSchema = Sury.$schema(s => ({
251
267
  extensions: s.m(Sury.array(extensionDefSchema)),
252
268
  extensionPoints: s.m(extensionPointDefArrayOptionSchema),
253
269
  requiredStores: s.m(stringArrayOptionSchema),
254
- requiredStoreDeclarations: s.m(requiredStoreDeclarationArrayOptionSchema)
270
+ requiredStoreDeclarations: s.m(requiredStoreDeclarationArrayOptionSchema),
271
+ requiredCapabilities: s.m(requiredCapabilityDeclarationArrayOptionSchema),
272
+ traitDeclarations: s.m(traitDeclarationArrayOptionSchema)
255
273
  }));
256
274
 
257
275
  let pluginStructureOffloadSchema = Offload$Reventless.optionSchema(undefined, "pluginStructures", undefined, pluginStructureSchema);
@@ -314,6 +332,10 @@ export {
314
332
  extensionPointDefArrayOptionSchema,
315
333
  requiredStoreDeclarationSchema,
316
334
  requiredStoreDeclarationArrayOptionSchema,
335
+ requiredCapabilityDeclarationSchema,
336
+ requiredCapabilityDeclarationArrayOptionSchema,
337
+ traitDeclarationSchema,
338
+ traitDeclarationArrayOptionSchema,
317
339
  pluginStructureSchema,
318
340
  pluginStructureOffloadSchema,
319
341
  pluginDefinitionSchema,
@@ -85,6 +85,28 @@ module type Spec = {
85
85
  `@@reventless.authorize(<rule>)`. */
86
86
  let commandAuthorization: command => Authorization.permission
87
87
 
88
+ /** The lifecycle enum this component's commands move a row through — the
89
+ linked view's own, e.g. `type lifecycleState = Customers.accountStatus`.
90
+ Auto-injected as `unit` alongside the default below; a host that declares
91
+ `commandTransition` declares this too, and the pair is what makes every
92
+ edge name one lifecycle. */
93
+ type lifecycleState
94
+
95
+ /** The lifecycle edge each command owns, read while the plugin structure is
96
+ assembled. Auto-injected as `_ => Unrestricted` by `@@reventless.spec`,
97
+ which leaves `@transition` in charge; a host that writes the switch by
98
+ hand takes charge instead, and gets an exhaustive one over typed states.
99
+ See `Transition`. */
100
+ let commandTransition: command => Transition.t<lifecycleState>
101
+
102
+ /** The domain traits grafted into this component, as values the trait packages
103
+ export — `[TraitAttachments.Attachments.declaration]`. Auto-injected as `[]`
104
+ by `@@reventless.spec`, so a component that is nobody's graft says so without
105
+ a line. A graft names its trait here and the structure records it, which is
106
+ the only way a deployed plugin can answer "where did this come from". See
107
+ `Trait`. */
108
+ let traits: array<Trait.t>
109
+
88
110
  /** Decision-read consistency mode for this slice's optimistic-concurrency
89
111
  retry loop. Auto-injected by `@@reventless.spec` and on
90
112
  structurally-detected inline spec modules — defaults to