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

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.
@@ -12,11 +12,9 @@ written whole; the two **relays** are printed, because what a host's events mean
12
12
  is the one thing a trait cannot be told in names.
13
13
 
14
14
  **What it does not do.** It never writes the host's wording. What a confirmation
15
- says is the host's sentence, and a config field for it would be a template
16
- language nobody asked for. It is left as a `TODO(graft)` marker in a string
17
- literal, which compiles, runs, and is obvious in a diff.
15
+ says is the host's sentence, so the emitted rule table carries a `TODO(graft)`
16
+ marker in place of one it compiles, runs, and is obvious in a diff.
18
17
  */
19
-
20
18
  /**
21
19
  The names a graft needs, and nothing else.
22
20
 
@@ -77,6 +75,7 @@ type output = {files: array<file>, patches: array<patch>}
77
75
 
78
76
  type names = {
79
77
  slice: string,
78
+ claims: string,
80
79
  send: string,
81
80
  relay: string,
82
81
  intake: string,
@@ -94,6 +93,12 @@ type names = {
94
93
  requested: string,
95
94
  suppressed: string,
96
95
  undeliverable: string,
96
+ deferred: string,
97
+ claimed: string,
98
+ released: string,
99
+ claimCmd: string,
100
+ releaseCmd: string,
101
+ claimError: string,
97
102
  delivered: string,
98
103
  failed: string,
99
104
  unknownError: string,
@@ -104,6 +109,7 @@ let namesOf = (c: config): names => {
104
109
  let n = c.noun
105
110
  {
106
111
  slice: n ++ "Preferences",
112
+ claims: "NotificationSourceClaims",
107
113
  send: "Send" ++ n ++ "Notification",
108
114
  relay: "Announce" ++ n ++ "Contact",
109
115
  intake: n ++ "NotificationIntake",
@@ -121,11 +127,17 @@ let namesOf = (c: config): names => {
121
127
  requested: "NotificationRequested",
122
128
  suppressed: "NotificationSuppressed",
123
129
  undeliverable: "NotificationUndeliverable",
130
+ deferred: "NotificationDeferred",
131
+ claimed: "NotificationSourceClaimed",
132
+ released: "NotificationSourceReleased",
133
+ claimCmd: "ClaimNotificationSource",
134
+ releaseCmd: "ReleaseNotificationSource",
135
+ claimError: "ClaimRefused",
124
136
  delivered: "NotificationDelivered",
125
137
  failed: "NotificationFailed",
126
138
  unknownError: n ++ "Unknown",
127
- recipientId: (n->String.slice(~start=0, ~end=1))->String.toLowerCase ++
128
- n->String.slice(~start=1, ~end=n->String.length) ++ "Id",
139
+ recipientId: n->String.slice(~start=0, ~end=1)->String.toLowerCase ++
140
+ n->String.slice(~start=1, ~end=n->String.length) ++ "Id",
129
141
  }
130
142
  }
131
143
 
@@ -134,6 +146,25 @@ let lines = (ls: array<string>) => ls->Array.join("\n")
134
146
  let addressA = (c: config) => c.addressA->Option.getOr("buyer@example.com")
135
147
  let addressB = (c: config) => c.addressB->Option.getOr("new@example.com")
136
148
 
149
+ /** What the occurrence is about, named from the id it is keyed by: `"orderId"` ⇒
150
+ `"Order"`. Derived rather than configured because `*Id` naming already says
151
+ which entity an id belongs to everywhere else, and a config field would be one
152
+ more thing to get out of step with `occurrenceId`. Emitted as a literal in the
153
+ graft, so a host whose component is registered under another name edits one
154
+ line rather than discovering the convention. */
155
+ let subjectTypeOf = (c: config) => {
156
+ let stem =
157
+ c.occurrenceId->String.endsWith("Id")
158
+ ? c.occurrenceId->String.slice(~start=0, ~end=c.occurrenceId->String.length - 2)
159
+ : c.occurrenceId
160
+ switch stem {
161
+ | "" => "Subject"
162
+ | s =>
163
+ s->String.slice(~start=0, ~end=1)->String.toUpperCase ++
164
+ s->String.slice(~start=1, ~end=s->String.length)
165
+ }
166
+ }
167
+
137
168
  // `@authorize` is the host's policy, so an absent one emits nothing at all
138
169
  // rather than a permissive default.
139
170
  let authorizeOn = (c: config) =>
