@reventlessdev/reventless-core 3.0.0-alpha.284 → 3.0.0-alpha.286

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 (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +7 -7
  3. package/src/Message.res.mjs +3 -0
  4. package/src/Roles.res +3 -0
  5. package/src/Roles.res.mjs +2 -0
  6. package/src/admin/Platform_AdminGroup.res +12 -0
  7. package/src/admin/Platform_AdminGroup.res.mjs +23 -0
  8. package/src/admin/Platform_Admin_Structure.res +14 -10
  9. package/src/admin/Platform_Admin_Structure.res.mjs +22 -33
  10. package/src/components/Aggregate/Aggregate_Callback.res +116 -39
  11. package/src/components/Aggregate/Aggregate_Callback.res.mjs +96 -37
  12. package/src/components/AutomationSlice/AutomationSlice_Builder.res +2 -1
  13. package/src/components/CommandGenerator/CommandGenerator_Callback.res +9 -8
  14. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +10 -8
  15. package/src/components/CommandTopic/CommandTopic.res.mjs +3 -0
  16. package/src/components/CommandTopic/CommandTopic_Helpers.res +37 -1
  17. package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +29 -0
  18. package/src/components/Counter/Counter_Builder.res +4 -2
  19. package/src/components/Dcb/Dcb_Builder.res +9 -6
  20. package/src/components/Dcb/Dcb_Builder.res.mjs +6 -6
  21. package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res +2 -1
  22. package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Builder.res +2 -1
  23. package/src/components/QueryDb/QueryDb_Builder.res +1 -1
  24. package/src/components/QueryDb/QueryDb_Builder.res.mjs +3 -2
  25. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +191 -141
  26. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +66 -12
  27. package/src/components/StateViewSlice/StateViewSlice_Builder.res +1 -0
  28. package/src/plugin/api/PluginBaseFragment.res +1 -1
  29. package/src/plugin/api/PluginBaseFragment.res.mjs +2 -1
  30. package/src/plugin/component/Plugin_Builder.res +7 -3
  31. package/src/plugin/component/Plugin_Builder.res.mjs +6 -3
  32. package/src/plugin/component/Plugin_Helpers.res +35 -6
  33. package/src/plugin/component/Plugin_Helpers.res.mjs +47 -5
  34. package/src/plugin/component/Plugin_Structure.res +52 -19
  35. package/src/plugin/component/Plugin_Structure.res.mjs +40 -15
  36. package/src/plugin/lifecycle/PluginsReadModelSpec.res +1 -1
  37. package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +1 -1
  38. package/tests/admin/AdminApiSchemaDriftTest.res +9 -3
  39. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +3 -3
  40. package/tests/admin/Platform_Admin_StructureTest.res +17 -14
  41. package/tests/admin/Platform_Admin_StructureTest.res.mjs +31 -19
  42. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +8 -72
  43. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +9 -39
  44. package/tests/api/GraphQL_FragmentGeneratorTest.res +1 -1
  45. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +1 -1
  46. package/tests/commandgenerator/OwnerActingTest.res +601 -0
  47. package/tests/commandgenerator/OwnerActingTest.res.mjs +1018 -0
  48. package/tests/commandgenerator/OwnerStampingTest.res +90 -0
  49. package/tests/commandgenerator/OwnerStampingTest.res.mjs +87 -0
  50. package/tests/commandtopic/CommandOutcomeHookTest.res +1 -0
  51. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +11 -1
  52. package/tests/plugin/PluginStructureAccessTest.res +30 -1
  53. package/tests/plugin/PluginStructureAccessTest.res.mjs +28 -1
  54. package/tests/plugin/StateChange/PsGatedCommands.res +9 -1
  55. package/tests/plugin/StateChange/PsGatedCommands.res.mjs +4 -1
  56. package/tests/plugin/StateView/PsGatedView.res +1 -1
  57. package/tests/plugin/StateView/PsGatedView.res.mjs +1 -1
  58. package/tests/plugin/pluginDefinitionRequiredScalars.txt +5 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
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.286 (2026-10-01)
7
+
8
+ * feat!: roles a plugin declares, groups a platform provides ([43daace](https://github.com/ReventlessDev/reventless-core/commit/43daace6ca588afe22562d079af1e6f1da81942e))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * AllowGroups and AdminGroup are removed — write AllowRoles over
13
+ the plugin's Roles.t, and use Role.admin / Platform.adminGroup(). Spec module
14
+ types declare `type role`, and their rules are Authorization.rule<role>; a spec
15
+ that writes its own rule declares `type role` (Reventless.Role.name for a rule
16
+ over Authorization.permission). Role.name is a private string: make one from a
17
+ string with Role.make.
18
+
19
+
20
+
21
+ # 3.0.0-alpha.285 (2026-10-01)
22
+
23
+ ### Features
24
+
25
+ * **spec, aws, core, local:** an app declares its environments, and every deployment records its commit ([c7cc925](https://github.com/ReventlessDev/reventless-core/commit/c7cc92573e97742b681a9000cea0bdfd6e9a33fc))
26
+ * **spec, core, gwt, ppx:** a command acts only on what its caller owns ([2636f0e](https://github.com/ReventlessDev/reventless-core/commit/2636f0ee44797ee2c0b0c40a7107cf306113173f))
27
+
28
+
6
29
  # 3.0.0-alpha.284 (2026-09-29)
7
30
 
8
31
  **Note:** Version bump only for package @reventlessdev/reventless-core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.284",
3
+ "version": "3.0.0-alpha.286",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -30,20 +30,20 @@
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.34",
32
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.20",
33
- "@reventlessdev/rescript-node": "2.0.0-alpha.20",
33
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
34
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.11",
35
+ "@reventlessdev/rescript-node": "2.0.0-alpha.20",
35
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.22",
36
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
37
37
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.21",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.182",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.38",
40
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.154"
39
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.38",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.156",
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.184"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.1",
45
45
  "sury-ppx": "11.0.0",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.95"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.96"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "^12.3.1"
@@ -381,6 +381,8 @@ function combineMessage(typ, data) {
381
381
 
382
382
  let serviceSchema = Message$Reventless.serviceSchema;
383
383
 
384
+ let CallerClaim = Message$Reventless.CallerClaim;
385
+
384
386
  let metaSchema = Message$Reventless.metaSchema;
385
387
 
386
388
  let contextSchema = Message$Reventless.contextSchema;
@@ -402,6 +404,7 @@ let InvalidEvent = Message$Reventless.InvalidEvent;
402
404
  export {
403
405
  logger,
404
406
  serviceSchema,
407
+ CallerClaim,
405
408
  metaSchema,
406
409
  contextSchema,
407
410
  statusChangeSchema,
package/src/Roles.res ADDED
@@ -0,0 +1,3 @@
1
+ // The roles the platform's own components require. `Admin` is `Reventless.Role.admin`,
2
+ // joined by name like any plugin's role.
3
+ type t = Admin
@@ -0,0 +1,2 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+ /* This output is empty. Its source's type definitions, externals and/or unused code got optimized away. */
@@ -0,0 +1,12 @@
1
+ // The administrator group as the host shell reads it from `config.json`: the
2
+ // group the platform gates its admin API on, from the same role-to-group
3
+ // resolution as the server, so the two cannot disagree. Written only where the
4
+ // deployment maps the administrator role away from its own name; the shell
5
+ // reads an absent key as `Admin`, so a default deployment's file is unchanged.
6
+
7
+ let configKey = "adminGroup"
8
+
9
+ let configEntry = (): option<(string, JSON.t)> => {
10
+ let group = Reventless.Role.adminGroup()
11
+ group == (Reventless.Role.admin :> string) ? None : Some((configKey, JSON.Encode.string(group)))
12
+ }
@@ -0,0 +1,23 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
4
+
5
+ let configKey = "adminGroup";
6
+
7
+ function configEntry() {
8
+ let group = Role$Reventless.adminGroup();
9
+ if (group === Role$Reventless.admin) {
10
+ return;
11
+ } else {
12
+ return [
13
+ configKey,
14
+ group
15
+ ];
16
+ }
17
+ }
18
+
19
+ export {
20
+ configKey,
21
+ configEntry,
22
+ }
23
+ /* Role-Reventless Not a pure module */
@@ -37,7 +37,8 @@ let pluginCommands: array<commandDef> = Plugin_Structure.extractCommandDefs(
37
37
  ~isAggregate=true,
38
38
  ~mutationFieldFor=variantName =>
39
39
  Api_Naming.adminField(~name=PluginSpec.name ++ "_" ++ variantName),
40
- ~commandAuthorization=PluginSpec.commandAuthorization->Obj.magic,
40
+ ~commandAuthorization=command =>
41
+ PluginSpec.commandAuthorization(command->Obj.magic)->Reventless.Authorization.named,
41
42
  ~commandTransition=PluginSpec.commandTransition->Obj.magic,
42
43
  commandSchema,
43
44
  )
@@ -57,16 +58,19 @@ let pluginAggregate: writableDef = {
57
58
  chapter: None,
58
59
  }
59
60
 
60
- let pluginReadModel: queryableDef = Plugin_Structure.queryableDefFromSpec(
61
- ~plugin=pluginId,
62
- ~name=PluginsReadModelSpec.name,
63
- ~stateSchema=PluginsReadModelSpec.stateSchema->S.castToUnknown,
64
- ~authorization=PluginsReadModelSpec.authorization,
65
- ~linkedWriteSide=["Plugin"],
66
- )
61
+ // Functions, not values: the view's access keys are the group the platform maps
62
+ // the administrator role to, and this module is evaluated before a root maps it.
63
+ let pluginReadModel = (): queryableDef =>
64
+ Plugin_Structure.queryableDefFromSpec(
65
+ ~plugin=pluginId,
66
+ ~name=PluginsReadModelSpec.name,
67
+ ~stateSchema=PluginsReadModelSpec.stateSchema->S.castToUnknown,
68
+ ~authorization=PluginsReadModelSpec.authorization->Reventless.Authorization.named,
69
+ ~linkedWriteSide=["Plugin"],
70
+ )
67
71
 
68
- let structure: pluginStructure = {
69
- readModels: [pluginReadModel],
72
+ let structure = (): pluginStructure => {
73
+ readModels: [pluginReadModel()],
70
74
  stateViewSlices: [],
71
75
  stateChangeSlices: [],
72
76
  aggregates: [pluginAggregate],
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
3
4
  import * as Api_Naming$ReventlessCore from "../components/Api/Api_Naming.res.mjs";
4
5
  import * as PluginSpec$ReventlessCore from "../plugin/lifecycle/PluginSpec.res.mjs";
5
6
  import * as Plugin_Structure$ReventlessCore from "../plugin/component/Plugin_Structure.res.mjs";
@@ -7,7 +8,7 @@ import * as PluginsReadModelSpec$ReventlessCore from "../plugin/lifecycle/Plugin
7
8
 
8
9
  let pluginId = "Platform";
9
10
 
10
- let pluginCommands = Plugin_Structure$ReventlessCore.extractCommandDefs(undefined, true, undefined, variantName => Api_Naming$ReventlessCore.adminField(PluginSpec$ReventlessCore.name + "_" + variantName), PluginSpec$ReventlessCore.commandAuthorization, PluginSpec$ReventlessCore.commandTransition, undefined, PluginSpec$ReventlessCore.commandSchema);
11
+ let pluginCommands = Plugin_Structure$ReventlessCore.extractCommandDefs(undefined, true, undefined, variantName => Api_Naming$ReventlessCore.adminField(PluginSpec$ReventlessCore.name + "_" + variantName), command => Authorization$Reventless.named(PluginSpec$ReventlessCore.commandAuthorization(command)), PluginSpec$ReventlessCore.commandTransition, undefined, PluginSpec$ReventlessCore.commandSchema);
11
12
 
12
13
  let pluginAggregate_producedEventTypes = [];
13
14
 
@@ -31,39 +32,27 @@ let pluginAggregate = {
31
32
  chapter: undefined
32
33
  };
33
34
 
34
- let pluginReadModel = Plugin_Structure$ReventlessCore.queryableDefFromSpec(pluginId, PluginsReadModelSpec$ReventlessCore.name, PluginsReadModelSpec$ReventlessCore.stateSchema, PluginsReadModelSpec$ReventlessCore.authorization, undefined, undefined, ["Plugin"], undefined);
35
-
36
- let structure_readModels = [pluginReadModel];
37
-
38
- let structure_stateViewSlices = [];
39
-
40
- let structure_stateChangeSlices = [];
41
-
42
- let structure_aggregates = [pluginAggregate];
43
-
44
- let structure_automationSlices = [];
45
-
46
- let structure_outboundTranslationSlices = [];
47
-
48
- let structure_inboundTranslationSlices = [];
49
-
50
- let structure_extensions = [];
35
+ function pluginReadModel() {
36
+ return Plugin_Structure$ReventlessCore.queryableDefFromSpec(pluginId, PluginsReadModelSpec$ReventlessCore.name, PluginsReadModelSpec$ReventlessCore.stateSchema, Authorization$Reventless.named(PluginsReadModelSpec$ReventlessCore.authorization), undefined, undefined, ["Plugin"], undefined);
37
+ }
51
38
 
52
- let structure = {
53
- readModels: structure_readModels,
54
- stateViewSlices: structure_stateViewSlices,
55
- stateChangeSlices: structure_stateChangeSlices,
56
- aggregates: structure_aggregates,
57
- automationSlices: structure_automationSlices,
58
- outboundTranslationSlices: structure_outboundTranslationSlices,
59
- inboundTranslationSlices: structure_inboundTranslationSlices,
60
- extensions: structure_extensions,
61
- extensionPoints: undefined,
62
- requiredStores: undefined,
63
- requiredStoreDeclarations: undefined,
64
- requiredCapabilities: undefined,
65
- traitDeclarations: undefined
66
- };
39
+ function structure() {
40
+ return {
41
+ readModels: [pluginReadModel()],
42
+ stateViewSlices: [],
43
+ stateChangeSlices: [],
44
+ aggregates: [pluginAggregate],
45
+ automationSlices: [],
46
+ outboundTranslationSlices: [],
47
+ inboundTranslationSlices: [],
48
+ extensions: [],
49
+ extensionPoints: undefined,
50
+ requiredStores: undefined,
51
+ requiredStoreDeclarations: undefined,
52
+ requiredCapabilities: undefined,
53
+ traitDeclarations: undefined
54
+ };
55
+ }
67
56
 
68
57
  let internalPluginDefinition = {
69
58
  id: pluginId + "@INTERNAL",
@@ -57,10 +57,29 @@ module Make = (
57
57
  let updateMeta = (command': Message.command'<'id, 'command>) =>
58
58
  Message.deriveMeta(~parent=command'.meta)
59
59
 
60
+ // The events whose `@owner` field names who owns this aggregate. Empty for an
61
+ // aggregate that marks none, and then nothing is enforced.
62
+ let ownerFieldsByEventType = Reventless.Owner.fieldNamesByVariant(
63
+ Spec.eventSchema->S.castToUnknown,
64
+ )
65
+ let actsOnOwned = ownerFieldsByEventType->Dict.keysToArray->Array.length > 0
66
+
67
+ let recordEventOwners = (owners, event: Spec.event) =>
68
+ if actsOnOwned {
69
+ let (eventType, data) = event->Message.encode(Spec.eventSchema)->Message.splitMessage
70
+ switch ownerFieldsByEventType->Dict.get(eventType) {
71
+ | Some(fields) => owners->Reventless.OwnerScope.recordOwners(~fields, data)
72
+ | None => owners
73
+ }
74
+ } else {
75
+ owners
76
+ }
77
+
60
78
  // Per-command outcome carried through the fold, paired with the originating reference and meta.
61
79
  type cmdOutcome =
62
80
  | CmdOk(array<Spec.event>)
63
81
  | CmdRejected({errorCode: string, errorDetail: string})
82
+ | CmdRefused(CommandTopic_Helpers.ownershipRefusal)
64
83
 
65
84
  // Folds a single command into the running accumulator: (currentState, perCommandOutcomes).
66
85
  // A domain rejection from Behavior.decide is recorded as `CmdRejected` (no state change,
@@ -69,9 +88,16 @@ module Make = (
69
88
  //
70
89
  // `~seq` is the replayed sequence number the batch decides from, carried in so
71
90
  // the line below can name the state rather than serialise it.
91
+ //
92
+ // `owners` are the distinct owners the stream records so far, including any a
93
+ // command earlier in the same batch just recorded.
72
94
  let processCommand = (~seq: int) =>
73
95
  (
74
- (state, outcomes): (Behavior.state, array<(string, cmdOutcome, Message.meta)>),
96
+ (state, owners, outcomes): (
97
+ Behavior.state,
98
+ array<string>,
99
+ array<(string, cmdOutcome, Message.meta)>,
100
+ ),
75
101
  topicItem: CommandTopic.topicItem<Message.command'<Spec.Id.t, Spec.command>>,
76
102
  ) => {
77
103
  let {reference, command: command'} = topicItem
@@ -81,33 +107,57 @@ module Make = (
81
107
  // history, so serialising it here makes the line's size a function of how
82
108
  // long the system has been in use. The state at decision time is a
83
109
  // debugger's subject, not a log's.
84
- let cmdName =
85
- command'.command
86
- ->Message.encode(Spec.commandSchema)
87
- ->Message.variantNameOfJson
88
- ->LogFormat.bold
110
+ let cmdVariant =
111
+ command'.command->Message.encode(Spec.commandSchema)->Message.variantNameOfJson
112
+ let cmdName = cmdVariant->LogFormat.bold
89
113
  EffectLogger.logDebug(
90
114
  ~comp,
91
115
  `deciding: id=${id} seq=${seq->Int.toString} cmd=${cmdName}`,
92
116
  )->Effect.runSync
93
- switch Behavior.decide(state, command'.command) {
94
- | Ok(generatedEvents) =>
95
- let newState = generatedEvents->Array.reduce(state, Behavior.evolve)
96
- (newState, Array.concat(outcomes, [(reference, CmdOk(generatedEvents), meta)]))
97
- | Error(error) =>
98
- let errorJson = error->Message.encode(Spec.errorSchema)
99
- let errorCode = errorJson->Message.variantNameOfJson
100
- // Strip the TAG so detail carries only the rejection payload (empty for unit errors).
101
- let (_, payloadDict) = errorJson->Message.splitMessage
102
- let errorDetail =
103
- payloadDict->Dict.toArray->Array.length == 0
104
- ? ""
105
- : payloadDict->JSON.Encode.object->JSON.stringify
106
- EffectLogger.logError(
107
- ~comp,
108
- `decide rejected: ${errorCode} ${errorDetail} id=${id}`,
117
+ // Before `decide`: an owner-marked aggregate refuses a caller who does not
118
+ // own the stream.
119
+ let refusal = actsOnOwned
120
+ ? Reventless.OwnerScope.decideActing(
121
+ ~claim=command'.meta.callerClaim,
122
+ ~recordedOwners=Some(owners),
123
+ )->CommandTopic_Helpers.ownershipRefusal(~component=Spec.name, ~command=cmdVariant)
124
+ : None
125
+ switch refusal {
126
+ | Some(refused) =>
127
+ let line = `refused: ${refused.rejected.errorDetail} id=${id}`
128
+
129
+ (
130
+ refused.isDefect ? EffectLogger.logError(~comp, line) : EffectLogger.logWarn(~comp, line)
109
131
  )->Effect.runSync
110
- (state, Array.concat(outcomes, [(reference, CmdRejected({errorCode, errorDetail}), meta)]))
132
+ (state, owners, Array.concat(outcomes, [(reference, CmdRefused(refused), meta)]))
133
+ | None =>
134
+ switch Behavior.decide(state, command'.command) {
135
+ | Ok(generatedEvents) =>
136
+ let newState = generatedEvents->Array.reduce(state, Behavior.evolve)
137
+ (
138
+ newState,
139
+ generatedEvents->Array.reduce(owners, recordEventOwners),
140
+ Array.concat(outcomes, [(reference, CmdOk(generatedEvents), meta)]),
141
+ )
142
+ | Error(error) =>
143
+ let errorJson = error->Message.encode(Spec.errorSchema)
144
+ let errorCode = errorJson->Message.variantNameOfJson
145
+ // Strip the TAG so detail carries only the rejection payload (empty for unit errors).
146
+ let (_, payloadDict) = errorJson->Message.splitMessage
147
+ let errorDetail =
148
+ payloadDict->Dict.toArray->Array.length == 0
149
+ ? ""
150
+ : payloadDict->JSON.Encode.object->JSON.stringify
151
+ EffectLogger.logError(
152
+ ~comp,
153
+ `decide rejected: ${errorCode} ${errorDetail} id=${id}`,
154
+ )->Effect.runSync
155
+ (
156
+ state,
157
+ owners,
158
+ Array.concat(outcomes, [(reference, CmdRejected({errorCode, errorDetail}), meta)]),
159
+ )
160
+ }
111
161
  }
112
162
  }
113
163
 
@@ -115,8 +165,8 @@ module Make = (
115
165
 
116
166
  // In-process replay cache (per warm Lambda instance, per aggregate — the
117
167
  // functor instance owns the cache, so the event log is implicit and the key
118
- // is just the aggregate id). Holds the `(state, sequenceNr)` the previous
119
- // command batch for that id left behind.
168
+ // is just the aggregate id). Holds the `(state, sequenceNr, owners)` the
169
+ // previous command batch for that id left behind.
120
170
  //
121
171
  // On a hit, `replayProcessAppend` skips the event-log replay entirely and
122
172
  // decides on the cached state; after a successful append it stores the
@@ -129,7 +179,9 @@ module Make = (
129
179
  // the fixed capacity (a per-aggregate knob is a future refinement — see
130
180
  // docs/plans/done/aggregate-snapshotting.md).
131
181
  let replayCacheCapacity = 100
132
- let replayCache: Lru.t<string, (Behavior.state, int)> = Lru.make(~capacity=replayCacheCapacity)
182
+ let replayCache: Lru.t<string, (Behavior.state, int, array<string>)> = Lru.make(
183
+ ~capacity=replayCacheCapacity,
184
+ )
133
185
 
134
186
  let resetCache = () => replayCache->Lru.clear
135
187
 
@@ -139,7 +191,17 @@ module Make = (
139
191
  // one every `interval` events. Snapshots are a read optimization only — the
140
192
  // OCC append remains the sole consistency primitive, so a missing, drifted,
141
193
  // or corrupt snapshot degrades silently to full replay.
142
- let snapshotConfig = Behavior.snapshot
194
+ //
195
+ // Off for an owner-marked aggregate: a snapshot holds the state alone, so a
196
+ // replay seeded from one would never see the event that recorded the owner,
197
+ // and the ownership check would pass every caller.
198
+ let snapshotConfig = actsOnOwned ? None : Behavior.snapshot
199
+ if actsOnOwned && Behavior.snapshot->Option.isSome {
200
+ EffectLogger.logWarn(
201
+ ~comp,
202
+ "snapshots disabled: this aggregate records an @owner, which a snapshot does not carry",
203
+ )->Effect.runSync
204
+ }
143
205
 
144
206
  // Structural hash of the state schema, computed once. Stored on every written
145
207
  // snapshot and compared on read: a snapshot whose hash differs from the
@@ -183,8 +245,8 @@ module Make = (
183
245
 
184
246
  // Cold read (in-process cache miss): when snapshots are enabled, seed from the
185
247
  // latest persisted snapshot (hash-gated) and replay only the events after it;
186
- // otherwise replay full history from seq 0. Returns `(state, sequenceNr)` where
187
- // sequenceNr is the total event count (the OCC condition for the next append).
248
+ // otherwise replay full history from seq 0. Returns `(state, sequenceNr, owners)`
249
+ // where sequenceNr is the total event count (the OCC condition for the next append).
188
250
  let coldReadState = id => {
189
251
  let idStr = id->Spec.Id.toString
190
252
  let seedEffect = switch snapshotConfig {
@@ -220,8 +282,12 @@ module Make = (
220
282
  }
221
283
  seedEffect->Effect.flatMap(((seedState, seedSeq)) =>
222
284
  Ops.eventLog.replayStream(id, ~fromSeq=seedSeq)
223
- ->Stream.runFold((seedState, seedSeq), ((st, n), ev) => (Behavior.evolve(st, ev), n + 1))
224
- ->Effect.tap(((_, n)) => {
285
+ ->Stream.runFold((seedState, seedSeq, []), ((st, n, owners), ev) => (
286
+ Behavior.evolve(st, ev),
287
+ n + 1,
288
+ owners->recordEventOwners(ev),
289
+ ))
290
+ ->Effect.tap(((_, n, _)) => {
225
291
  let detail =
226
292
  seedSeq > 0
227
293
  ? `(snapshot@${seedSeq->Int.toString}, ${(n - seedSeq)->Int.toString} delta event(s))`
@@ -305,6 +371,14 @@ module Make = (
305
371
  {errorCode, errorDetail},
306
372
  )
307
373
  Ok(reference)
374
+ | CmdRefused({rejected}) =>
375
+ CommandTopic_Helpers.reportRejected(
376
+ ~component=Spec.name,
377
+ ~cause=AccessRefusal,
378
+ reference,
379
+ rejected,
380
+ )
381
+ Ok(reference)
308
382
  | CmdOk(_) if appendSucceeded =>
309
383
  CommandTopic_Helpers.reportAccepted(
310
384
  ~component=Spec.name,
@@ -339,24 +413,27 @@ module Make = (
339
413
  // path (`coldReadState`): seed from the persisted snapshot when enabled, else
340
414
  // full replay.
341
415
  let readState = switch replayCache->Lru.get(idStr) {
342
- | Some((state, seqNr)) =>
416
+ | Some((state, seqNr, owners)) =>
343
417
  EffectLogger.logInfo(
344
418
  ~comp,
345
419
  `replay skipped (cached): id=${idStr}, seq=${seqNr->Int.toString}`,
346
- )->Effect.map(_ => (state, seqNr))
420
+ )->Effect.map(_ => (state, seqNr, owners))
347
421
  | None => coldReadState(id)
348
422
  }
349
423
 
350
- readState->Effect.flatMap(((initialState, sequenceNr)) => {
351
- let (finalState, outcomes) =
352
- topicItemsForId->Array.reduce((initialState, []), processCommand(~seq=sequenceNr))
424
+ readState->Effect.flatMap(((initialState, sequenceNr, initialOwners)) => {
425
+ let (finalState, finalOwners, outcomes) =
426
+ topicItemsForId->Array.reduce(
427
+ (initialState, initialOwners, []),
428
+ processCommand(~seq=sequenceNr),
429
+ )
353
430
 
354
431
  let eventsToAppend =
355
432
  outcomes
356
433
  ->Array.map(((_reference, outcome, meta)) =>
357
434
  switch outcome {
358
435
  | CmdOk(events) => events->Array.map(event => {Message.id, meta, event})
359
- | CmdRejected(_) => []
436
+ | CmdRejected(_) | CmdRefused(_) => []
360
437
  }
361
438
  )
362
439
  ->Array.flat
@@ -365,7 +442,7 @@ module Make = (
365
442
  | [] =>
366
443
  // Nothing appended, but the read snapshot is valid — keep it warm for
367
444
  // the next command (also refreshes recency on a cache hit).
368
- replayCache->Lru.put(idStr, (initialState, sequenceNr))
445
+ replayCache->Lru.put(idStr, (initialState, sequenceNr, initialOwners))
369
446
  let perRef = reportFinalOutcomes(
370
447
  outcomes,
371
448
  ~entityId=idStr,
@@ -392,7 +469,7 @@ module Make = (
392
469
  // The post-decide fold state IS the post-append replay result;
393
470
  // cache it so the next command for this id skips the replay.
394
471
  let newSeq = sequenceNr + generatedEvents'->Array.length
395
- replayCache->Lru.put(idStr, (finalState, newSeq))
472
+ replayCache->Lru.put(idStr, (finalState, newSeq, finalOwners))
396
473
  // Persist a snapshot if this append crossed an interval boundary
397
474
  // (fire-and-forget — never blocks or fails the command).
398
475
  maybeWriteSnapshot(id, idStr, ~oldSeq=sequenceNr, ~newSeq, finalState)