@reventlessdev/reventless-core 3.0.0-alpha.283 → 3.0.0-alpha.285

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,22 @@
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.285 (2026-10-01)
7
+
8
+ ### Features
9
+
10
+ * **spec, aws, core, local:** an app declares its environments, and every deployment records its commit ([c7cc925](https://github.com/ReventlessDev/reventless-core/commit/c7cc92573e97742b681a9000cea0bdfd6e9a33fc))
11
+ * **spec, core, gwt, ppx:** a command acts only on what its caller owns ([2636f0e](https://github.com/ReventlessDev/reventless-core/commit/2636f0ee44797ee2c0b0c40a7107cf306113173f))
12
+
13
+
14
+ # 3.0.0-alpha.284 (2026-09-29)
15
+
16
+ **Note:** Version bump only for package @reventlessdev/reventless-core
17
+
18
+
19
+
20
+
21
+
6
22
  # 3.0.0-alpha.283 (2026-09-28)
7
23
 
8
24
  **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.283",
3
+ "version": "3.0.0-alpha.285",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,17 +28,17 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "0.1.0-alpha.34",
31
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.20",
32
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
33
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.19",
33
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.34",
34
+ "@reventlessdev/rescript-node": "2.0.0-alpha.20",
34
35
  "@reventlessdev/rescript-jest": "1.0.0-alpha.11",
35
- "@reventlessdev/rescript-node": "2.0.0-alpha.19",
36
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.20",
37
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.22",
37
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.21",
38
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.181",
39
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.155",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.38",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.153"
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.183"
42
42
  },
43
43
  "devDependencies": {
44
44
  "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,
@@ -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)
@@ -8,9 +8,11 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
8
  import * as Effect from "effect/Effect";
9
9
  import * as Stream from "effect/Stream";
10
10
  import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
11
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
11
12
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
12
13
  import * as Lru$ReventlessCore from "../../util/Lru.res.mjs";
13
14
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
15
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
14
16
  import * as Message$ReventlessCore from "../../Message.res.mjs";
15
17
  import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
16
18
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
@@ -29,21 +31,55 @@ function Make(Spec) {
29
31
  Spec.Id.makeFromString(param[0]),
30
32
  param[1]
31
33
  ]));
34
+ let ownerFieldsByEventType = Owner$Reventless.fieldNamesByVariant(Spec.eventSchema);
35
+ let actsOnOwned = Object.keys(ownerFieldsByEventType).length !== 0;
36
+ let recordEventOwners = (owners, event) => {
37
+ if (!actsOnOwned) {
38
+ return owners;
39
+ }
40
+ let match = Message$ReventlessCore.splitMessage(Message$ReventlessCore.encode(event, Spec.eventSchema));
41
+ let fields = ownerFieldsByEventType[match[0]];
42
+ if (fields !== undefined) {
43
+ return OwnerScope$Reventless.recordOwners(owners, fields, match[1]);
44
+ } else {
45
+ return owners;
46
+ }
47
+ };
32
48
  let processCommand = seq => ((param, topicItem) => {
33
49
  let reference = topicItem.reference;
34
50
  let command$p = topicItem.command;
35
- let outcomes = param[1];
51
+ let outcomes = param[2];
52
+ let owners = param[1];
36
53
  let state = param[0];
37
54
  let meta = Message$ReventlessCore.deriveMeta(command$p.meta, undefined);
38
55
  let id = Spec.Id.toString(command$p.id);
39
- let cmdName = LogFormat$ReventlessCore.bold(Message$ReventlessCore.variantNameOfJson(Message$ReventlessCore.encode(command$p.command, Spec.commandSchema)));
56
+ let cmdVariant = Message$ReventlessCore.variantNameOfJson(Message$ReventlessCore.encode(command$p.command, Spec.commandSchema));
57
+ let cmdName = LogFormat$ReventlessCore.bold(cmdVariant);
40
58
  Effect.runSync(EffectLogger$ReventlessCore.logDebug(comp, undefined, `deciding: id=` + id + ` seq=` + seq.toString() + ` cmd=` + cmdName));
59
+ let refusal = actsOnOwned ? CommandTopic_Helpers$ReventlessCore.ownershipRefusal(OwnerScope$Reventless.decideActing(command$p.meta.callerClaim, owners), Spec.name, cmdVariant) : undefined;
60
+ if (refusal !== undefined) {
61
+ let line = `refused: ` + refusal.rejected.errorDetail + ` id=` + id;
62
+ Effect.runSync(refusal.isDefect ? EffectLogger$ReventlessCore.logError(comp, undefined, line) : EffectLogger$ReventlessCore.logWarn(comp, undefined, line));
63
+ return [
64
+ state,
65
+ owners,
66
+ outcomes.concat([[
67
+ reference,
68
+ {
69
+ TAG: "CmdRefused",
70
+ _0: refusal
71
+ },
72
+ meta
73
+ ]])
74
+ ];
75
+ }
41
76
  let generatedEvents = Behavior.decide(state, command$p.command);
42
77
  if (generatedEvents.TAG === "Ok") {
43
78
  let generatedEvents$1 = generatedEvents._0;
44
79
  let newState = Stdlib_Array.reduce(generatedEvents$1, state, Behavior.evolve);
45
80
  return [
46
81
  newState,
82
+ Stdlib_Array.reduce(generatedEvents$1, owners, recordEventOwners),
47
83
  outcomes.concat([[
48
84
  reference,
49
85
  {
@@ -62,6 +98,7 @@ function Make(Spec) {
62
98
  Effect.runSync(EffectLogger$ReventlessCore.logError(comp, undefined, `decide rejected: ` + errorCode + ` ` + errorDetail + ` id=` + id));
63
99
  return [
64
100
  state,
101
+ owners,
65
102
  outcomes.concat([[
66
103
  reference,
67
104
  {
@@ -75,7 +112,10 @@ function Make(Spec) {
75
112
  });
76
113
  let replayCache = Lru$ReventlessCore.make(100);
77
114
  let resetCache = () => Lru$ReventlessCore.clear(replayCache);
78
- let snapshotConfig = Behavior.snapshot;
115
+ let snapshotConfig = actsOnOwned ? undefined : Behavior.snapshot;
116
+ if (actsOnOwned && Stdlib_Option.isSome(Behavior.snapshot)) {
117
+ Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, "snapshots disabled: this aggregate records an @owner, which a snapshot does not carry"));
118
+ }
79
119
  let stateSchemaHash = snapshotConfig !== undefined ? HashObject(Object.fromEntries([[
80
120
  "state",
81
121
  SchemaWalker$ReventlessCore.describeSchema(snapshotConfig.stateSchema)
@@ -151,10 +191,12 @@ function Make(Spec) {
151
191
  let seedSeq = param[1];
152
192
  return Effect.tap(Stream.runFold(Ops.eventLog.replayStream(id, seedSeq), [
153
193
  param[0],
154
- seedSeq
194
+ seedSeq,
195
+ []
155
196
  ], (param, ev) => [
156
197
  Behavior.evolve(param[0], ev),
157
- param[1] + 1 | 0
198
+ param[1] + 1 | 0,
199
+ recordEventOwners(param[2], ev)
158
200
  ]), param => {
159
201
  let n = param[1];
160
202
  let detail = seedSeq > 0 ? `(snapshot@` + seedSeq.toString() + `, ` + (n - seedSeq | 0).toString() + ` delta event(s))` : n.toString() + ` event(s)`;
@@ -199,35 +241,43 @@ function Make(Spec) {
199
241
  return outcomes.map(param => {
200
242
  let outcome = param[1];
201
243
  let reference = param[0];
202
- if (outcome.TAG === "CmdOk") {
203
- if (appendSucceeded) {
204
- CommandTopic_Helpers$ReventlessCore.reportAccepted(Spec.name, reference, {
205
- entityId: entityId,
206
- eventCount: appendedEventCount
244
+ switch (outcome.TAG) {
245
+ case "CmdOk" :
246
+ if (appendSucceeded) {
247
+ CommandTopic_Helpers$ReventlessCore.reportAccepted(Spec.name, reference, {
248
+ entityId: entityId,
249
+ eventCount: appendedEventCount
250
+ });
251
+ return {
252
+ TAG: "Ok",
253
+ _0: reference
254
+ };
255
+ } else {
256
+ CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "InfrastructureFailure", reference, {
257
+ errorCode: "AppendFailed",
258
+ errorDetail: appendErrorDetail
259
+ });
260
+ return {
261
+ TAG: "Error",
262
+ _0: reference
263
+ };
264
+ }
265
+ case "CmdRejected" :
266
+ CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "DomainRejection", reference, {
267
+ errorCode: outcome.errorCode,
268
+ errorDetail: outcome.errorDetail
207
269
  });
208
270
  return {
209
271
  TAG: "Ok",
210
272
  _0: reference
211
273
  };
212
- } else {
213
- CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "InfrastructureFailure", reference, {
214
- errorCode: "AppendFailed",
215
- errorDetail: appendErrorDetail
216
- });
274
+ case "CmdRefused" :
275
+ CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "AccessRefusal", reference, outcome._0.rejected);
217
276
  return {
218
- TAG: "Error",
277
+ TAG: "Ok",
219
278
  _0: reference
220
279
  };
221
- }
222
280
  }
223
- CommandTopic_Helpers$ReventlessCore.reportRejected(Spec.name, "DomainRejection", reference, {
224
- errorCode: outcome.errorCode,
225
- errorDetail: outcome.errorDetail
226
- });
227
- return {
228
- TAG: "Ok",
229
- _0: reference
230
- };
231
281
  });
232
282
  };
233
283
  let replayProcessAppend = (id, topicItemsForId) => {
@@ -235,35 +285,42 @@ function Make(Spec) {
235
285
  let match = Lru$ReventlessCore.get(replayCache, idStr);
236
286
  let readState;
237
287
  if (match !== undefined) {
288
+ let owners = match[2];
238
289
  let seqNr = match[1];
239
290
  let state = match[0];
240
291
  readState = Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay skipped (cached): id=` + idStr + `, seq=` + seqNr.toString()), () => [
241
292
  state,
242
- seqNr
293
+ seqNr,
294
+ owners
243
295
  ]);
244
296
  } else {
245
297
  readState = coldReadState(id);
246
298
  }
247
299
  return Effect.flatMap(readState, param => {
300
+ let initialOwners = param[2];
248
301
  let sequenceNr = param[1];
249
302
  let initialState = param[0];
250
303
  let match = Stdlib_Array.reduce(topicItemsForId, [
251
304
  initialState,
305
+ initialOwners,
252
306
  []
253
307
  ], processCommand(sequenceNr));
254
- let outcomes = match[1];
308
+ let outcomes = match[2];
309
+ let finalOwners = match[1];
255
310
  let finalState = match[0];
256
311
  let eventsToAppend = outcomes.map(param => {
257
312
  let meta = param[2];
258
313
  let outcome = param[1];
259
- if (outcome.TAG === "CmdOk") {
260
- return outcome._0.map(event => ({
261
- id: id,
262
- meta: meta,
263
- event: event
264
- }));
265
- } else {
266
- return [];
314
+ switch (outcome.TAG) {
315
+ case "CmdOk" :
316
+ return outcome._0.map(event => ({
317
+ id: id,
318
+ meta: meta,
319
+ event: event
320
+ }));
321
+ case "CmdRejected" :
322
+ case "CmdRefused" :
323
+ return [];
267
324
  }
268
325
  }).flat();
269
326
  if (eventsToAppend.length !== 0) {
@@ -280,7 +337,8 @@ function Make(Spec) {
280
337
  let newSeq = sequenceNr + eventsToAppend.length | 0;
281
338
  Lru$ReventlessCore.put(replayCache, idStr, [
282
339
  finalState,
283
- newSeq
340
+ newSeq,
341
+ finalOwners
284
342
  ]);
285
343
  maybeWriteSnapshot(id, idStr, sequenceNr, newSeq, finalState);
286
344
  let perRef = reportFinalOutcomes(outcomes, idStr, true, eventsToAppend.length, undefined);
@@ -308,7 +366,8 @@ function Make(Spec) {
308
366
  }
309
367
  Lru$ReventlessCore.put(replayCache, idStr, [
310
368
  initialState,
311
- sequenceNr
369
+ sequenceNr,
370
+ initialOwners
312
371
  ]);
313
372
  let perRef = reportFinalOutcomes(outcomes, idStr, true, 0, undefined);
314
373
  return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `no events produced: id=` + idStr), () => ({
@@ -55,13 +55,13 @@ let stampOwnerFields = (
55
55
  obj: dict<JSON.t>,
56
56
  ~commandSchema: S.t<unknown>,
57
57
  ~command: string,
58
- ~identity: Reventless.Identity.t,
58
+ ~scope: Reventless.OwnerScope.t,
59
59
  ~serviceName: string,
60
60
  ) =>
61
61
  switch Reventless.Owner.variantFieldNames(commandSchema, ~variant=command) {
62
62
  | [] => ()
63
63
  | ownerFields =>
64
- switch identity->Reventless.OwnerScope.resolve {
64
+ switch scope {
65
65
  | System | Elevated(_) => ()
66
66
  | Owned({userId}) =>
67
67
  ownerFields->Array.forEach(field => obj->Dict.set(field, JSON.Encode.string(userId)))
@@ -118,6 +118,11 @@ let makeGenerateCommand = (
118
118
  (payload: CommandGenerator.payload) =>
119
119
  Effect.sync(() => {
120
120
  let msgId = Message.uuid()
121
+ // One classification for the stamp below and for the claim the handler
122
+ // checks ownership against, so the two cannot disagree about the caller.
123
+ // The claim is built here and nowhere from the payload, which is all a
124
+ // client controls.
125
+ let scope = payload.identity->Reventless.OwnerScope.resolve
121
126
  let meta = {
122
127
  {
123
128
  Message.service: serviceName,
@@ -126,6 +131,7 @@ let makeGenerateCommand = (
126
131
  time: Message.nowAsISOString(),
127
132
  msgId,
128
133
  correlationId: msgId,
134
+ callerClaim: scope->Reventless.OwnerScope.toClaim,
129
135
  }
130
136
  }
131
137
  let params = switch payload.arguments
@@ -136,12 +142,7 @@ let makeGenerateCommand = (
136
142
  obj->Dict.delete("id")
137
143
  }
138
144
  obj->dropNullArguments
139
- obj->stampOwnerFields(
140
- ~commandSchema,
141
- ~command=payload.command,
142
- ~identity=payload.identity,
143
- ~serviceName,
144
- )
145
+ obj->stampOwnerFields(~commandSchema, ~command=payload.command, ~scope, ~serviceName)
145
146
  obj->Dict.toArray
146
147
  }
147
148
  | None =>