@reventlessdev/trait-notification 1.0.0-alpha.1 → 1.0.0-alpha.10

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;
@@ -425,13 +660,20 @@ function sendTranslation(c) {
425
660
  ` item.address`,
426
661
  ` ->Reventless.Phone.fromString`,
427
662
  ` ->Result.map(phone => Reventless.Messaging.ToSms(phone))`,
428
- ` | Push => Ok(Reventless.Messaging.ToPush({deviceToken: item.address}))`,
663
+ ` // The directory stores a channel beside one flat address, and no push address is`,
664
+ ` // one string: two services issue a token, Web Push issues an endpoint with its`,
665
+ ` // encryption keys, and which service issued this one is not recorded either. So`,
666
+ ` // the pair cannot be re-fused, and refusing says so once rather than guessing a`,
667
+ ` // service and failing at whichever provider. A push transport arrives with the`,
668
+ ` // stored shape it needs, in the commit that justifies the columns.`,
669
+ ` | Push =>`,
670
+ ` Error("a push address names its issuing service, and the directory stores one flat address")`,
429
671
  ` }`,
430
672
  ``,
431
673
  `let translate = async (_id, item: outboundItem, ~capabilities: Reventless.Capabilities.t) =>`,
432
674
  ` switch recipientFor(item) {`,
433
- ` // An address the directory holds that its own channel's grammar refuses. Not`,
434
- ` // retryable and not the provider's fault — the row that holds it needs fixing.`,
675
+ ` // An address the directory holds that its own channel cannot use. Not retryable`,
676
+ ` // and not the provider's fault — the row that holds it needs fixing.`,
435
677
  ` | Error(why) =>`,
436
678
  ` Ok(`,
437
679
  ` Some((`,
@@ -514,20 +756,27 @@ function conformanceBinding(c) {
514
756
  ` module Spec = ` + n.slice,
515
757
  ` module Behavior = ` + n.slice + `_Behavior`,
516
758
  ``,
517
- ` // A DCB slice's entity comes into existence with its first fact, so there is`,
518
- ` // no creation event to seed: an unannounced ` + c.noun.toLowerCase() + ` is one with no history.`,
519
- ` let created: array<Spec.consumedEvent> = []`,
520
- ``,
521
759
  ` let ` + id + ` = "` + c.noun.toLowerCase() + `-1"`,
522
760
  ``,
523
761
  ` // Annotated: this slice reads back exactly what it writes, so each of these`,
524
762
  ` // names a constructor of both unions and the later declaration would win.`,
525
- ` let announcedC = (` + c.contactField + `): Spec.consumedEvent =>`,
526
- ` ` + n.announced + `({` + id + `: ` + id + `, ` + c.contactField + `: ` + c.contactField + `})`,
527
- ` let subscribedC = (category, channel): Spec.consumedEvent =>`,
528
- ` ` + n.subscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
529
- ` let unsubscribedC = (category, channel): Spec.consumedEvent =>`,
530
- ` ` + n.unsubscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
763
+ ` module Consumed = {`,
764
+ ` // A DCB slice's entity comes into existence with its first fact, so there`,
765
+ ` // is no creation event to seed: an unannounced ` + c.noun.toLowerCase() + ` is one with no`,
766
+ ` // history.`,
767
+ ` let created: array<Spec.consumedEvent> = []`,
768
+ ` let announced = (` + c.contactField + `): Spec.consumedEvent =>`,
769
+ ` ` + n.announced + `({` + id + `: ` + id + `, ` + c.contactField + `: ` + c.contactField + `})`,
770
+ ` let subscribed = (category, channel): Spec.consumedEvent =>`,
771
+ ` ` + n.subscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
772
+ ` let unsubscribed = (category, channel): Spec.consumedEvent =>`,
773
+ ` ` + n.unsubscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
774
+ ` // Written by ` + n.claims + ` and read here across partitions — this slice`,
775
+ ` // consumes them and produces neither.`,
776
+ ` let claimed = (source, by): Spec.consumedEvent =>`,
777
+ ` ` + n.claimed + `({sourceId: source, by})`,
778
+ ` let released = (source): Spec.consumedEvent => ` + n.released + `({sourceId: source})`,
779
+ ` }`,
531
780
  ``,
532
781
  ` let announce = ` + c.contactField + ` =>`,
533
782
  ` Spec.` + n.announceCmd + `({` + id + `: ` + id + `, ` + c.contactField + `: ` + c.contactField + `})`,
@@ -535,37 +784,86 @@ function conformanceBinding(c) {
535
784
  ` Spec.` + n.subscribeCmd + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
536
785
  ` let unsubscribe = (category, channel) =>`,
537
786
  ` 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) =>`,
787
+ ` // The wording and the subject are this host's and the trait carries neither, so`,
788
+ ` // the suite supplies whatever it likes and asserts nothing about them.`,
789
+ ` //`,
790
+ ` // One source nothing ever claims, so every assertion that predates the handover`,
791
+ ` // keeps deciding exactly as it did.`,
792
+ ` let defaultSource = "TODO(graft)DcbEventLog:` + c.occurrence + `"`,
793
+ ``,
794
+ ` let requestFrom = (category, reference, ~source, ~origin) =>`,
541
795
  ` Spec.` + n.requestCmd + `({`,
542
796
  ` ` + id + `: ` + id + `,`,
543
797
  ` category,`,
544
798
  ` reference,`,
799
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
800
+ ` subjectRef: "subject-1",`,
545
801
  ` subject: "subject",`,
546
802
  ` body: "body",`,
803
+ ` sourceId: source,`,
804
+ ` origin: switch (origin: TraitNotification.Notification_Rules.origin) {`,
805
+ ` | Default => Default`,
806
+ ` | Configured => Configured({ruleId: "rule-1", ruleVersion: "1"})`,
807
+ ` },`,
547
808
  ` })`,
548
809
  ``,
810
+ ` let request = (category, reference) =>`,
811
+ ` requestFrom(category, reference, ~source=defaultSource, ~origin=Default)`,
812
+ ``,
549
813
  ` let announced = ` + c.contactField + ` =>`,
550
814
  ` Spec.` + n.announced + `({` + id + `: ` + id + `, ` + c.contactField + `: ` + c.contactField + `})`,
551
815
  ` let subscribed = (category, channel) =>`,
552
816
  ` Spec.` + n.subscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
553
817
  ` let unsubscribed = (category, channel) =>`,
554
818
  ` Spec.` + n.unsubscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
555
- ` let requested = (category, reference, channel, address) =>`,
819
+ ` let requestedWith = (category, reference, channel, address, origin) =>`,
556
820
  ` Spec.` + n.requested + `({`,
557
821
  ` ` + id + `: ` + id + `,`,
558
822
  ` category,`,
559
823
  ` reference,`,
560
824
  ` channel: Behavior.channelOf(channel),`,
561
825
  ` address,`,
826
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
827
+ ` subjectRef: "subject-1",`,
562
828
  ` subject: "subject",`,
563
829
  ` body: "body",`,
830
+ ` origin,`,
564
831
  ` })`,
832
+ ``,
833
+ ` let requested = (category, reference, channel, address) =>`,
834
+ ` requestedWith(category, reference, channel, address, Spec.Default)`,
835
+ ` // Which rule asked is this host's own shape, so the trait cannot build this.`,
836
+ ` let requestedConfigured = (category, reference, channel, address) =>`,
837
+ ` requestedWith(`,
838
+ ` category,`,
839
+ ` reference,`,
840
+ ` channel,`,
841
+ ` address,`,
842
+ ` Spec.Configured({ruleId: "rule-1", ruleVersion: "1"}),`,
843
+ ` )`,
844
+ ` let deferred = (reference, source) =>`,
845
+ ` Spec.` + n.deferred + `({` + id + `: ` + id + `, reference, sourceKey: source})`,
846
+ ` // The subject rides through the two decisions not to send as well: what a`,
847
+ ` // suppressed or undeliverable notification was about is the whole reason those`,
848
+ ` // rows are worth reading.`,
565
849
  ` let suppressed = (category, reference) =>`,
566
- ` Spec.` + n.suppressed + `({` + id + `: ` + id + `, category, reference})`,
850
+ ` Spec.` + n.suppressed + `({`,
851
+ ` ` + id + `: ` + id + `,`,
852
+ ` category,`,
853
+ ` reference,`,
854
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
855
+ ` subjectRef: "subject-1",`,
856
+ ` origin: Spec.Default,`,
857
+ ` })`,
567
858
  ` let undeliverable = (category, reference) =>`,
568
- ` Spec.` + n.undeliverable + `({` + id + `: ` + id + `, category, reference})`,
859
+ ` Spec.` + n.undeliverable + `({`,
860
+ ` ` + id + `: ` + id + `,`,
861
+ ` category,`,
862
+ ` reference,`,
863
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
864
+ ` subjectRef: "subject-1",`,
865
+ ` origin: Spec.Default,`,
866
+ ` })`,
569
867
  ``,
570
868
  ` let recipientUnknown = Spec.` + n.unknownError,
571
869
  ``,
@@ -588,7 +886,7 @@ function contactRelayPatch(c) {
588
886
  let n = namesOf(c);
589
887
  let id = n.recipientId;
590
888
  return {
591
- into: c.chapter + `/OutboundTranslationSlice/` + n.relay + `.res (new, plus its _Translation)`,
889
+ into: c.chapter + `/OutboundTranslation/` + n.relay + `.res (new, plus its _Translation)`,
592
890
  at: `a new file — the trait cannot write what this host's events mean`,
593
891
  contents: [
594
892
  [
@@ -661,15 +959,21 @@ function intakeRelayPatch(c) {
661
959
  let n = namesOf(c);
662
960
  let id = n.recipientId;
663
961
  return {
664
- into: c.chapter + `/AutomationSlice/` + n.intake + `.res (new, plus its _Automation)`,
962
+ into: c.chapter + `/Automation/` + n.intake + `.res (new, plus its _Automation)`,
665
963
  at: `a new file — the wording is this host's sentence, not the trait's`,
666
964
  contents: [
667
965
  `// Turns one of this host's occurrences into a request to notify somebody.`,
668
966
  ``,
669
967
  `@@reventless.spec`,
670
968
  ``,
969
+ `// The row a rule composes from. It carries the rule's id rather than a kind of`,
970
+ `// occurrence: the rule holds the category, the subject and the wording, so a`,
971
+ `// second notifiable event is a second entry in the table next door.`,
972
+ `//`,
973
+ `// It is also the payload the wording is rendered against, so every field here`,
974
+ `// is a path a template may name — \`{{ ` + c.occurrenceId + ` }}\`.`,
671
975
  `@schema`,
672
- `type todoItem = {` + id + `: string, ` + c.occurrenceId + `: string}`,
976
+ `type todoItem = {ruleId: string, ` + id + `: string, ` + c.occurrenceId + `: string}`,
673
977
  ``,
674
978
  `@schema`,
675
979
  `type command =`,
@@ -677,8 +981,12 @@ function intakeRelayPatch(c) {
677
981
  ` ` + id + `: string,`,
678
982
  ` category: ` + n.slice + `.category,`,
679
983
  ` reference: string,`,
984
+ ` subjectType: string,`,
985
+ ` subjectRef: string,`,
680
986
  ` subject: string,`,
681
987
  ` body: string,`,
988
+ ` sourceId: string,`,
989
+ ` origin: ` + n.slice + `.origin,`,
682
990
  ` })`,
683
991
  ``,
684
992
  `let maxRetries = 3`,
@@ -689,9 +997,7 @@ function intakeRelayPatch(c) {
689
997
  ``,
690
998
  `@@reventless.automation`,
691
999
  ``,
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 + `}\``,
1000
+ `module Rule = TraitNotification.Notification_Rule`,
695
1001
  ``,
696
1002
  `module DcbSource = {`,
697
1003
  ` // MUST equal "<pluginName>DcbEventLog".`,
@@ -705,32 +1011,73 @@ function intakeRelayPatch(c) {
705
1011
  ` | ` + n.requested + `({reference: string})`,
706
1012
  ` | ` + n.suppressed + `({reference: string})`,
707
1013
  ` | ` + n.undeliverable + `({reference: string})`,
1014
+ ` // The fourth is not a nicety: an unresolved row retries its whole budget`,
1015
+ ` // and lands in \`onExhausted\`, so a handover would look like a slow failure`,
1016
+ ` // with nothing in the log to explain it.`,
1017
+ ` | ` + n.deferred + `({reference: string})`,
708
1018
  `}`,
709
1019
  ``,
1020
+ `// TODO(graft): the wording. A trait declares the kind; what the sentence says`,
1021
+ `// is this host's — and it is text rather than ReScript, rendered against the`,
1022
+ `// todo item next door, so \`{{ ` + c.occurrenceId + ` }}\` is a field of it.`,
1023
+ `//`,
1024
+ `// A rule's id is also the namespace of the references it writes, so two rules`,
1025
+ `// on one subject are two notifications and two delivery rows.`,
1026
+ `let defaultRules: array<Rule.t> = [`,
1027
+ ` {`,
1028
+ ` id: "` + c.occurrence + `",`,
1029
+ ` version: "1",`,
1030
+ ` source: {log: DcbSource.name, eventType: "` + c.occurrence + `"},`,
1031
+ ` filter: Always,`,
1032
+ ` category: "` + c.occurrenceCategory + `",`,
1033
+ ` delivery: Immediate,`,
1034
+ ` recipientPath: "` + id + `",`,
1035
+ ` subjectType: "` + subjectTypeOf(c) + `",`,
1036
+ ` subjectPath: "` + c.occurrenceId + `",`,
1037
+ ` content: [{locale: "en", subject: "TODO(graft)", body: "TODO(graft)"}],`,
1038
+ ` },`,
1039
+ `]`,
1040
+ ``,
1041
+ `// The dispatch is the table's, so the switch below only takes an event apart.`,
1042
+ `// Two rules on one event type are two notifications, with no arm to add.`,
1043
+ `//`,
1044
+ `// Only the immediate half of the table: a digest rule's occurrences are`,
1045
+ `// gathered by the component that sends the digest, so no row is opened here.`,
1046
+ `let todosFor = (~eventType, ~` + id + `, ~` + c.occurrenceId + `) =>`,
1047
+ ` defaultRules`,
1048
+ ` ->Rule.forEvent(~log=DcbSource.name, ~eventType)`,
1049
+ ` ->Array.filter(Rule.isImmediate)`,
1050
+ ` ->Array.map(rule => (`,
1051
+ ` Rule.reference(rule, ~subject=` + c.occurrenceId + `),`,
1052
+ ` ({ruleId: rule.id, ` + id + `: ` + id + `, ` + c.occurrenceId + `: ` + c.occurrenceId + `}: ` + n.intake + `.todoItem),`,
1053
+ ` ))`,
1054
+ ``,
710
1055
  `module FromDcb = Mapping.Make(`,
711
1056
  ` DcbSource,`,
712
1057
  ` ` + n.intake + `,`,
713
1058
  ` {`,
714
1059
  ` open DcbSource`,
715
1060
  ``,
716
- ` let collect = (event, _ctx) =>`,
1061
+ ` let collect = (event, ~sourceId as _, _ctx) =>`,
717
1062
  ` 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
- ` ]`,
1063
+ ` | ` + c.occurrence + `({` + c.occurrenceId + `, ` + c.occurrenceRecipient + `}) =>`,
1064
+ ` todosFor(`,
1065
+ ` ~eventType="` + c.occurrence + `",`,
1066
+ ` ~` + id + `=` + c.occurrenceRecipient + `,`,
1067
+ ` ~` + c.occurrenceId + `,`,
1068
+ ` )`,
724
1069
  ` | ` + n.requested + `(_)`,
725
1070
  ` | ` + n.suppressed + `(_)`,
726
- ` | ` + n.undeliverable + `(_) => []`,
1071
+ ` | ` + n.undeliverable + `(_)`,
1072
+ ` | ` + n.deferred + `(_) => []`,
727
1073
  ` }`,
728
1074
  ``,
729
1075
  ` let resolve = event =>`,
730
1076
  ` switch event {`,
731
1077
  ` | ` + n.requested + `({reference})`,
732
1078
  ` | ` + n.suppressed + `({reference})`,
733
- ` | ` + n.undeliverable + `({reference}) =>`,
1079
+ ` | ` + n.undeliverable + `({reference})`,
1080
+ ` | ` + n.deferred + `({reference}) =>`,
734
1081
  ` Some(reference)`,
735
1082
  ` | ` + c.occurrence + `(_) => None`,
736
1083
  ` }`,
@@ -739,19 +1086,41 @@ function intakeRelayPatch(c) {
739
1086
  ``,
740
1087
  `let mappings: array<module(Mapping)> = [module(FromDcb)]`,
741
1088
  ``,
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
1089
  `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
- ` ))`,
1090
+ ` // Two ways the table can have moved under a row that was already open: the`,
1091
+ ` // rule is gone, or it has become a digest's. Neither publishes anything, and`,
1092
+ ` // either leaves the row Pending a \`None\` here spends no retry budget, so it`,
1093
+ ` // is re-swept on every batch and never reaches \`onExhausted\`.`,
1094
+ ` switch defaultRules->Rule.byId(item.ruleId)->Option.filter(Rule.isImmediate) {`,
1095
+ ` | None => None`,
1096
+ ` | Some(rule) =>`,
1097
+ ` let payload = item->Reventless.Util_Sury.toJson(` + n.intake + `.todoItemSchema)`,
1098
+ ` switch (Rule.matches(rule.filter, ~payload), Rule.recipientOf(rule, ~payload)) {`,
1099
+ ` | (false, _) | (_, None) => None`,
1100
+ ` | (true, Some(` + id + `)) =>`,
1101
+ ` let (subject, body) =`,
1102
+ ` Rule.compose(rule, ~payload, ~schema=` + n.intake + `.todoItemSchema)`,
1103
+ ` Some((`,
1104
+ ` ` + id + `,`,
1105
+ ` ` + n.intake + `.` + n.requestCmd + `({`,
1106
+ ` ` + id + `: ` + id + `,`,
1107
+ ` category: ` + n.slice + `_Behavior.categoryOf(rule.category),`,
1108
+ ` reference: id,`,
1109
+ ` subjectType: rule.subjectType,`,
1110
+ ` subjectRef: Rule.subjectOf(rule, ~payload),`,
1111
+ ` subject,`,
1112
+ ` body,`,
1113
+ ` // Which stream this came from. A second producer claims exactly this`,
1114
+ ` // string to take the entry over, so both sides must derive it the same`,
1115
+ ` // way — the format is the whole agreement.`,
1116
+ ` sourceId: Rule.sourceId(rule),`,
1117
+ ` // This relay IS the compiled table, so every request it makes is the`,
1118
+ ` // default one — the arm that yields when somebody claims the source.`,
1119
+ ` origin: Default,`,
1120
+ ` }),`,
1121
+ ` ))`,
1122
+ ` }`,
1123
+ ` }`,
755
1124
  ``,
756
1125
  `// A relay that gave up published no command, so the competency never heard of`,
757
1126
  `// the occurrence — a delivery-failed fact for a message nobody requested would`,
@@ -766,19 +1135,27 @@ function emit(config, into, tests) {
766
1135
  return {
767
1136
  files: [
768
1137
  {
769
- path: into + `/StateChangeSlice/` + n.slice + `.res`,
1138
+ path: into + `/StateChange/` + n.slice + `.res`,
770
1139
  contents: sliceSpec(config)
771
1140
  },
772
1141
  {
773
- path: into + `/StateChangeSlice/` + n.slice + `_Behavior.res`,
1142
+ path: into + `/StateChange/` + n.slice + `_Behavior.res`,
774
1143
  contents: sliceBehavior(config)
775
1144
  },
776
1145
  {
777
- path: into + `/OutboundTranslationSlice/` + n.send + `.res`,
1146
+ path: into + `/StateChange/` + n.claims + `.res`,
1147
+ contents: claimsSpec(config)
1148
+ },
1149
+ {
1150
+ path: into + `/StateChange/` + n.claims + `_Behavior.res`,
1151
+ contents: claimsBehavior(config)
1152
+ },
1153
+ {
1154
+ path: into + `/OutboundTranslation/` + n.send + `.res`,
778
1155
  contents: sendSpec(config)
779
1156
  },
780
1157
  {
781
- path: into + `/OutboundTranslationSlice/` + n.send + `_Translation.res`,
1158
+ path: into + `/OutboundTranslation/` + n.send + `_Translation.res`,
782
1159
  contents: sendTranslation(config)
783
1160
  },
784
1161
  {
@@ -799,11 +1176,14 @@ export {
799
1176
  lines,
800
1177
  addressA,
801
1178
  addressB,
1179
+ subjectTypeOf,
802
1180
  authorizeOn,
803
1181
  categoryArms,
804
1182
  sliceSpec,
805
1183
  postureArms,
806
1184
  sliceBehavior,
1185
+ claimsSpec,
1186
+ claimsBehavior,
807
1187
  sendSpec,
808
1188
  sendTranslation,
809
1189
  conformanceBinding,