@@ -183,13 +214,24 @@ let sliceSpec = (c: config): string => {
183
214
  ` | Sms`,
184
215
  ` | Push`,
185
216
  ``,
186
- `// Its own past facts, and nothing else which is what lets one component hold`,
187
- `// the directory and make the dispatch decision.`,
217
+ `// Why a request exists: the compiled table in the relay, or a rule somebody`,
218
+ `// configured on top of it. Recorded on the request that goes out, so "which`,
219
+ `// wording was in force when this was sent" has an answer.`,
220
+ `@schema`,
221
+ `type origin =`,
222
+ ` | Default`,
223
+ ` | Configured({ruleId: string, ruleVersion: string})`,
224
+ ``,
225
+ `// Its own past facts, plus the claim set from ${n.claims}. Claims are per`,
226
+ `// source and this slice is per recipient, so they come from another partition —`,
227
+ `// see \`${n.requestCmd}\` for what makes that read happen.`,
188
228
  `@schema`,
189
229
  `type consumedEvent =`,
190
230
  ` | ${n.announced}({${id}: string, ${c.contactField}: string})`,
191
231
  ` | ${n.subscribed}({${id}: string, category: category, channel: channel})`,
192
232
  ` | ${n.unsubscribed}({${id}: string, category: category, channel: channel})`,
233
+ ` | ${n.claimed}({sourceId: string, by: string})`,
234
+ ` | ${n.released}({sourceId: string})`,
193
235
  ``,
194
236
  `@schema`,
195
237
  `type command =`,
@@ -202,13 +244,32 @@ let sliceSpec = (c: config): string => {
202
244
  `${auth}${n.unsubscribeCmd}({@owner ${id}: string, category: category, channel: channel})`,
203
245
  ` // Also relayed. \`reference\` is the requester's own key, echoed back on`,
204
246
  ` // whichever outcome follows, so the relay can tell its work is finished.`,
247
+ ` // \`subjectType\`/\`subjectRef\` say what the notification is ABOUT, which the`,
248
+ ` // reference deliberately does not: its format is the requester's own`,
249
+ ` // business, so a row keyed by it alone cannot be read back. Empty is legal.`,
250
+ ` // Named \`*Ref\` and not \`*Id\` on purpose: two inferences read id fields by`,
251
+ ` // NAME — the DCB partition and the queryable's key field — and a second`,
252
+ ` // \`*Id\` on the row makes both ambiguous, which costs the view its filter`,
253
+ ` // and its orderBy with no error anywhere.`,
205
254
  ` | @noApi`,
206
255
  ` ${n.requestCmd}({`,
207
256
  ` ${id}: string,`,
208
257
  ` category: category,`,
209
258
  ` reference: string,`,
259
+ ` subjectType: string,`,
260
+ ` subjectRef: string,`,
210
261
  ` subject: string,`,
211
262
  ` body: string,`,
263
+ ` // Which stream this came from, \`"<log>:<eventType>"\`, and who is asking.`,
264
+ ` // A \`Default\` request for a source another producer has claimed yields;`,
265
+ ` // a \`Configured\` one always goes through.`,
266
+ ` //`,
267
+ ` // \`sourceId\` is a DCB tag and has to be: the decision reads claim facts`,
268
+ ` // that live in another partition. Because this slice consumes \`sourceId\``,
269
+ ` // without producing it, the framework infers the cross-partition read from`,
270
+ ` // the slice graph. Renaming it off \`*Id\` would silently drop that.`,
271
+ ` sourceId: string,`,
272
+ ` origin: origin,`,
212
273
  ` })`,
213
274
  ` // Reported by the send slice once the provider has settled.`,
214
275
  ` | @noApi ${n.recordCmd}({${id}: string, reference: string, providerRef: string})`,
@@ -226,21 +287,51 @@ let sliceSpec = (c: config): string => {
226
287
  ` | ${n.subscribed}({${id}: string, category: category, channel: channel})`,
227
288
  ` | ${n.unsubscribed}({${id}: string, category: category, channel: channel})`,
228
289
  ` // The addressed message. \`address\` is the snapshot delivery uses, which is`,
229
- ` // why it is on the fact rather than looked up again later.`,
290
+ ` // why it is on the fact rather than looked up again later — and why it stays`,
291
+ ` // here and off the delivery view: the record of where a message actually went`,
292
+ ` // belongs in the log an investigation reads, not in a general read model.`,
230
293
  ` | ${n.requested}({`,
231
294
  ` ${id}: string,`,
232
295
  ` category: category,`,
233
296
  ` reference: string,`,
234
297
  ` channel: channel,`,
235
298
  ` address: string,`,
299
+ ` subjectType: string,`,
300
+ ` subjectRef: string,`,
236
301
  ` subject: string,`,
237
302
  ` body: string,`,
303
+ ` // What was in force when this went out — a message should record why.`,
304
+ ` origin: origin,`,
238
305
  ` })`,
239
306
  ` // Two different ways to send nothing. A ${c.noun->String.toLowerCase} who declined is the`,
240
307
  ` // system working; one with no address for a channel they enabled is the system`,
241
308
  ` // falling short, and one fact for both hides every gap behind a preference.`,
242
- ` | ${n.suppressed}({${id}: string, category: category, reference: string})`,
243
- ` | ${n.undeliverable}({${id}: string, category: category, reference: string})`,
309
+ ` | ${n.suppressed}({`,
310
+ ` ${id}: string,`,
311
+ ` category: category,`,
312
+ ` reference: string,`,
313
+ ` subjectType: string,`,
314
+ ` subjectRef: string,`,
315
+ ` origin: origin,`,
316
+ ` })`,
317
+ ` | ${n.undeliverable}({`,
318
+ ` ${id}: string,`,
319
+ ` category: category,`,
320
+ ` reference: string,`,
321
+ ` subjectType: string,`,
322
+ ` subjectRef: string,`,
323
+ ` origin: origin,`,
324
+ ` })`,
325
+ ` // The third way of not sending: another producer owns this source. Its own`,
326
+ ` // fact for the same reason the two above are two — and load-bearing besides,`,
327
+ ` // because the relay resolves its TODO row on an outcome, and without this one`,
328
+ ` // a deferred request retries its whole budget and is abandoned silently.`,
329
+ ` //`,
330
+ ` // \`sourceKey\` and not \`sourceId\`, and this is the one place the two names`,
331
+ ` // differ: a produced \`sourceId\` here would give this slice two candidate`,
332
+ ` // partitions and stop \`sourceId\` counting as consumed-but-not-produced, which`,
333
+ ` // is what makes the claim read cross partitions at all.`,
334
+ ` | ${n.deferred}({${id}: string, reference: string, sourceKey: string})`,
244
335
  ` | ${n.delivered}({${id}: string, reference: string, providerRef: string})`,
245
336
  ` | ${n.failed}({${id}: string, reference: string, reason: string})`,
246
337
  ``,
@@ -282,19 +373,9 @@ let sliceBehavior = (c: config): string => {
282
373
  ` switch (category, channel) {`,
283
374
  ],
284
375
  postureArms(c),
285
- [
286
- ` }`,
287
- ``,
288
- `let categoryKey = (category: category) =>`,
289
- ` switch category {`,
290
- ],
376
+ [` }`, ``, `let categoryKey = (category: category) =>`, ` switch category {`],
291
377
  c.categories->Array.map(cat => ` | ${cat} => "${cat}"`),
292
- [
293
- ` }`,
294
- ``,
295
- `let categoryOf = (key: string) =>`,
296
- ` switch key {`,
297
- ],
378
+ [` }`, ``, `let categoryOf = (key: string) =>`, ` switch key {`],
298
379
  c.categories
299
380
  ->Array.filter(cat => cat != first)
300
381
  ->Array.map(cat => ` | "${cat}" => ${cat}`),
@@ -336,44 +417,86 @@ let sliceBehavior = (c: config): string => {
336
417
  ` state->Rules.evolve(`,
337
418
  ` Unsubscribed({category: categoryKey(category), channel: channelKey(channel)}),`,
338
419
  ` )`,
420
+ ` // Read across partitions from ${n.claims} — see the source field on`,
421
+ ` // \`${n.requestCmd}\`.`,
422
+ ` | ${n.claimed}({sourceId, by}) => state->Rules.evolve(Claimed({source: sourceId, by}))`,
423
+ ` | ${n.released}({sourceId}) => state->Rules.evolve(Released({source: sourceId}))`,
339
424
  ` }`,
340
425
  ``,
341
426
  `// The trait decides; this names what it decided in the host's vocabulary.`,
427
+ `//`,
428
+ `// \`option\` because two of the trait's facts belong to ${n.claims}: this`,
429
+ `// component reads them and never writes them, so it has no event to name them`,
430
+ `// with.`,
342
431
  `let named = (${id}, fact: Rules.fact) =>`,
343
432
  ` switch fact {`,
344
- ` | Announced({address}) => ${n.announced}({${id}: ${id}, ${c.contactField}: address})`,
433
+ ` | Claimed(_)`,
434
+ ` | Released(_) => None`,
435
+ ` | Deferred({reference, source}) =>`,
436
+ ` Some(${n.deferred}({${id}: ${id}, reference, sourceKey: source}))`,
437
+ ` | Announced({address}) =>`,
438
+ ` Some(${n.announced}({${id}: ${id}, ${c.contactField}: address}))`,
345
439
  ` | Subscribed({category, channel}) =>`,
346
- ` ${n.subscribed}({`,
347
- ` ${id}: ${id},`,
348
- ` category: categoryOf(category),`,
349
- ` channel: channelOf(channel),`,
350
- ` })`,
440
+ ` Some(`,
441
+ ` ${n.subscribed}({`,
442
+ ` ${id}: ${id},`,
443
+ ` category: categoryOf(category),`,
444
+ ` channel: channelOf(channel),`,
445
+ ` }),`,
446
+ ` )`,
351
447
  ` | Unsubscribed({category, channel}) =>`,
352
- ` ${n.unsubscribed}({`,
353
- ` ${id}: ${id},`,
354
- ` category: categoryOf(category),`,
355
- ` channel: channelOf(channel),`,
356
- ` })`,
448
+ ` Some(`,
449
+ ` ${n.unsubscribed}({`,
450
+ ` ${id}: ${id},`,
451
+ ` category: categoryOf(category),`,
452
+ ` channel: channelOf(channel),`,
453
+ ` }),`,
454
+ ` )`,
357
455
  ` | Requested({category, reference, channel, address}) =>`,
358
- ` ${n.requested}({`,
359
- ` ${id}: ${id},`,
360
- ` category: categoryOf(category),`,
361
- ` reference,`,
362
- ` channel: channelOf(channel),`,
363
- ` address,`,
364
- ` // Carried on the command and put back below: the trait holds no sentence.`,
365
- ` subject: "",`,
366
- ` body: "",`,
367
- ` })`,
456
+ ` Some(`,
457
+ ` ${n.requested}({`,
458
+ ` ${id}: ${id},`,
459
+ ` category: categoryOf(category),`,
460
+ ` reference,`,
461
+ ` channel: channelOf(channel),`,
462
+ ` address,`,
463
+ ` // Carried on the command and put back below: the trait holds no`,
464
+ ` // sentence, refuses to know what an occurrence is, and has no opinion`,
465
+ ` // about which rule asked.`,
466
+ ` subjectType: "",`,
467
+ ` subjectRef: "",`,
468
+ ` subject: "",`,
469
+ ` body: "",`,
470
+ ` origin: Default,`,
471
+ ` }),`,
472
+ ` )`,
368
473
  ` | Suppressed({category, reference}) =>`,
369
- ` ${n.suppressed}({${id}: ${id}, category: categoryOf(category), reference})`,
474
+ ` Some(`,
475
+ ` ${n.suppressed}({`,
476
+ ` ${id}: ${id},`,
477
+ ` category: categoryOf(category),`,
478
+ ` reference,`,
479
+ ` subjectType: "",`,
480
+ ` subjectRef: "",`,
481
+ ` origin: Default,`,
482
+ ` }),`,
483
+ ` )`,
370
484
  ` | Undeliverable({category, reference}) =>`,
371
- ` ${n.undeliverable}({${id}: ${id}, category: categoryOf(category), reference})`,
485
+ ` Some(`,
486
+ ` ${n.undeliverable}({`,
487
+ ` ${id}: ${id},`,
488
+ ` category: categoryOf(category),`,
489
+ ` reference,`,
490
+ ` subjectType: "",`,
491
+ ` subjectRef: "",`,
492
+ ` origin: Default,`,
493
+ ` }),`,
494
+ ` )`,
372
495
  ` }`,
373
496
  ``,
374
497
  `let through = (state, ${id}, op) =>`,
375
498
  ` switch state->Rules.decide(op, ~posture) {`,
376
- ` | Ok(facts) => Ok(facts->Array.map(named(${id}, _)))`,
499
+ ` | Ok(facts) => Ok(facts->Array.filterMap(named(${id}, _)))`,
377
500
  ` | Error(#RecipientUnknown) => Error(${n.unknownError})`,
378
501
  ` }`,
379
502
  ``,
@@ -394,14 +517,44 @@ let sliceBehavior = (c: config): string => {
394
517
  ` Unsubscribe({category: categoryKey(category), channel: channelKey(channel)}),`,
395
518
  ` )`,
396
519
  ``,
397
- ` // The one arm that is not a rename: the words belong to the requester, and the`,
398
- ` // trait's fact does not carry them, so they are put back on the way out.`,
399
- ` | ${n.requestCmd}({${id}, category, reference, subject, body}) =>`,
400
- ` through(state, ${id}, Request({category: categoryKey(category), reference}))`,
401
- ` ->Result.map(events =>`,
520
+ ` // The one arm that is not a rename: the words and the subject belong to the`,
521
+ ` // requester and the trait's facts carry neither, so both are put back on the`,
522
+ ` // way out. All three outcomes get the subject what a suppressed or`,
523
+ ` // undeliverable notification was about is what makes those rows worth reading.`,
524
+ ` | ${n.requestCmd}({`,
525
+ ` ${id},`,
526
+ ` category,`,
527
+ ` reference,`,
528
+ ` subjectType,`,
529
+ ` subjectRef,`,
530
+ ` subject,`,
531
+ ` body,`,
532
+ ` sourceId,`,
533
+ ` origin,`,
534
+ ` }) =>`,
535
+ ` through(`,
536
+ ` state,`,
537
+ ` ${id},`,
538
+ ` Request({`,
539
+ ` category: categoryKey(category),`,
540
+ ` reference,`,
541
+ ` source: sourceId,`,
542
+ ` // The trait needs only which of the two yields to a claim; which rule`,
543
+ ` // it was is this host's fact, put back below.`,
544
+ ` origin: switch origin {`,
545
+ ` | Default => Default`,
546
+ ` | Configured(_) => Configured`,
547
+ ` },`,
548
+ ` }),`,
549
+ ` )->Result.map(events =>`,
402
550
  ` events->Array.map(event =>`,
403
551
  ` switch event {`,
404
- ` | ${n.requested}(fields) => ${n.requested}({...fields, subject, body})`,
552
+ ` | ${n.requested}(fields) =>`,
553
+ ` ${n.requested}({...fields, subjectType, subjectRef, subject, body, origin})`,
554
+ ` | ${n.suppressed}(fields) =>`,
555
+ ` ${n.suppressed}({...fields, subjectType, subjectRef, origin})`,
556
+ ` | ${n.undeliverable}(fields) =>`,
557
+ ` ${n.undeliverable}({...fields, subjectType, subjectRef, origin})`,
405
558
  ` | other => other`,
406
559
  ` }`,
407
560
  ` )`,
@@ -421,6 +574,102 @@ let sliceBehavior = (c: config): string => {
421
574
  )
422
575
  }
423
576
 
577
+ // ── The claims slice ─────────────────────────────────────────────────────────
578
+ //
579
+ // Its own component, and that is forced rather than preferred: a slice's DCB
580
+ // partition is derived from the `*Id` fields its own events declare, so one slice
581
+ // producing both recipient facts and source facts has two candidate partitions
582
+ // and resolves to neither. Split, each side produces one key — and the reading
583
+ // side then sees `sourceId` as a key it consumes without producing, which is
584
+ // exactly the shape a cross-partition read is inferred from.
585
+
586
+ let claimsSpec = (c: config): string => {
587
+ let n = namesOf(c)
588
+ lines([
589
+ `// ${n.claims} StateChangeSlice: which streams of occurrences a second`,
590
+ `// producer has taken over. One row per source, keyed by the source itself —`,
591
+ `// \`"<log>:<eventType>"\`, opaque to everyone but the producers that agree on it.`,
592
+ `//`,
593
+ `// Emitted by the trait; ordinary source from here on.`,
594
+ ``,
595
+ `@@reventless.spec`,
596
+ ``,
597
+ `@schema`,
598
+ `type consumedEvent =`,
599
+ ` | ${n.claimed}({sourceId: string, by: string})`,
600
+ ` | ${n.released}({sourceId: string})`,
601
+ ``,
602
+ `@schema`,
603
+ `type command =`,
604
+ ` // Relayed, never a client door — a caller who could claim a source would be`,
605
+ ` // silencing everybody else's notifications from it.`,
606
+ ` | @noApi ${n.claimCmd}({sourceId: string, by: string})`,
607
+ ` | @noApi ${n.releaseCmd}({sourceId: string})`,
608
+ ``,
609
+ `@schema`,
610
+ `type error =`,
611
+ ` // Both commands are idempotent, so there is no refusal to make. Declared`,
612
+ ` // because the shape requires one.`,
613
+ ` | ${n.claimError}`,
614
+ ``,
615
+ `@schema`,
616
+ `type event =`,
617
+ ` | ${n.claimed}({sourceId: string, by: string})`,
618
+ ` | ${n.released}({sourceId: string})`,
619
+ ``,
620
+ `let traits = [TraitNotification.Notification.declaration]`,
621
+ ``,
622
+ ])
623
+ }
624
+
625
+ let claimsBehavior = (c: config): string => {
626
+ let n = namesOf(c)
627
+ lines([
628
+ `@@reventless.behavior`,
629
+ ``,
630
+ `// The claim set is the trait's, folded through the same value the preferences`,
631
+ `// slice folds. Only the claim half is ever populated here.`,
632
+ `module Rules = TraitNotification.Notification_Rules`,
633
+ ``,
634
+ `type state = Rules.t`,
635
+ ``,
636
+ `let initialState = Rules.empty`,
637
+ ``,
638
+ `let evolve = (state, event: consumedEvent) =>`,
639
+ ` switch event {`,
640
+ ` | ${n.claimed}({sourceId, by}) => state->Rules.evolve(Claimed({source: sourceId, by}))`,
641
+ ` | ${n.released}({sourceId}) => state->Rules.evolve(Released({source: sourceId}))`,
642
+ ` }`,
643
+ ``,
644
+ `// No posture is consulted on either arm, so this table is never read. Passing`,
645
+ `// the trait's own decision function anyway keeps the idempotence rule in one`,
646
+ `// place rather than restating it here.`,
647
+ `let posture = (_category: string, _channel: Rules.channel) => false`,
648
+ ``,
649
+ `let named = (fact: Rules.fact) =>`,
650
+ ` switch fact {`,
651
+ ` | Claimed({source, by}) => Some(${n.claimed}({sourceId: source, by}))`,
652
+ ` | Released({source}) => Some(${n.released}({sourceId: source}))`,
653
+ ` // The other facts belong to the preferences slice; the two commands below`,
654
+ ` // cannot reach them.`,
655
+ ` | _ => None`,
656
+ ` }`,
657
+ ``,
658
+ `let through = (state, op) =>`,
659
+ ` switch state->Rules.decide(op, ~posture) {`,
660
+ ` | Ok(facts) => Ok(facts->Array.filterMap(named))`,
661
+ ` | Error(#RecipientUnknown) => Error(${n.claimError})`,
662
+ ` }`,
663
+ ``,
664
+ `let decide = (state, command) =>`,
665
+ ` switch command {`,
666
+ ` | ${n.claimCmd}({sourceId, by}) => through(state, Claim({source: sourceId, by}))`,
667
+ ` | ${n.releaseCmd}({sourceId}) => through(state, Release({source: sourceId}))`,
668
+ ` }`,
669
+ ``,
670
+ ])
671
+ }
672
+
424
673
  // ── The send slice ───────────────────────────────────────────────────────────
425
674
 
426
675
  let sendSpec = (c: config): string => {
@@ -514,13 +763,20 @@ let sendTranslation = (c: config): string => {
514
763
  ` item.address`,
515
764
  ` ->Reventless.Phone.fromString`,
516
765
  ` ->Result.map(phone => Reventless.Messaging.ToSms(phone))`,
517
- ` | Push => Ok(Reventless.Messaging.ToPush({deviceToken: item.address}))`,
766
+ ` // The directory stores a channel beside one flat address, and no push address is`,
767
+ ` // one string: two services issue a token, Web Push issues an endpoint with its`,
768
+ ` // encryption keys, and which service issued this one is not recorded either. So`,
769
+ ` // the pair cannot be re-fused, and refusing says so once rather than guessing a`,
770
+ ` // service and failing at whichever provider. A push transport arrives with the`,
771
+ ` // stored shape it needs, in the commit that justifies the columns.`,
772
+ ` | Push =>`,
773
+ ` Error("a push address names its issuing service, and the directory stores one flat address")`,
518
774
  ` }`,
519
775
  ``,
520
776
  `let translate = async (_id, item: outboundItem, ~capabilities: Reventless.Capabilities.t) =>`,
521
777
  ` switch recipientFor(item) {`,
522
- ` // An address the directory holds that its own channel's grammar refuses. Not`,
523
- ` // retryable and not the provider's fault — the row that holds it needs fixing.`,
778
+ ` // An address the directory holds that its own channel cannot use. Not retryable`,
779
+ ` // and not the provider's fault — the row that holds it needs fixing.`,
524
780
  ` | Error(why) =>`,
525
781
  ` Ok(`,
526
782
  ` Some((`,
@@ -608,20 +864,27 @@ let conformanceBinding = (c: config): string => {
608
864
  ` module Spec = ${n.slice}`,
609
865
  ` module Behavior = ${n.slice}_Behavior`,
610
866
  ``,
611
- ` // A DCB slice's entity comes into existence with its first fact, so there is`,
612
- ` // no creation event to seed: an unannounced ${c.noun->String.toLowerCase} is one with no history.`,
613
- ` let created: array<Spec.consumedEvent> = []`,
614
- ``,
615
867
  ` let ${id} = "${c.noun->String.toLowerCase}-1"`,
616
868
  ``,
617
869
  ` // Annotated: this slice reads back exactly what it writes, so each of these`,
618
870
  ` // names a constructor of both unions and the later declaration would win.`,
619
- ` let announcedC = (${c.contactField}): Spec.consumedEvent =>`,
620
- ` ${n.announced}({${id}: ${id}, ${c.contactField}: ${c.contactField}})`,
621
- ` let subscribedC = (category, channel): Spec.consumedEvent =>`,
622
- ` ${n.subscribed}({${id}: ${id}, category, channel: Behavior.channelOf(channel)})`,
623
- ` let unsubscribedC = (category, channel): Spec.consumedEvent =>`,
624
- ` ${n.unsubscribed}({${id}: ${id}, category, channel: Behavior.channelOf(channel)})`,
871
+ ` module Consumed = {`,
872
+ ` // A DCB slice's entity comes into existence with its first fact, so there`,
873
+ ` // is no creation event to seed: an unannounced ${c.noun->String.toLowerCase} is one with no`,
874
+ ` // history.`,
875
+ ` let created: array<Spec.consumedEvent> = []`,
876
+ ` let announced = (${c.contactField}): Spec.consumedEvent =>`,
877
+ ` ${n.announced}({${id}: ${id}, ${c.contactField}: ${c.contactField}})`,
878
+ ` let subscribed = (category, channel): Spec.consumedEvent =>`,
879
+ ` ${n.subscribed}({${id}: ${id}, category, channel: Behavior.channelOf(channel)})`,
880
+ ` let unsubscribed = (category, channel): Spec.consumedEvent =>`,
881
+ ` ${n.unsubscribed}({${id}: ${id}, category, channel: Behavior.channelOf(channel)})`,
882
+ ` // Written by ${n.claims} and read here across partitions — this slice`,
883
+ ` // consumes them and produces neither.`,
884
+ ` let claimed = (source, by): Spec.consumedEvent =>`,
885
+ ` ${n.claimed}({sourceId: source, by})`,
886
+ ` let released = (source): Spec.consumedEvent => ${n.released}({sourceId: source})`,
887
+ ` }`,
625
888
  ``,
626
889
  ` let announce = ${c.contactField} =>`,
627
890
  ` Spec.${n.announceCmd}({${id}: ${id}, ${c.contactField}: ${c.contactField}})`,
@@ -629,37 +892,86 @@ let conformanceBinding = (c: config): string => {
629
892
  ` Spec.${n.subscribeCmd}({${id}: ${id}, category, channel: Behavior.channelOf(channel)})`,
630
893
  ` let unsubscribe = (category, channel) =>`,
631
894
  ` Spec.${n.unsubscribeCmd}({${id}: ${id}, category, channel: Behavior.channelOf(channel)})`,
632
- ` // The wording is this host's and the trait carries none, so the suite supplies`,
633
- ` // whatever it likes and asserts nothing about it.`,
634
- ` let request = (category, reference) =>`,
895
+ ` // The wording and the subject are this host's and the trait carries neither, so`,
896
+ ` // the suite supplies whatever it likes and asserts nothing about them.`,
897
+ ` //`,
898
+ ` // One source nothing ever claims, so every assertion that predates the handover`,
899
+ ` // keeps deciding exactly as it did.`,
900
+ ` let defaultSource = "TODO(graft)DcbEventLog:${c.occurrence}"`,
901
+ ``,
902
+ ` let requestFrom = (category, reference, ~source, ~origin) =>`,
635
903
  ` Spec.${n.requestCmd}({`,
636
904
  ` ${id}: ${id},`,
637
905
  ` category,`,
638
906
  ` reference,`,
907
+ ` subjectType: "${subjectTypeOf(c)}",`,
908
+ ` subjectRef: "subject-1",`,
639
909
  ` subject: "subject",`,
640
910
  ` body: "body",`,
911
+ ` sourceId: source,`,
912
+ ` origin: switch (origin: TraitNotification.Notification_Rules.origin) {`,
913
+ ` | Default => Default`,
914
+ ` | Configured => Configured({ruleId: "rule-1", ruleVersion: "1"})`,
915
+ ` },`,
641
916
  ` })`,
642
917
  ``,
918
+ ` let request = (category, reference) =>`,
919
+ ` requestFrom(category, reference, ~source=defaultSource, ~origin=Default)`,
920
+ ``,
643
921
  ` let announced = ${c.contactField} =>`,
644
922
  ` Spec.${n.announced}({${id}: ${id}, ${c.contactField}: ${c.contactField}})`,
645
923
  ` let subscribed = (category, channel) =>`,
646
924
  ` Spec.${n.subscribed}({${id}: ${id}, category, channel: Behavior.channelOf(channel)})`,
647
925
  ` let unsubscribed = (category, channel) =>`,
648
926
  ` Spec.${n.unsubscribed}({${id}: ${id}, category, channel: Behavior.channelOf(channel)})`,
649
- ` let requested = (category, reference, channel, address) =>`,
927
+ ` let requestedWith = (category, reference, channel, address, origin) =>`,
650
928
  ` Spec.${n.requested}({`,
651
929
  ` ${id}: ${id},`,
652
930
  ` category,`,
653
931
  ` reference,`,
654
932
  ` channel: Behavior.channelOf(channel),`,
655
933
  ` address,`,
934
+ ` subjectType: "${subjectTypeOf(c)}",`,
935
+ ` subjectRef: "subject-1",`,
656
936
  ` subject: "subject",`,
657
937
  ` body: "body",`,
938
+ ` origin,`,
658
939
  ` })`,
940
+ ``,
941
+ ` let requested = (category, reference, channel, address) =>`,
942
+ ` requestedWith(category, reference, channel, address, Spec.Default)`,
943
+ ` // Which rule asked is this host's own shape, so the trait cannot build this.`,
944
+ ` let requestedConfigured = (category, reference, channel, address) =>`,
945
+ ` requestedWith(`,
946
+ ` category,`,
947
+ ` reference,`,
948
+ ` channel,`,
949
+ ` address,`,
950
+ ` Spec.Configured({ruleId: "rule-1", ruleVersion: "1"}),`,
951
+ ` )`,
952
+ ` let deferred = (reference, source) =>`,
953
+ ` Spec.${n.deferred}({${id}: ${id}, reference, sourceKey: source})`,
954
+ ` // The subject rides through the two decisions not to send as well: what a`,
955
+ ` // suppressed or undeliverable notification was about is the whole reason those`,
956
+ ` // rows are worth reading.`,
659
957
  ` let suppressed = (category, reference) =>`,
660
- ` Spec.${n.suppressed}({${id}: ${id}, category, reference})`,
958
+ ` Spec.${n.suppressed}({`,
959
+ ` ${id}: ${id},`,
960
+ ` category,`,
961
+ ` reference,`,
962
+ ` subjectType: "${subjectTypeOf(c)}",`,
963
+ ` subjectRef: "subject-1",`,
964
+ ` origin: Spec.Default,`,
965
+ ` })`,
661
966
  ` let undeliverable = (category, reference) =>`,
662
- ` Spec.${n.undeliverable}({${id}: ${id}, category, reference})`,
967
+ ` Spec.${n.undeliverable}({`,
968
+ ` ${id}: ${id},`,
969
+ ` category,`,
970
+ ` reference,`,
971
+ ` subjectType: "${subjectTypeOf(c)}",`,
972
+ ` subjectRef: "subject-1",`,
973
+ ` origin: Spec.Default,`,
974
+ ` })`,
663
975
  ``,
664
976
  ` let recipientUnknown = Spec.${n.unknownError}`,
665
977
  ``,
@@ -690,7 +1002,7 @@ let contactRelayPatch = (c: config): patch => {
690
1002
  let n = namesOf(c)
691
1003
  let id = n.recipientId
692
1004
  {
693
- into: `${c.chapter}/OutboundTranslationSlice/${n.relay}.res (new, plus its _Translation)`,
1005
+ into: `${c.chapter}/OutboundTranslation/${n.relay}.res (new, plus its _Translation)`,
694
1006
  at: `a new file — the trait cannot write what this host's events mean`,
695
1007
  contents: lines(
696
1008
  Array.flatMap(
@@ -770,15 +1082,21 @@ let intakeRelayPatch = (c: config): patch => {
770
1082
  let n = namesOf(c)
771
1083
  let id = n.recipientId
772
1084
  {
773
- into: `${c.chapter}/AutomationSlice/${n.intake}.res (new, plus its _Automation)`,
1085
+ into: `${c.chapter}/Automation/${n.intake}.res (new, plus its _Automation)`,
774
1086
  at: `a new file — the wording is this host's sentence, not the trait's`,
775
1087
  contents: lines([
776
1088
  `// Turns one of this host's occurrences into a request to notify somebody.`,
777
1089
  ``,
778
1090
  `@@reventless.spec`,
779
1091
  ``,
1092
+ `// The row a rule composes from. It carries the rule's id rather than a kind of`,
1093
+ `// occurrence: the rule holds the category, the subject and the wording, so a`,
1094
+ `// second notifiable event is a second entry in the table next door.`,
1095
+ `//`,
1096
+ `// It is also the payload the wording is rendered against, so every field here`,
1097
+ `// is a path a template may name — \`{{ ${c.occurrenceId} }}\`.`,
780
1098
  `@schema`,
781
- `type todoItem = {${id}: string, ${c.occurrenceId}: string}`,
1099
+ `type todoItem = {ruleId: string, ${id}: string, ${c.occurrenceId}: string}`,
782
1100
  ``,
783
1101
  `@schema`,
784
1102
  `type command =`,
@@ -786,8 +1104,12 @@ let intakeRelayPatch = (c: config): patch => {
786
1104
  ` ${id}: string,`,
787
1105
  ` category: ${n.slice}.category,`,
788
1106
  ` reference: string,`,
1107
+ ` subjectType: string,`,
1108
+ ` subjectRef: string,`,
789
1109
  ` subject: string,`,
790
1110
  ` body: string,`,
1111
+ ` sourceId: string,`,
1112
+ ` origin: ${n.slice}.origin,`,
791
1113
  ` })`,
792
1114
  ``,
793
1115
  `let maxRetries = 3`,
@@ -798,9 +1120,7 @@ let intakeRelayPatch = (c: config): patch => {
798
1120
  ``,
799
1121
  `@@reventless.automation`,
800
1122
  ``,
801
- `// A TODO id is also the reference the request carries, so the outcome event`,
802
- `// echoes back exactly what resolves the row.`,
803
- `let key = ${c.occurrenceId} => \`notify:\${${c.occurrenceId}}\``,
1123
+ `module Rule = TraitNotification.Notification_Rule`,
804
1124
  ``,
805
1125
  `module DcbSource = {`,
806
1126
  ` // MUST equal "<pluginName>DcbEventLog".`,
@@ -814,32 +1134,73 @@ let intakeRelayPatch = (c: config): patch => {
814
1134
  ` | ${n.requested}({reference: string})`,
815
1135
  ` | ${n.suppressed}({reference: string})`,
816
1136
  ` | ${n.undeliverable}({reference: string})`,
1137
+ ` // The fourth is not a nicety: an unresolved row retries its whole budget`,
1138
+ ` // and lands in \`onExhausted\`, so a handover would look like a slow failure`,
1139
+ ` // with nothing in the log to explain it.`,
1140
+ ` | ${n.deferred}({reference: string})`,
817
1141
  `}`,
818
1142
  ``,
1143
+ `// TODO(graft): the wording. A trait declares the kind; what the sentence says`,
1144
+ `// is this host's — and it is text rather than ReScript, rendered against the`,
1145
+ `// todo item next door, so \`{{ ${c.occurrenceId} }}\` is a field of it.`,
1146
+ `//`,
1147
+ `// A rule's id is also the namespace of the references it writes, so two rules`,
1148
+ `// on one subject are two notifications and two delivery rows.`,
1149
+ `let defaultRules: array<Rule.t> = [`,
1150
+ ` {`,
1151
+ ` id: "${c.occurrence}",`,
1152
+ ` version: "1",`,
1153
+ ` source: {log: DcbSource.name, eventType: "${c.occurrence}"},`,
1154
+ ` filter: Always,`,
1155
+ ` category: "${c.occurrenceCategory}",`,
1156
+ ` delivery: Immediate,`,
1157
+ ` recipientPath: "${id}",`,
1158
+ ` subjectType: "${subjectTypeOf(c)}",`,
1159
+ ` subjectPath: "${c.occurrenceId}",`,
1160
+ ` content: [{locale: "en", subject: "TODO(graft)", body: "TODO(graft)"}],`,
1161
+ ` },`,
1162
+ `]`,
1163
+ ``,
1164
+ `// The dispatch is the table's, so the switch below only takes an event apart.`,
1165
+ `// Two rules on one event type are two notifications, with no arm to add.`,
1166
+ `//`,
1167
+ `// Only the immediate half of the table: a digest rule's occurrences are`,
1168
+ `// gathered by the component that sends the digest, so no row is opened here.`,
1169
+ `let todosFor = (~eventType, ~${id}, ~${c.occurrenceId}) =>`,
1170
+ ` defaultRules`,
1171
+ ` ->Rule.forEvent(~log=DcbSource.name, ~eventType)`,
1172
+ ` ->Array.filter(Rule.isImmediate)`,
1173
+ ` ->Array.map(rule => (`,
1174
+ ` Rule.reference(rule, ~subject=${c.occurrenceId}),`,
1175
+ ` ({ruleId: rule.id, ${id}: ${id}, ${c.occurrenceId}: ${c.occurrenceId}}: ${n.intake}.todoItem),`,
1176
+ ` ))`,
1177
+ ``,
819
1178
  `module FromDcb = Mapping.Make(`,
820
1179
  ` DcbSource,`,
821
1180
  ` ${n.intake},`,
822
1181
  ` {`,
823
1182
  ` open DcbSource`,
824
1183
  ``,
825
- ` let collect = (event, _ctx) =>`,
1184
+ ` let collect = (event, ~sourceId as _, _ctx) =>`,
826
1185
  ` switch event {`,
827
- ` | ${c.occurrence}({${c.occurrenceId}, ${c.occurrenceRecipient}}) => [`,
828
- ` (`,
829
- ` key(${c.occurrenceId}),`,
830
- ` ({${id}: ${c.occurrenceRecipient}, ${c.occurrenceId}: ${c.occurrenceId}}: ${n.intake}.todoItem),`,
831
- ` ),`,
832
- ` ]`,
1186
+ ` | ${c.occurrence}({${c.occurrenceId}, ${c.occurrenceRecipient}}) =>`,
1187
+ ` todosFor(`,
1188
+ ` ~eventType="${c.occurrence}",`,
1189
+ ` ~${id}=${c.occurrenceRecipient},`,
1190
+ ` ~${c.occurrenceId},`,
1191
+ ` )`,
833
1192
  ` | ${n.requested}(_)`,
834
1193
  ` | ${n.suppressed}(_)`,
835
- ` | ${n.undeliverable}(_) => []`,
1194
+ ` | ${n.undeliverable}(_)`,
1195
+ ` | ${n.deferred}(_) => []`,
836
1196
  ` }`,
837
1197
  ``,
838
1198
  ` let resolve = event =>`,
839
1199
  ` switch event {`,
840
1200
  ` | ${n.requested}({reference})`,
841
1201
  ` | ${n.suppressed}({reference})`,
842
- ` | ${n.undeliverable}({reference}) =>`,
1202
+ ` | ${n.undeliverable}({reference})`,
1203
+ ` | ${n.deferred}({reference}) =>`,
843
1204
  ` Some(reference)`,
844
1205
  ` | ${c.occurrence}(_) => None`,
845
1206
  ` }`,
@@ -848,19 +1209,41 @@ let intakeRelayPatch = (c: config): patch => {
848
1209
  ``,
849
1210
  `let mappings: array<module(Mapping)> = [module(FromDcb)]`,
850
1211
  ``,
851
- `// TODO(graft): the wording. A trait declares the kind; what the sentence says`,
852
- `// is this host's, and a config field for it would be a template language.`,
853
1212
  `let process = (id, item: ${n.intake}.todoItem) =>`,
854
- ` Some((`,
855
- ` item.${id},`,
856
- ` ${n.intake}.${n.requestCmd}({`,
857
- ` ${id}: item.${id},`,
858
- ` category: ${c.occurrenceCategory},`,
859
- ` reference: id,`,
860
- ` subject: "TODO(graft)",`,
861
- ` body: "TODO(graft)",`,
862
- ` }),`,
863
- ` ))`,
1213
+ ` // Two ways the table can have moved under a row that was already open: the`,
1214
+ ` // rule is gone, or it has become a digest's. Neither publishes anything, and`,
1215
+ ` // either leaves the row Pending — a \`None\` here spends no retry budget, so it`,
1216
+ ` // is re-swept on every batch and never reaches \`onExhausted\`.`,
1217
+ ` switch defaultRules->Rule.byId(item.ruleId)->Option.filter(Rule.isImmediate) {`,
1218
+ ` | None => None`,
1219
+ ` | Some(rule) =>`,
1220
+ ` let payload = item->Reventless.Util_Sury.toJson(${n.intake}.todoItemSchema)`,
1221
+ ` switch (Rule.matches(rule.filter, ~payload), Rule.recipientOf(rule, ~payload)) {`,
1222
+ ` | (false, _) | (_, None) => None`,
1223
+ ` | (true, Some(${id})) =>`,
1224
+ ` let (subject, body) =`,
1225
+ ` Rule.compose(rule, ~payload, ~schema=${n.intake}.todoItemSchema)`,
1226
+ ` Some((`,
1227
+ ` ${id},`,
1228
+ ` ${n.intake}.${n.requestCmd}({`,
1229
+ ` ${id}: ${id},`,
1230
+ ` category: ${n.slice}_Behavior.categoryOf(rule.category),`,
1231
+ ` reference: id,`,
1232
+ ` subjectType: rule.subjectType,`,
1233
+ ` subjectRef: Rule.subjectOf(rule, ~payload),`,
1234
+ ` subject,`,
1235
+ ` body,`,
1236
+ ` // Which stream this came from. A second producer claims exactly this`,
1237
+ ` // string to take the entry over, so both sides must derive it the same`,
1238
+ ` // way — the format is the whole agreement.`,
1239
+ ` sourceId: Rule.sourceId(rule),`,
1240
+ ` // This relay IS the compiled table, so every request it makes is the`,
1241
+ ` // default one — the arm that yields when somebody claims the source.`,
1242
+ ` origin: Default,`,
1243
+ ` }),`,
1244
+ ` ))`,
1245
+ ` }`,
1246
+ ` }`,
864
1247
  ``,
865
1248
  `// A relay that gave up published no command, so the competency never heard of`,
866
1249
  `// the occurrence — a delivery-failed fact for a message nobody requested would`,
@@ -880,11 +1263,16 @@ let emit = (~config: config, ~into: string, ~tests: string): output => {
880
1263
  let n = namesOf(config)
881
1264
  {
882
1265
  files: [
883
- {path: `${into}/StateChangeSlice/${n.slice}.res`, contents: sliceSpec(config)},
884
- {path: `${into}/StateChangeSlice/${n.slice}_Behavior.res`, contents: sliceBehavior(config)},
885
- {path: `${into}/OutboundTranslationSlice/${n.send}.res`, contents: sendSpec(config)},
1266
+ {path: `${into}/StateChange/${n.slice}.res`, contents: sliceSpec(config)},
1267
+ {path: `${into}/StateChange/${n.slice}_Behavior.res`, contents: sliceBehavior(config)},
1268
+ {path: `${into}/StateChange/${n.claims}.res`, contents: claimsSpec(config)},
1269
+ {
1270
+ path: `${into}/StateChange/${n.claims}_Behavior.res`,
1271
+ contents: claimsBehavior(config),
1272
+ },
1273
+ {path: `${into}/OutboundTranslation/${n.send}.res`, contents: sendSpec(config)},
886
1274
  {
887
- path: `${into}/OutboundTranslationSlice/${n.send}_Translation.res`,
1275
+ path: `${into}/OutboundTranslation/${n.send}_Translation.res`,
888
1276
  contents: sendTranslation(config),
889
1277
  },
890
1278
  {path: `${tests}/NotificationConformance_GWT.res`, contents: conformanceBinding(config)},