@reventlessdev/trait-notification 1.0.0-alpha.2 → 1.0.0-alpha.4

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.
@@ -24,6 +24,7 @@ function namesOf(c) {
24
24
  let n = c.noun;
25
25
  return {
26
26
  slice: n + "Preferences",
27
+ claims: "NotificationSourceClaims",
27
28
  send: "Send" + n + "Notification",
28
29
  relay: "Announce" + n + "Contact",
29
30
  intake: n + "NotificationIntake",
@@ -41,6 +42,12 @@ function namesOf(c) {
41
42
  requested: "NotificationRequested",
42
43
  suppressed: "NotificationSuppressed",
43
44
  undeliverable: "NotificationUndeliverable",
45
+ deferred: "NotificationDeferred",
46
+ claimed: "NotificationSourceClaimed",
47
+ released: "NotificationSourceReleased",
48
+ claimCmd: "ClaimNotificationSource",
49
+ releaseCmd: "ReleaseNotificationSource",
50
+ claimError: "ClaimRefused",
44
51
  delivered: "NotificationDelivered",
45
52
  failed: "NotificationFailed",
46
53
  unknownError: n + "Unknown",
@@ -60,6 +67,15 @@ function addressB(c) {
60
67
  return Stdlib_Option.getOr(c.addressB, "new@example.com");
61
68
  }
62
69
 
70
+ function subjectTypeOf(c) {
71
+ let stem = c.occurrenceId.endsWith("Id") ? c.occurrenceId.slice(0, c.occurrenceId.length - 2 | 0) : c.occurrenceId;
72
+ if (stem === "") {
73
+ return "Subject";
74
+ } else {
75
+ return stem.slice(0, 1).toUpperCase() + stem.slice(1, stem.length);
76
+ }
77
+ }
78
+
63
79
  function authorizeOn(c) {
64
80
  let a = c.authorize;
65
81
  if (a !== undefined) {
@@ -108,13 +124,24 @@ function sliceSpec(c) {
108
124
  ` | Sms`,
109
125
  ` | Push`,
110
126
  ``,
111
- `// Its own past facts, and nothing else which is what lets one component hold`,
112
- `// the directory and make the dispatch decision.`,
127
+ `// Why a request exists: the compiled table in the relay, or a rule somebody`,
128
+ `// configured on top of it. Recorded on the request that goes out, so "which`,
129
+ `// wording was in force when this was sent" has an answer.`,
130
+ `@schema`,
131
+ `type origin =`,
132
+ ` | Default`,
133
+ ` | Configured({ruleId: string, ruleVersion: string})`,
134
+ ``,
135
+ `// Its own past facts, plus the claim set from ` + n.claims + `. Claims are per`,
136
+ `// source and this slice is per recipient, so they come from another partition —`,
137
+ `// see \`` + n.requestCmd + `\` for what makes that read happen.`,
113
138
  `@schema`,
114
139
  `type consumedEvent =`,
115
140
  ` | ` + n.announced + `({` + id + `: string, ` + c.contactField + `: string})`,
116
141
  ` | ` + n.subscribed + `({` + id + `: string, category: category, channel: channel})`,
117
142
  ` | ` + n.unsubscribed + `({` + id + `: string, category: category, channel: channel})`,
143
+ ` | ` + n.claimed + `({sourceId: string, by: string})`,
144
+ ` | ` + n.released + `({sourceId: string})`,
118
145
  ``,
119
146
  `@schema`,
120
147
  `type command =`,
@@ -127,13 +154,32 @@ function sliceSpec(c) {
127
154
  auth + n.unsubscribeCmd + `({@owner ` + id + `: string, category: category, channel: channel})`,
128
155
  ` // Also relayed. \`reference\` is the requester's own key, echoed back on`,
129
156
  ` // whichever outcome follows, so the relay can tell its work is finished.`,
157
+ ` // \`subjectType\`/\`subjectRef\` say what the notification is ABOUT, which the`,
158
+ ` // reference deliberately does not: its format is the requester's own`,
159
+ ` // business, so a row keyed by it alone cannot be read back. Empty is legal.`,
160
+ ` // Named \`*Ref\` and not \`*Id\` on purpose: two inferences read id fields by`,
161
+ ` // NAME — the DCB partition and the queryable's key field — and a second`,
162
+ ` // \`*Id\` on the row makes both ambiguous, which costs the view its filter`,
163
+ ` // and its orderBy with no error anywhere.`,
130
164
  ` | @noApi`,
131
165
  ` ` + n.requestCmd + `({`,
132
166
  ` ` + id + `: string,`,
133
167
  ` category: category,`,
134
168
  ` reference: string,`,
169
+ ` subjectType: string,`,
170
+ ` subjectRef: string,`,
135
171
  ` subject: string,`,
136
172
  ` body: string,`,
173
+ ` // Which stream this came from, \`"<log>:<eventType>"\`, and who is asking.`,
174
+ ` // A \`Default\` request for a source another producer has claimed yields;`,
175
+ ` // a \`Configured\` one always goes through.`,
176
+ ` //`,
177
+ ` // \`sourceId\` is a DCB tag and has to be: the decision reads claim facts`,
178
+ ` // that live in another partition. Because this slice consumes \`sourceId\``,
179
+ ` // without producing it, the framework infers the cross-partition read from`,
180
+ ` // the slice graph. Renaming it off \`*Id\` would silently drop that.`,
181
+ ` sourceId: string,`,
182
+ ` origin: origin,`,
137
183
  ` })`,
138
184
  ` // Reported by the send slice once the provider has settled.`,
139
185
  ` | @noApi ` + n.recordCmd + `({` + id + `: string, reference: string, providerRef: string})`,
@@ -151,21 +197,51 @@ function sliceSpec(c) {
151
197
  ` | ` + n.subscribed + `({` + id + `: string, category: category, channel: channel})`,
152
198
  ` | ` + n.unsubscribed + `({` + id + `: string, category: category, channel: channel})`,
153
199
  ` // The addressed message. \`address\` is the snapshot delivery uses, which is`,
154
- ` // why it is on the fact rather than looked up again later.`,
200
+ ` // why it is on the fact rather than looked up again later — and why it stays`,
201
+ ` // here and off the delivery view: the record of where a message actually went`,
202
+ ` // belongs in the log an investigation reads, not in a general read model.`,
155
203
  ` | ` + n.requested + `({`,
156
204
  ` ` + id + `: string,`,
157
205
  ` category: category,`,
158
206
  ` reference: string,`,
159
207
  ` channel: channel,`,
160
208
  ` address: string,`,
209
+ ` subjectType: string,`,
210
+ ` subjectRef: string,`,
161
211
  ` subject: string,`,
162
212
  ` body: string,`,
213
+ ` // What was in force when this went out — a message should record why.`,
214
+ ` origin: origin,`,
163
215
  ` })`,
164
216
  ` // Two different ways to send nothing. A ` + c.noun.toLowerCase() + ` who declined is the`,
165
217
  ` // system working; one with no address for a channel they enabled is the system`,
166
218
  ` // falling short, and one fact for both hides every gap behind a preference.`,
167
- ` | ` + n.suppressed + `({` + id + `: string, category: category, reference: string})`,
168
- ` | ` + n.undeliverable + `({` + id + `: string, category: category, reference: string})`,
219
+ ` | ` + n.suppressed + `({`,
220
+ ` ` + id + `: string,`,
221
+ ` category: category,`,
222
+ ` reference: string,`,
223
+ ` subjectType: string,`,
224
+ ` subjectRef: string,`,
225
+ ` origin: origin,`,
226
+ ` })`,
227
+ ` | ` + n.undeliverable + `({`,
228
+ ` ` + id + `: string,`,
229
+ ` category: category,`,
230
+ ` reference: string,`,
231
+ ` subjectType: string,`,
232
+ ` subjectRef: string,`,
233
+ ` origin: origin,`,
234
+ ` })`,
235
+ ` // The third way of not sending: another producer owns this source. Its own`,
236
+ ` // fact for the same reason the two above are two — and load-bearing besides,`,
237
+ ` // because the relay resolves its TODO row on an outcome, and without this one`,
238
+ ` // a deferred request retries its whole budget and is abandoned silently.`,
239
+ ` //`,
240
+ ` // \`sourceKey\` and not \`sourceId\`, and this is the one place the two names`,
241
+ ` // differ: a produced \`sourceId\` here would give this slice two candidate`,
242
+ ` // partitions and stop \`sourceId\` counting as consumed-but-not-produced, which`,
243
+ ` // is what makes the claim read cross partitions at all.`,
244
+ ` | ` + n.deferred + `({` + id + `: string, reference: string, sourceKey: string})`,
169
245
  ` | ` + n.delivered + `({` + id + `: string, reference: string, providerRef: string})`,
170
246
  ` | ` + n.failed + `({` + id + `: string, reference: string, reason: string})`,
171
247
  ``
@@ -252,44 +328,86 @@ function sliceBehavior(c) {
252
328
  ` state->Rules.evolve(`,
253
329
  ` Unsubscribed({category: categoryKey(category), channel: channelKey(channel)}),`,
254
330
  ` )`,
331
+ ` // Read across partitions from ` + n.claims + ` — see the source field on`,
332
+ ` // \`` + n.requestCmd + `\`.`,
333
+ ` | ` + n.claimed + `({sourceId, by}) => state->Rules.evolve(Claimed({source: sourceId, by}))`,
334
+ ` | ` + n.released + `({sourceId}) => state->Rules.evolve(Released({source: sourceId}))`,
255
335
  ` }`,
256
336
  ``,
257
337
  `// The trait decides; this names what it decided in the host's vocabulary.`,
338
+ `//`,
339
+ `// \`option\` because two of the trait's facts belong to ` + n.claims + `: this`,
340
+ `// component reads them and never writes them, so it has no event to name them`,
341
+ `// with.`,
258
342
  `let named = (` + id + `, fact: Rules.fact) =>`,
259
343
  ` switch fact {`,
260
- ` | Announced({address}) => ` + n.announced + `({` + id + `: ` + id + `, ` + c.contactField + `: address})`,
344
+ ` | Claimed(_)`,
345
+ ` | Released(_) => None`,
346
+ ` | Deferred({reference, source}) =>`,
347
+ ` Some(` + n.deferred + `({` + id + `: ` + id + `, reference, sourceKey: source}))`,
348
+ ` | Announced({address}) =>`,
349
+ ` Some(` + n.announced + `({` + id + `: ` + id + `, ` + c.contactField + `: address}))`,
261
350
  ` | Subscribed({category, channel}) =>`,
262
- ` ` + n.subscribed + `({`,
263
- ` ` + id + `: ` + id + `,`,
264
- ` category: categoryOf(category),`,
265
- ` channel: channelOf(channel),`,
266
- ` })`,
351
+ ` Some(`,
352
+ ` ` + n.subscribed + `({`,
353
+ ` ` + id + `: ` + id + `,`,
354
+ ` category: categoryOf(category),`,
355
+ ` channel: channelOf(channel),`,
356
+ ` }),`,
357
+ ` )`,
267
358
  ` | Unsubscribed({category, channel}) =>`,
268
- ` ` + n.unsubscribed + `({`,
269
- ` ` + id + `: ` + id + `,`,
270
- ` category: categoryOf(category),`,
271
- ` channel: channelOf(channel),`,
272
- ` })`,
359
+ ` Some(`,
360
+ ` ` + n.unsubscribed + `({`,
361
+ ` ` + id + `: ` + id + `,`,
362
+ ` category: categoryOf(category),`,
363
+ ` channel: channelOf(channel),`,
364
+ ` }),`,
365
+ ` )`,
273
366
  ` | Requested({category, reference, channel, address}) =>`,
274
- ` ` + n.requested + `({`,
275
- ` ` + id + `: ` + id + `,`,
276
- ` category: categoryOf(category),`,
277
- ` reference,`,
278
- ` channel: channelOf(channel),`,
279
- ` address,`,
280
- ` // Carried on the command and put back below: the trait holds no sentence.`,
281
- ` subject: "",`,
282
- ` body: "",`,
283
- ` })`,
367
+ ` Some(`,
368
+ ` ` + n.requested + `({`,
369
+ ` ` + id + `: ` + id + `,`,
370
+ ` category: categoryOf(category),`,
371
+ ` reference,`,
372
+ ` channel: channelOf(channel),`,
373
+ ` address,`,
374
+ ` // Carried on the command and put back below: the trait holds no`,
375
+ ` // sentence, refuses to know what an occurrence is, and has no opinion`,
376
+ ` // about which rule asked.`,
377
+ ` subjectType: "",`,
378
+ ` subjectRef: "",`,
379
+ ` subject: "",`,
380
+ ` body: "",`,
381
+ ` origin: Default,`,
382
+ ` }),`,
383
+ ` )`,
284
384
  ` | Suppressed({category, reference}) =>`,
285
- ` ` + n.suppressed + `({` + id + `: ` + id + `, category: categoryOf(category), reference})`,
385
+ ` Some(`,
386
+ ` ` + n.suppressed + `({`,
387
+ ` ` + id + `: ` + id + `,`,
388
+ ` category: categoryOf(category),`,
389
+ ` reference,`,
390
+ ` subjectType: "",`,
391
+ ` subjectRef: "",`,
392
+ ` origin: Default,`,
393
+ ` }),`,
394
+ ` )`,
286
395
  ` | Undeliverable({category, reference}) =>`,
287
- ` ` + n.undeliverable + `({` + id + `: ` + id + `, category: categoryOf(category), reference})`,
396
+ ` Some(`,
397
+ ` ` + n.undeliverable + `({`,
398
+ ` ` + id + `: ` + id + `,`,
399
+ ` category: categoryOf(category),`,
400
+ ` reference,`,
401
+ ` subjectType: "",`,
402
+ ` subjectRef: "",`,
403
+ ` origin: Default,`,
404
+ ` }),`,
405
+ ` )`,
288
406
  ` }`,
289
407
  ``,
290
408
  `let through = (state, ` + id + `, op) =>`,
291
409
  ` switch state->Rules.decide(op, ~posture) {`,
292
- ` | Ok(facts) => Ok(facts->Array.map(named(` + id + `, _)))`,
410
+ ` | Ok(facts) => Ok(facts->Array.filterMap(named(` + id + `, _)))`,
293
411
  ` | Error(#RecipientUnknown) => Error(` + n.unknownError + `)`,
294
412
  ` }`,
295
413
  ``,
@@ -310,14 +428,44 @@ function sliceBehavior(c) {
310
428
  ` Unsubscribe({category: categoryKey(category), channel: channelKey(channel)}),`,
311
429
  ` )`,
312
430
  ``,
313
- ` // The one arm that is not a rename: the words belong to the requester, and the`,
314
- ` // trait's fact does not carry them, so they are put back on the way out.`,
315
- ` | ` + n.requestCmd + `({` + id + `, category, reference, subject, body}) =>`,
316
- ` through(state, ` + id + `, Request({category: categoryKey(category), reference}))`,
317
- ` ->Result.map(events =>`,
431
+ ` // The one arm that is not a rename: the words and the subject belong to the`,
432
+ ` // requester and the trait's facts carry neither, so both are put back on the`,
433
+ ` // way out. All three outcomes get the subject what a suppressed or`,
434
+ ` // undeliverable notification was about is what makes those rows worth reading.`,
435
+ ` | ` + n.requestCmd + `({`,
436
+ ` ` + id + `,`,
437
+ ` category,`,
438
+ ` reference,`,
439
+ ` subjectType,`,
440
+ ` subjectRef,`,
441
+ ` subject,`,
442
+ ` body,`,
443
+ ` sourceId,`,
444
+ ` origin,`,
445
+ ` }) =>`,
446
+ ` through(`,
447
+ ` state,`,
448
+ ` ` + id + `,`,
449
+ ` Request({`,
450
+ ` category: categoryKey(category),`,
451
+ ` reference,`,
452
+ ` source: sourceId,`,
453
+ ` // The trait needs only which of the two yields to a claim; which rule`,
454
+ ` // it was is this host's fact, put back below.`,
455
+ ` origin: switch origin {`,
456
+ ` | Default => Default`,
457
+ ` | Configured(_) => Configured`,
458
+ ` },`,
459
+ ` }),`,
460
+ ` )->Result.map(events =>`,
318
461
  ` events->Array.map(event =>`,
319
462
  ` switch event {`,
320
- ` | ` + n.requested + `(fields) => ` + n.requested + `({...fields, subject, body})`,
463
+ ` | ` + n.requested + `(fields) =>`,
464
+ ` ` + n.requested + `({...fields, subjectType, subjectRef, subject, body, origin})`,
465
+ ` | ` + n.suppressed + `(fields) =>`,
466
+ ` ` + n.suppressed + `({...fields, subjectType, subjectRef, origin})`,
467
+ ` | ` + n.undeliverable + `(fields) =>`,
468
+ ` ` + n.undeliverable + `({...fields, subjectType, subjectRef, origin})`,
321
469
  ` | other => other`,
322
470
  ` }`,
323
471
  ` )`,
@@ -334,6 +482,93 @@ function sliceBehavior(c) {
334
482
  ].flatMap(x => x).join("\n");
335
483
  }
336
484
 
485
+ function claimsSpec(c) {
486
+ let n = namesOf(c);
487
+ return [
488
+ `// ` + n.claims + ` StateChangeSlice: which streams of occurrences a second`,
489
+ `// producer has taken over. One row per source, keyed by the source itself —`,
490
+ `// \`"<log>:<eventType>"\`, opaque to everyone but the producers that agree on it.`,
491
+ `//`,
492
+ `// Emitted by the trait; ordinary source from here on.`,
493
+ ``,
494
+ `@@reventless.spec`,
495
+ ``,
496
+ `@schema`,
497
+ `type consumedEvent =`,
498
+ ` | ` + n.claimed + `({sourceId: string, by: string})`,
499
+ ` | ` + n.released + `({sourceId: string})`,
500
+ ``,
501
+ `@schema`,
502
+ `type command =`,
503
+ ` // Relayed, never a client door — a caller who could claim a source would be`,
504
+ ` // silencing everybody else's notifications from it.`,
505
+ ` | @noApi ` + n.claimCmd + `({sourceId: string, by: string})`,
506
+ ` | @noApi ` + n.releaseCmd + `({sourceId: string})`,
507
+ ``,
508
+ `@schema`,
509
+ `type error =`,
510
+ ` // Both commands are idempotent, so there is no refusal to make. Declared`,
511
+ ` // because the shape requires one.`,
512
+ ` | ` + n.claimError,
513
+ ``,
514
+ `@schema`,
515
+ `type event =`,
516
+ ` | ` + n.claimed + `({sourceId: string, by: string})`,
517
+ ` | ` + n.released + `({sourceId: string})`,
518
+ ``,
519
+ `let traits = [TraitNotification.Notification.declaration]`,
520
+ ``
521
+ ].join("\n");
522
+ }
523
+
524
+ function claimsBehavior(c) {
525
+ let n = namesOf(c);
526
+ return [
527
+ `@@reventless.behavior`,
528
+ ``,
529
+ `// The claim set is the trait's, folded through the same value the preferences`,
530
+ `// slice folds. Only the claim half is ever populated here.`,
531
+ `module Rules = TraitNotification.Notification_Rules`,
532
+ ``,
533
+ `type state = Rules.t`,
534
+ ``,
535
+ `let initialState = Rules.empty`,
536
+ ``,
537
+ `let evolve = (state, event: consumedEvent) =>`,
538
+ ` switch event {`,
539
+ ` | ` + n.claimed + `({sourceId, by}) => state->Rules.evolve(Claimed({source: sourceId, by}))`,
540
+ ` | ` + n.released + `({sourceId}) => state->Rules.evolve(Released({source: sourceId}))`,
541
+ ` }`,
542
+ ``,
543
+ `// No posture is consulted on either arm, so this table is never read. Passing`,
544
+ `// the trait's own decision function anyway keeps the idempotence rule in one`,
545
+ `// place rather than restating it here.`,
546
+ `let posture = (_category: string, _channel: Rules.channel) => false`,
547
+ ``,
548
+ `let named = (fact: Rules.fact) =>`,
549
+ ` switch fact {`,
550
+ ` | Claimed({source, by}) => Some(` + n.claimed + `({sourceId: source, by}))`,
551
+ ` | Released({source}) => Some(` + n.released + `({sourceId: source}))`,
552
+ ` // The other facts belong to the preferences slice; the two commands below`,
553
+ ` // cannot reach them.`,
554
+ ` | _ => None`,
555
+ ` }`,
556
+ ``,
557
+ `let through = (state, op) =>`,
558
+ ` switch state->Rules.decide(op, ~posture) {`,
559
+ ` | Ok(facts) => Ok(facts->Array.filterMap(named))`,
560
+ ` | Error(#RecipientUnknown) => Error(` + n.claimError + `)`,
561
+ ` }`,
562
+ ``,
563
+ `let decide = (state, command) =>`,
564
+ ` switch command {`,
565
+ ` | ` + n.claimCmd + `({sourceId, by}) => through(state, Claim({source: sourceId, by}))`,
566
+ ` | ` + n.releaseCmd + `({sourceId}) => through(state, Release({source: sourceId}))`,
567
+ ` }`,
568
+ ``
569
+ ].join("\n");
570
+ }
571
+
337
572
  function sendSpec(c) {
338
573
  let n = namesOf(c);
339
574
  let id = n.recipientId;
@@ -528,6 +763,11 @@ function conformanceBinding(c) {
528
763
  ` ` + n.subscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
529
764
  ` let unsubscribedC = (category, channel): Spec.consumedEvent =>`,
530
765
  ` ` + n.unsubscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
766
+ ` // Written by ` + n.claims + ` and read here across partitions — this slice`,
767
+ ` // consumes them and produces neither.`,
768
+ ` let claimedC = (source, by): Spec.consumedEvent =>`,
769
+ ` ` + n.claimed + `({sourceId: source, by})`,
770
+ ` let releasedC = (source): Spec.consumedEvent => ` + n.released + `({sourceId: source})`,
531
771
  ``,
532
772
  ` let announce = ` + c.contactField + ` =>`,
533
773
  ` Spec.` + n.announceCmd + `({` + id + `: ` + id + `, ` + c.contactField + `: ` + c.contactField + `})`,
@@ -535,37 +775,86 @@ function conformanceBinding(c) {
535
775
  ` Spec.` + n.subscribeCmd + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
536
776
  ` let unsubscribe = (category, channel) =>`,
537
777
  ` Spec.` + n.unsubscribeCmd + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
538
- ` // The wording is this host's and the trait carries none, so the suite supplies`,
539
- ` // whatever it likes and asserts nothing about it.`,
540
- ` let request = (category, reference) =>`,
778
+ ` // The wording and the subject are this host's and the trait carries neither, so`,
779
+ ` // the suite supplies whatever it likes and asserts nothing about them.`,
780
+ ` //`,
781
+ ` // One source nothing ever claims, so every assertion that predates the handover`,
782
+ ` // keeps deciding exactly as it did.`,
783
+ ` let defaultSource = "TODO(graft)DcbEventLog:` + c.occurrence + `"`,
784
+ ``,
785
+ ` let requestFrom = (category, reference, ~source, ~origin) =>`,
541
786
  ` Spec.` + n.requestCmd + `({`,
542
787
  ` ` + id + `: ` + id + `,`,
543
788
  ` category,`,
544
789
  ` reference,`,
790
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
791
+ ` subjectRef: "subject-1",`,
545
792
  ` subject: "subject",`,
546
793
  ` body: "body",`,
794
+ ` sourceId: source,`,
795
+ ` origin: switch (origin: TraitNotification.Notification_Rules.origin) {`,
796
+ ` | Default => Default`,
797
+ ` | Configured => Configured({ruleId: "rule-1", ruleVersion: "1"})`,
798
+ ` },`,
547
799
  ` })`,
548
800
  ``,
801
+ ` let request = (category, reference) =>`,
802
+ ` requestFrom(category, reference, ~source=defaultSource, ~origin=Default)`,
803
+ ``,
549
804
  ` let announced = ` + c.contactField + ` =>`,
550
805
  ` Spec.` + n.announced + `({` + id + `: ` + id + `, ` + c.contactField + `: ` + c.contactField + `})`,
551
806
  ` let subscribed = (category, channel) =>`,
552
807
  ` Spec.` + n.subscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
553
808
  ` let unsubscribed = (category, channel) =>`,
554
809
  ` Spec.` + n.unsubscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
555
- ` let requested = (category, reference, channel, address) =>`,
810
+ ` let requestedWith = (category, reference, channel, address, origin) =>`,
556
811
  ` Spec.` + n.requested + `({`,
557
812
  ` ` + id + `: ` + id + `,`,
558
813
  ` category,`,
559
814
  ` reference,`,
560
815
  ` channel: Behavior.channelOf(channel),`,
561
816
  ` address,`,
817
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
818
+ ` subjectRef: "subject-1",`,
562
819
  ` subject: "subject",`,
563
820
  ` body: "body",`,
821
+ ` origin,`,
564
822
  ` })`,
823
+ ``,
824
+ ` let requested = (category, reference, channel, address) =>`,
825
+ ` requestedWith(category, reference, channel, address, Spec.Default)`,
826
+ ` // Which rule asked is this host's own shape, so the trait cannot build this.`,
827
+ ` let requestedConfigured = (category, reference, channel, address) =>`,
828
+ ` requestedWith(`,
829
+ ` category,`,
830
+ ` reference,`,
831
+ ` channel,`,
832
+ ` address,`,
833
+ ` Spec.Configured({ruleId: "rule-1", ruleVersion: "1"}),`,
834
+ ` )`,
835
+ ` let deferred = (reference, source) =>`,
836
+ ` Spec.` + n.deferred + `({` + id + `: ` + id + `, reference, sourceKey: source})`,
837
+ ` // The subject rides through the two decisions not to send as well: what a`,
838
+ ` // suppressed or undeliverable notification was about is the whole reason those`,
839
+ ` // rows are worth reading.`,
565
840
  ` let suppressed = (category, reference) =>`,
566
- ` Spec.` + n.suppressed + `({` + id + `: ` + id + `, category, reference})`,
841
+ ` Spec.` + n.suppressed + `({`,
842
+ ` ` + id + `: ` + id + `,`,
843
+ ` category,`,
844
+ ` reference,`,
845
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
846
+ ` subjectRef: "subject-1",`,
847
+ ` origin: Spec.Default,`,
848
+ ` })`,
567
849
  ` let undeliverable = (category, reference) =>`,
568
- ` Spec.` + n.undeliverable + `({` + id + `: ` + id + `, category, reference})`,
850
+ ` Spec.` + n.undeliverable + `({`,
851
+ ` ` + id + `: ` + id + `,`,
852
+ ` category,`,
853
+ ` reference,`,
854
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
855
+ ` subjectRef: "subject-1",`,
856
+ ` origin: Spec.Default,`,
857
+ ` })`,
569
858
  ``,
570
859
  ` let recipientUnknown = Spec.` + n.unknownError,
571
860
  ``,
@@ -668,8 +957,14 @@ function intakeRelayPatch(c) {
668
957
  ``,
669
958
  `@@reventless.spec`,
670
959
  ``,
960
+ `// The row a rule composes from. It carries the rule's id rather than a kind of`,
961
+ `// occurrence: the rule holds the category, the subject and the wording, so a`,
962
+ `// second notifiable event is a second entry in the table next door.`,
963
+ `//`,
964
+ `// It is also the payload the wording is rendered against, so every field here`,
965
+ `// is a path a template may name — \`{{ ` + c.occurrenceId + ` }}\`.`,
671
966
  `@schema`,
672
- `type todoItem = {` + id + `: string, ` + c.occurrenceId + `: string}`,
967
+ `type todoItem = {ruleId: string, ` + id + `: string, ` + c.occurrenceId + `: string}`,
673
968
  ``,
674
969
  `@schema`,
675
970
  `type command =`,
@@ -677,8 +972,12 @@ function intakeRelayPatch(c) {
677
972
  ` ` + id + `: string,`,
678
973
  ` category: ` + n.slice + `.category,`,
679
974
  ` reference: string,`,
975
+ ` subjectType: string,`,
976
+ ` subjectRef: string,`,
680
977
  ` subject: string,`,
681
978
  ` body: string,`,
979
+ ` sourceId: string,`,
980
+ ` origin: ` + n.slice + `.origin,`,
682
981
  ` })`,
683
982
  ``,
684
983
  `let maxRetries = 3`,
@@ -689,9 +988,7 @@ function intakeRelayPatch(c) {
689
988
  ``,
690
989
  `@@reventless.automation`,
691
990
  ``,
692
- `// A TODO id is also the reference the request carries, so the outcome event`,
693
- `// echoes back exactly what resolves the row.`,
694
- `let key = ` + c.occurrenceId + ` => \`notify:\${` + c.occurrenceId + `}\``,
991
+ `module Rule = TraitNotification.Notification_Rule`,
695
992
  ``,
696
993
  `module DcbSource = {`,
697
994
  ` // MUST equal "<pluginName>DcbEventLog".`,
@@ -705,32 +1002,73 @@ function intakeRelayPatch(c) {
705
1002
  ` | ` + n.requested + `({reference: string})`,
706
1003
  ` | ` + n.suppressed + `({reference: string})`,
707
1004
  ` | ` + n.undeliverable + `({reference: string})`,
1005
+ ` // The fourth is not a nicety: an unresolved row retries its whole budget`,
1006
+ ` // and lands in \`onExhausted\`, so a handover would look like a slow failure`,
1007
+ ` // with nothing in the log to explain it.`,
1008
+ ` | ` + n.deferred + `({reference: string})`,
708
1009
  `}`,
709
1010
  ``,
1011
+ `// TODO(graft): the wording. A trait declares the kind; what the sentence says`,
1012
+ `// is this host's — and it is text rather than ReScript, rendered against the`,
1013
+ `// todo item next door, so \`{{ ` + c.occurrenceId + ` }}\` is a field of it.`,
1014
+ `//`,
1015
+ `// A rule's id is also the namespace of the references it writes, so two rules`,
1016
+ `// on one subject are two notifications and two delivery rows.`,
1017
+ `let defaultRules: array<Rule.t> = [`,
1018
+ ` {`,
1019
+ ` id: "` + c.occurrence + `",`,
1020
+ ` version: "1",`,
1021
+ ` source: {log: DcbSource.name, eventType: "` + c.occurrence + `"},`,
1022
+ ` filter: Always,`,
1023
+ ` category: "` + c.occurrenceCategory + `",`,
1024
+ ` delivery: Immediate,`,
1025
+ ` recipientPath: "` + id + `",`,
1026
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
1027
+ ` subjectPath: "` + c.occurrenceId + `",`,
1028
+ ` content: [{locale: "en", subject: "TODO(graft)", body: "TODO(graft)"}],`,
1029
+ ` },`,
1030
+ `]`,
1031
+ ``,
1032
+ `// The dispatch is the table's, so the switch below only takes an event apart.`,
1033
+ `// Two rules on one event type are two notifications, with no arm to add.`,
1034
+ `//`,
1035
+ `// Only the immediate half of the table: a digest rule's occurrences are`,
1036
+ `// gathered by the component that sends the digest, so no row is opened here.`,
1037
+ `let todosFor = (~eventType, ~` + id + `, ~` + c.occurrenceId + `) =>`,
1038
+ ` defaultRules`,
1039
+ ` ->Rule.forEvent(~log=DcbSource.name, ~eventType)`,
1040
+ ` ->Array.filter(Rule.isImmediate)`,
1041
+ ` ->Array.map(rule => (`,
1042
+ ` Rule.reference(rule, ~subject=` + c.occurrenceId + `),`,
1043
+ ` ({ruleId: rule.id, ` + id + `: ` + id + `, ` + c.occurrenceId + `: ` + c.occurrenceId + `}: ` + n.intake + `.todoItem),`,
1044
+ ` ))`,
1045
+ ``,
710
1046
  `module FromDcb = Mapping.Make(`,
711
1047
  ` DcbSource,`,
712
1048
  ` ` + n.intake + `,`,
713
1049
  ` {`,
714
1050
  ` open DcbSource`,
715
1051
  ``,
716
- ` let collect = (event, _ctx) =>`,
1052
+ ` let collect = (event, ~sourceId as _, _ctx) =>`,
717
1053
  ` switch event {`,
718
- ` | ` + c.occurrence + `({` + c.occurrenceId + `, ` + c.occurrenceRecipient + `}) => [`,
719
- ` (`,
720
- ` key(` + c.occurrenceId + `),`,
721
- ` ({` + id + `: ` + c.occurrenceRecipient + `, ` + c.occurrenceId + `: ` + c.occurrenceId + `}: ` + n.intake + `.todoItem),`,
722
- ` ),`,
723
- ` ]`,
1054
+ ` | ` + c.occurrence + `({` + c.occurrenceId + `, ` + c.occurrenceRecipient + `}) =>`,
1055
+ ` todosFor(`,
1056
+ ` ~eventType="` + c.occurrence + `",`,
1057
+ ` ~` + id + `=` + c.occurrenceRecipient + `,`,
1058
+ ` ~` + c.occurrenceId + `,`,
1059
+ ` )`,
724
1060
  ` | ` + n.requested + `(_)`,
725
1061
  ` | ` + n.suppressed + `(_)`,
726
- ` | ` + n.undeliverable + `(_) => []`,
1062
+ ` | ` + n.undeliverable + `(_)`,
1063
+ ` | ` + n.deferred + `(_) => []`,
727
1064
  ` }`,
728
1065
  ``,
729
1066
  ` let resolve = event =>`,
730
1067
  ` switch event {`,
731
1068
  ` | ` + n.requested + `({reference})`,
732
1069
  ` | ` + n.suppressed + `({reference})`,
733
- ` | ` + n.undeliverable + `({reference}) =>`,
1070
+ ` | ` + n.undeliverable + `({reference})`,
1071
+ ` | ` + n.deferred + `({reference}) =>`,
734
1072
  ` Some(reference)`,
735
1073
  ` | ` + c.occurrence + `(_) => None`,
736
1074
  ` }`,
@@ -739,19 +1077,41 @@ function intakeRelayPatch(c) {
739
1077
  ``,
740
1078
  `let mappings: array<module(Mapping)> = [module(FromDcb)]`,
741
1079
  ``,
742
- `// TODO(graft): the wording. A trait declares the kind; what the sentence says`,
743
- `// is this host's, and a config field for it would be a template language.`,
744
1080
  `let process = (id, item: ` + n.intake + `.todoItem) =>`,
745
- ` Some((`,
746
- ` item.` + id + `,`,
747
- ` ` + n.intake + `.` + n.requestCmd + `({`,
748
- ` ` + id + `: item.` + id + `,`,
749
- ` category: ` + c.occurrenceCategory + `,`,
750
- ` reference: id,`,
751
- ` subject: "TODO(graft)",`,
752
- ` body: "TODO(graft)",`,
753
- ` }),`,
754
- ` ))`,
1081
+ ` // Two ways the table can have moved under a row that was already open: the`,
1082
+ ` // rule is gone, or it has become a digest's. Neither publishes anything, and`,
1083
+ ` // either leaves the row Pending a \`None\` here spends no retry budget, so it`,
1084
+ ` // is re-swept on every batch and never reaches \`onExhausted\`.`,
1085
+ ` switch defaultRules->Rule.byId(item.ruleId)->Option.filter(Rule.isImmediate) {`,
1086
+ ` | None => None`,
1087
+ ` | Some(rule) =>`,
1088
+ ` let payload = item->Reventless.Util_Sury.toJson(` + n.intake + `.todoItemSchema)`,
1089
+ ` switch (Rule.matches(rule.filter, ~payload), Rule.recipientOf(rule, ~payload)) {`,
1090
+ ` | (false, _) | (_, None) => None`,
1091
+ ` | (true, Some(` + id + `)) =>`,
1092
+ ` let (subject, body) =`,
1093
+ ` Rule.compose(rule, ~payload, ~schema=` + n.intake + `.todoItemSchema)`,
1094
+ ` Some((`,
1095
+ ` ` + id + `,`,
1096
+ ` ` + n.intake + `.` + n.requestCmd + `({`,
1097
+ ` ` + id + `: ` + id + `,`,
1098
+ ` category: ` + n.slice + `_Behavior.categoryOf(rule.category),`,
1099
+ ` reference: id,`,
1100
+ ` subjectType: rule.subjectType,`,
1101
+ ` subjectRef: Rule.subjectOf(rule, ~payload),`,
1102
+ ` subject,`,
1103
+ ` body,`,
1104
+ ` // Which stream this came from. A second producer claims exactly this`,
1105
+ ` // string to take the entry over, so both sides must derive it the same`,
1106
+ ` // way — the format is the whole agreement.`,
1107
+ ` sourceId: Rule.sourceId(rule),`,
1108
+ ` // This relay IS the compiled table, so every request it makes is the`,
1109
+ ` // default one — the arm that yields when somebody claims the source.`,
1110
+ ` origin: Default,`,
1111
+ ` }),`,
1112
+ ` ))`,
1113
+ ` }`,
1114
+ ` }`,
755
1115
  ``,
756
1116
  `// A relay that gave up published no command, so the competency never heard of`,
757
1117
  `// the occurrence — a delivery-failed fact for a message nobody requested would`,
@@ -773,6 +1133,14 @@ function emit(config, into, tests) {
773
1133
  path: into + `/StateChangeSlice/` + n.slice + `_Behavior.res`,
774
1134
  contents: sliceBehavior(config)
775
1135
  },
1136
+ {
1137
+ path: into + `/StateChangeSlice/` + n.claims + `.res`,
1138
+ contents: claimsSpec(config)
1139
+ },
1140
+ {
1141
+ path: into + `/StateChangeSlice/` + n.claims + `_Behavior.res`,
1142
+ contents: claimsBehavior(config)
1143
+ },
776
1144
  {
777
1145
  path: into + `/OutboundTranslationSlice/` + n.send + `.res`,
778
1146
  contents: sendSpec(config)
@@ -799,11 +1167,14 @@ export {
799
1167
  lines,
800
1168
  addressA,
801
1169
  addressB,
1170
+ subjectTypeOf,
802
1171
  authorizeOn,
803
1172
  categoryArms,
804
1173
  sliceSpec,
805
1174
  postureArms,
806
1175
  sliceBehavior,
1176
+ claimsSpec,
1177
+ claimsBehavior,
807
1178
  sendSpec,
808
1179
  sendTranslation,
809
1180
  conformanceBinding,