@smthrs/notifications 0.0.0-stage → 1.0.0-rc.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/LICENSE +21 -0
  3. package/README.md +93 -2
  4. package/dist/cjs/AlertPolicy.d.ts +284 -0
  5. package/dist/cjs/AlertPolicy.d.ts.map +1 -0
  6. package/dist/cjs/AlertPolicy.js +124 -0
  7. package/dist/cjs/AlertPolicy.js.map +7 -0
  8. package/dist/cjs/AlertRuntime.d.ts +90 -0
  9. package/dist/cjs/AlertRuntime.d.ts.map +1 -0
  10. package/dist/cjs/AlertRuntime.js +166 -0
  11. package/dist/cjs/AlertRuntime.js.map +7 -0
  12. package/dist/cjs/AlertSink.d.ts +101 -0
  13. package/dist/cjs/AlertSink.d.ts.map +1 -0
  14. package/dist/cjs/AlertSink.js +44 -0
  15. package/dist/cjs/AlertSink.js.map +7 -0
  16. package/dist/cjs/Alerts.d.ts +57 -0
  17. package/dist/cjs/Alerts.d.ts.map +1 -0
  18. package/dist/cjs/Alerts.js +46 -0
  19. package/dist/cjs/Alerts.js.map +7 -0
  20. package/dist/cjs/Notification.d.ts +214 -0
  21. package/dist/cjs/Notification.d.ts.map +1 -0
  22. package/dist/cjs/Notification.js +62 -0
  23. package/dist/cjs/Notification.js.map +7 -0
  24. package/dist/cjs/NotificationEvent.d.ts +214 -0
  25. package/dist/cjs/NotificationEvent.d.ts.map +1 -0
  26. package/dist/cjs/NotificationEvent.js +84 -0
  27. package/dist/cjs/NotificationEvent.js.map +7 -0
  28. package/dist/cjs/NotificationQueue.d.ts +194 -0
  29. package/dist/cjs/NotificationQueue.d.ts.map +1 -0
  30. package/dist/cjs/NotificationQueue.js +477 -0
  31. package/dist/cjs/NotificationQueue.js.map +7 -0
  32. package/dist/cjs/NotificationState.d.ts +169 -0
  33. package/dist/cjs/NotificationState.d.ts.map +1 -0
  34. package/dist/cjs/NotificationState.js +110 -0
  35. package/dist/cjs/NotificationState.js.map +7 -0
  36. package/dist/cjs/Projection.d.ts +30 -0
  37. package/dist/cjs/Projection.d.ts.map +1 -0
  38. package/dist/cjs/Projection.js +53 -0
  39. package/dist/cjs/Projection.js.map +7 -0
  40. package/dist/cjs/SteerPayload.d.ts +177 -0
  41. package/dist/cjs/SteerPayload.d.ts.map +1 -0
  42. package/dist/cjs/SteerPayload.js +61 -0
  43. package/dist/cjs/SteerPayload.js.map +7 -0
  44. package/dist/cjs/index.d.ts +41 -0
  45. package/dist/cjs/index.d.ts.map +1 -0
  46. package/dist/cjs/index.js +47 -0
  47. package/dist/cjs/index.js.map +7 -0
  48. package/dist/cjs/internal/foldCache.d.ts +36 -0
  49. package/dist/cjs/internal/foldCache.d.ts.map +1 -0
  50. package/dist/cjs/internal/foldCache.js +54 -0
  51. package/dist/cjs/internal/foldCache.js.map +7 -0
  52. package/dist/cjs/internal/replay.d.ts +22 -0
  53. package/dist/cjs/internal/replay.d.ts.map +1 -0
  54. package/dist/cjs/internal/replay.js +46 -0
  55. package/dist/cjs/internal/replay.js.map +7 -0
  56. package/dist/cjs/layerWebhook.d.ts +60 -0
  57. package/dist/cjs/layerWebhook.d.ts.map +1 -0
  58. package/dist/cjs/layerWebhook.js +145 -0
  59. package/dist/cjs/layerWebhook.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/esm/AlertPolicy.d.ts +284 -0
  62. package/dist/esm/AlertPolicy.d.ts.map +1 -0
  63. package/dist/esm/AlertPolicy.js +284 -0
  64. package/dist/esm/AlertPolicy.js.map +1 -0
  65. package/dist/esm/AlertRuntime.d.ts +90 -0
  66. package/dist/esm/AlertRuntime.d.ts.map +1 -0
  67. package/dist/esm/AlertRuntime.js +191 -0
  68. package/dist/esm/AlertRuntime.js.map +1 -0
  69. package/dist/esm/AlertSink.d.ts +101 -0
  70. package/dist/esm/AlertSink.d.ts.map +1 -0
  71. package/dist/esm/AlertSink.js +63 -0
  72. package/dist/esm/AlertSink.js.map +1 -0
  73. package/dist/esm/Alerts.d.ts +57 -0
  74. package/dist/esm/Alerts.d.ts.map +1 -0
  75. package/dist/esm/Alerts.js +57 -0
  76. package/dist/esm/Alerts.js.map +1 -0
  77. package/dist/esm/Notification.d.ts +214 -0
  78. package/dist/esm/Notification.d.ts.map +1 -0
  79. package/dist/esm/Notification.js +121 -0
  80. package/dist/esm/Notification.js.map +1 -0
  81. package/dist/esm/NotificationEvent.d.ts +214 -0
  82. package/dist/esm/NotificationEvent.d.ts.map +1 -0
  83. package/dist/esm/NotificationEvent.js +158 -0
  84. package/dist/esm/NotificationEvent.js.map +1 -0
  85. package/dist/esm/NotificationQueue.d.ts +194 -0
  86. package/dist/esm/NotificationQueue.d.ts.map +1 -0
  87. package/dist/esm/NotificationQueue.js +625 -0
  88. package/dist/esm/NotificationQueue.js.map +1 -0
  89. package/dist/esm/NotificationState.d.ts +169 -0
  90. package/dist/esm/NotificationState.d.ts.map +1 -0
  91. package/dist/esm/NotificationState.js +191 -0
  92. package/dist/esm/NotificationState.js.map +1 -0
  93. package/dist/esm/Projection.d.ts +30 -0
  94. package/dist/esm/Projection.d.ts.map +1 -0
  95. package/dist/esm/Projection.js +47 -0
  96. package/dist/esm/Projection.js.map +1 -0
  97. package/dist/esm/SteerPayload.d.ts +177 -0
  98. package/dist/esm/SteerPayload.d.ts.map +1 -0
  99. package/dist/esm/SteerPayload.js +139 -0
  100. package/dist/esm/SteerPayload.js.map +1 -0
  101. package/dist/esm/index.d.ts +41 -0
  102. package/dist/esm/index.d.ts.map +1 -0
  103. package/dist/esm/index.js +41 -0
  104. package/dist/esm/index.js.map +1 -0
  105. package/dist/esm/internal/foldCache.d.ts +36 -0
  106. package/dist/esm/internal/foldCache.d.ts.map +1 -0
  107. package/dist/esm/internal/foldCache.js +58 -0
  108. package/dist/esm/internal/foldCache.js.map +1 -0
  109. package/dist/esm/internal/replay.d.ts +22 -0
  110. package/dist/esm/internal/replay.d.ts.map +1 -0
  111. package/dist/esm/internal/replay.js +31 -0
  112. package/dist/esm/internal/replay.js.map +1 -0
  113. package/dist/esm/layerWebhook.d.ts +60 -0
  114. package/dist/esm/layerWebhook.d.ts.map +1 -0
  115. package/dist/esm/layerWebhook.js +186 -0
  116. package/dist/esm/layerWebhook.js.map +1 -0
  117. package/docs/README.md +146 -0
  118. package/docs/api.md +443 -0
  119. package/docs/concepts/admission-and-promotion.md +144 -0
  120. package/docs/concepts/alerting.md +138 -0
  121. package/docs/concepts/journal-records.md +132 -0
  122. package/docs/guides/alert-on-a-stuck-run.md +140 -0
  123. package/docs/guides/drain-at-a-turn-boundary.md +95 -0
  124. package/docs/guides/handle-a-full-queue.md +102 -0
  125. package/docs/guides/report-pending-notifications.md +92 -0
  126. package/docs/guides/send-alerts-to-a-webhook.md +155 -0
  127. package/docs/guides/steer-a-run.md +127 -0
  128. package/docs/guides/testing.md +161 -0
  129. package/docs/installation.md +123 -0
  130. package/docs/quickstart.md +159 -0
  131. package/docs/troubleshooting.md +254 -0
  132. package/package.json +182 -3
  133. package/src/AlertPolicy.ts +377 -0
  134. package/src/AlertRuntime.ts +280 -0
  135. package/src/AlertSink.ts +105 -0
  136. package/src/Alerts.ts +71 -0
  137. package/src/Notification.ts +171 -0
  138. package/src/NotificationEvent.ts +213 -0
  139. package/src/NotificationQueue.ts +856 -0
  140. package/src/NotificationState.ts +295 -0
  141. package/src/Projection.ts +48 -0
  142. package/src/SteerPayload.ts +198 -0
  143. package/src/index.ts +47 -0
  144. package/src/internal/foldCache.ts +72 -0
  145. package/src/internal/replay.ts +39 -0
  146. package/src/layerWebhook.ts +239 -0
package/docs/api.md ADDED
@@ -0,0 +1,443 @@
1
+ ---
2
+ title: "API reference"
3
+ description: "Every public export of @smthrs/notifications: the queue service and its layers, the notification and event schemas, the pure state machine, the steering vocabulary, and the alert policy."
4
+ ---
5
+
6
+ Every module is importable as a namespace from the root entry point and from its
7
+ own subpath:
8
+
9
+ ```ts
10
+ import { Alerts, Notification, NotificationQueue } from "@smthrs/notifications"
11
+ import * as SteerPayload from "@smthrs/notifications/SteerPayload"
12
+ ```
13
+
14
+ Types written below as `Schema` are `effect` schemas; each one exports a value
15
+ and a type of the same name, and the type is the decoded form.
16
+
17
+ ## Example
18
+
19
+ ```ts
20
+ import { NotificationQueue } from "@smthrs/notifications"
21
+
22
+ const deliver = Effect.gen(function*() {
23
+ const queue = yield* NotificationQueue.NotificationQueue
24
+ const receipt = yield* queue.drain({
25
+ runId: "run-1",
26
+ targetLineageId: "run-1/root",
27
+ boundary: "turn-1",
28
+ wouldIdle: true
29
+ })
30
+ return receipt.notifications
31
+ }).pipe(Effect.provide(NotificationQueue.layer))
32
+ ```
33
+
34
+ ## NotificationQueue
35
+
36
+ Journal-backed durable admission and turn-boundary drain. Import from
37
+ `@smthrs/notifications/NotificationQueue`.
38
+
39
+ ### Service
40
+
41
+ ```ts
42
+ interface Service {
43
+ readonly admit: (
44
+ runId: string,
45
+ notification: Notification.Notification,
46
+ version?: number
47
+ ) => Effect.Effect<AdmissionReceipt, Journal.JournalError | NotificationError>
48
+ readonly drain: (
49
+ input: DrainInput
50
+ ) => Effect.Effect<DrainReceipt, Journal.JournalError | NotificationError>
51
+ readonly pending: (
52
+ runId: string
53
+ ) => Effect.Effect<ReadonlyArray<Notification.Notification>, Journal.JournalError | NotificationError>
54
+ }
55
+ ```
56
+
57
+ | Method | What it does |
58
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
59
+ | `admit(runId, notification)` | Decodes the notification, snapshots it, and records one admission decision for `runId`. Idempotent on `notification.id`. |
60
+ | `drain(input)` | Promotes what the boundary named by `input` may deliver, and records the promotion. First writer wins on the drain identity. |
61
+ | `pending(runId)` | Returns the run's admitted but unpromoted notifications, in admission order, across every lineage. |
62
+
63
+ `NotificationQueue` is the service tag, a `Context.Service` under the key
64
+ `/notifications/NotificationQueue`.
65
+
66
+ New admissions persist a SHA-256 fingerprint of the validated notification's
67
+ canonical JSON before journal redaction. Identical retries return the committed
68
+ receipt even when redaction changed the stored payload. Different content still
69
+ fails with `notification_id_reused`, including changes to redacted fields.
70
+ Legacy admissions without a fingerprint compare against their persisted content;
71
+ original values removed by redaction cannot be recovered.
72
+
73
+ The journal-backed layers deeply freeze decoded notifications, including nested
74
+ payload objects and arrays, before `pending` or `drain` exposes them. Outputs
75
+ carry the persisted, possibly redacted content. Consumer mutation cannot change
76
+ later reads or drain receipts.
77
+
78
+ `admit` and `drain` may run inside an enclosing `journal.transact` while
79
+ standalone queue calls are pending. Both acquire the journal transaction before
80
+ the queue permit. Shared folds are published only after the enclosing commit.
81
+
82
+ The fold reads only Admitted/Promoted event types through `Journal.entries` and
83
+ keeps the last matching canonical sequence as its cursor. Unrelated run events
84
+ do not require decoding or advance that cursor. A compaction floor that passes
85
+ the cursor causes a conservative `JournalError(compacted)` refusal, including
86
+ for a warm queue. Deployments that compact active notification histories need
87
+ a queue-aware retention or checkpoint policy; the current production
88
+ compositions do not enable compaction.
89
+
90
+ Admission refuses objects or arrays at depth 256 with `notification_invalid`
91
+ and writes no row. The notification envelope is depth 0; its payload is
92
+ at depth 1. Cyclic payloads receive the same typed refusal.
93
+
94
+ Admission also refuses a notification larger than 1,048,576 UTF-16 code units
95
+ with `notification_invalid` and writes no row. Every string and object key
96
+ counts at its length and every other value counts as one. The bound is checked
97
+ before the notification is copied, decoded, or fingerprinted.
98
+
99
+ ### AdmissionReceipt
100
+
101
+ ```ts
102
+ interface AdmissionReceipt {
103
+ readonly notificationId: string
104
+ readonly decision: NotificationState.AdmissionDecision
105
+ readonly seq: number | undefined
106
+ readonly duplicate: boolean
107
+ }
108
+ ```
109
+
110
+ | Field | Meaning |
111
+ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
112
+ | `notificationId` | The `id` of the notification this receipt answers for. |
113
+ | `decision` | `admitted`, `coalesced`, or `rejected-full`. A caller MUST read it: `rejected-full` means the queue retained nothing. |
114
+ | `seq` | The journal sequence the admission committed at. Absent exactly when nothing was written, which today means `rejected-full`. |
115
+ | `duplicate` | The id had already been admitted. `decision` and `seq` are read back from the committed record rather than recomputed. |
116
+
117
+ ### DrainInput
118
+
119
+ ```ts
120
+ interface DrainInput {
121
+ readonly runId: string
122
+ readonly targetLineageId: string
123
+ readonly boundary: string
124
+ readonly wouldIdle: boolean
125
+ readonly cutoffSeq?: number | undefined
126
+ }
127
+ ```
128
+
129
+ | Field | Meaning |
130
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | `runId` | The run whose journal holds the notifications. |
132
+ | `targetLineageId` | The lineage this boundary belongs to. Notifications for other lineages are left pending. |
133
+ | `boundary` | The name of this safe point. With `runId` and `targetLineageId` it is the drain identity. |
134
+ | `wouldIdle` | Whether the run would have nothing to do if this boundary delivered nothing. When true and no steer was promoted, one queued notification is promoted. |
135
+ | `cutoffSeq` | The journal sequence that opened this turn. Steers admitted after it are held for the next boundary. Omitting it delivers everything pending for the lineage. A value that is not a non-negative integer fails with `notification_invalid`. |
136
+
137
+ ### DrainReceipt
138
+
139
+ ```ts
140
+ interface DrainReceipt {
141
+ readonly notifications: ReadonlyArray<Notification.Notification>
142
+ readonly boundary: string
143
+ readonly duplicate: boolean
144
+ }
145
+ ```
146
+
147
+ `notifications` are the ones the committed promotion record names, so two
148
+ processes draining one boundary report the same delivery. `duplicate` is true
149
+ when the boundary had already drained.
150
+
151
+ ### NotificationError
152
+
153
+ ```ts
154
+ class NotificationError extends Schema.TaggedError<NotificationError>()(
155
+ "/notifications/NotificationError",
156
+ {
157
+ code: "notification_unavailable" | "notification_closed" | "notification_full" | "notification_id_reused" | "notification_invalid"
158
+ message: string
159
+ notificationId?: string
160
+ path?: string
161
+ }
162
+ ) {}
163
+ ```
164
+
165
+ | Field | Meaning |
166
+ | ---------------- | ---------------------------------------------------------------------------------- |
167
+ | `code` | The stable half, and the one to branch on. Defaults to `notification_unavailable`. |
168
+ | `message` | Human-readable detail. Never carries the offending value. |
169
+ | `notificationId` | The notification the failure is about, when one was readable. |
170
+ | `path` | Dotted path of the offending field, for `notification_invalid`. |
171
+
172
+ | Code | Means |
173
+ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
174
+ | `notification_unavailable` | The seam serves nothing, or the journal identity for a drain holds an event this queue did not write. |
175
+ | `notification_closed` | New public refusal code: this receiver has finished. Start a new request; retrying this message at the closed receiver cannot deliver it. |
176
+ | `notification_full` | New public refusal code: nothing was retained. Retry the same notification after pending work drains. |
177
+ | `notification_id_reused` | A stable id was already admitted with different content. A producer bug, not a storage failure. |
178
+ | `notification_invalid` | The value is not a notification, or a drain's `cutoffSeq` is not a non-negative integer. `path` names the field that failed. |
179
+
180
+ Storage failures surface as `Journal.JournalError` instead, so the two stay
181
+ distinguishable.
182
+
183
+ The queue's ordinary `admit` still returns its existing `rejected-full` receipt.
184
+ `Control.steer` and configured receivers expose that refusal as
185
+ `NotificationError(notification_full)` instead of acknowledging an undeliverable
186
+ message. Configured receiver policies can also emit `notification_closed`.
187
+ Existing notification error fields and older codes are unchanged. Consumers
188
+ with an exhaustive code union must add both new codes. The Control RPC error
189
+ union now carries this existing error class; update remote decoders together
190
+ with the server so they can decode these refusals.
191
+
192
+ ### Constructors and layers
193
+
194
+ | Export | Signature | What it provides |
195
+ | ----------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
196
+ | `make` | `(implementation: Service) => Service` | The service, from an implementation of its three methods. |
197
+ | `makeNoop` | `(overrides?: Partial<Service>) => Service` | A service whose every method fails with `notification_unavailable`, except the ones `overrides` replaces. |
198
+ | `layerNoop` | `(overrides?: Partial<Service>) => Layer<NotificationQueue>` | `makeNoop`, as a layer. Requires nothing. |
199
+ | `layerWith` | `(options?: { capacity?: number }) => Layer<NotificationQueue, never, Journal.Journal>` | The journal-backed implementation at the given pending capacity, defaulting to `NotificationState.defaultCapacity`. |
200
+ | `layer` | `Layer<NotificationQueue, never, Journal.Journal>` | `layerWith()`: the journal-backed implementation at the default capacity. |
201
+
202
+ Both journal-backed layers write through the journal's unfenced durable channel,
203
+ so `admit` and `drain` return only after the corresponding entry is committed.
204
+ The channel is unfenced on purpose: the notifying process owns no run, so there
205
+ is no ownership fence to hand over, and both records are first-writer-wins on
206
+ their own identity instead.
207
+
208
+ ## Notification
209
+
210
+ The durable notification payloads and their admission classification. Import
211
+ from `@smthrs/notifications/Notification`.
212
+
213
+ ### Schemas
214
+
215
+ | Export | Shape |
216
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
217
+ | `Provenance` | `{ sourceRunId: string; sourceLineageId: string; sourceTurn: number; sourceActor: string }`. `sourceTurn` is an integer at or above zero. |
218
+ | `HumanSteer` | Tagged `"human-steer"`. The common fields plus `delivery: "steer"`. |
219
+ | `HumanFollowup` | Tagged `"human-followup"`. The common fields plus `delivery: "queue"`. |
220
+ | `SystemEvent` | Tagged `"system-event"`. The common fields plus `delivery: "queue"` and an optional `coalescingKey: string`. |
221
+ | `Notification` | The union of the three. |
222
+
223
+ The common fields on every member are `id` (a non-empty string, and the caller's
224
+ idempotency key), `targetLineageId` (a non-empty string), `provenance`, and
225
+ `payload` (any JSON value).
226
+
227
+ Provenance travels with the notification because the run that receives one is
228
+ not the run that wrote it. An operator steer, a parent run's event, and a webhook
229
+ all arrive on the same queue, and only these fields say which is which after the
230
+ fact.
231
+
232
+ ### Functions
233
+
234
+ | Export | Signature | Returns |
235
+ | ---------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
236
+ | `admissionClass` | `(notification: Notification) => "steer" \| "queue"` | How the notification is allowed to reach the model. Steers are promoted in a batch at turn close; queued notifications one at a time at idle. |
237
+ | `coalesceKey` | `(notification: Notification) => string \| null` | The key the notification coalesces on, or `null` when it must never coalesce. Only a `system-event` that declares a `coalescingKey` returns one. |
238
+
239
+ ## NotificationState
240
+
241
+ The pure bounded queue: the same rules with no I/O and no journal. Import from
242
+ `@smthrs/notifications/NotificationState`.
243
+
244
+ ### Models
245
+
246
+ | Export | Shape |
247
+ | ------------------- | -------------------------------------------------------------------------------------------------------- |
248
+ | `defaultCapacity` | `128`. The default maximum pending notifications per run. |
249
+ | `Pending` | `{ notification: Notification; seq: number }`. `seq` is the journal sequence the admission committed at. |
250
+ | `State` | `{ capacity: number; items: ReadonlyArray<Pending> }`. Frozen, and every transition returns a new value. |
251
+ | `Admission` | `{ state: State; decision: AdmissionDecision }`. |
252
+ | `Promotion` | `{ state: State; promoted: ReadonlyArray<Pending> }`. |
253
+ | `AdmissionDecision` | Re-exported from `NotificationEvent`, which owns the single declaration. |
254
+
255
+ ### Operations
256
+
257
+ | Export | Signature | What it does |
258
+ | ---------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
259
+ | `empty` | `(capacity: number) => State` | An empty queue. A capacity that is not a finite number becomes zero, so a misconfigured bound refuses everything rather than retaining an unbounded backlog. |
260
+ | `admit` | `(state: State, notification: Notification, seq: number) => Admission` | Admits, coalescing only pending system events with the same key, and keeping the first sequence so replay order stays stable. A queue at capacity decides `rejected-full` and retains nothing. |
261
+ | `applyAdmission` | `(state: State, notification: Notification, seq: number, decision: AdmissionDecision) => State` | Applies a decision already committed in a journal record. Replay never re-decides. |
262
+ | `pending` | `(state: State, admission: "steer" \| "queue", targetLineageId?: string) => ReadonlyArray<Pending>` | Still-pending notifications of one admission class, in durable journal order, for one lineage or for every lineage. |
263
+ | `promoteSteers` | `(state: State, cutoffSeq: number, targetLineageId?: string) => Promotion` | Promotes every steer admitted at or before the cutoff. Notifications admitted after it remain pending. |
264
+ | `promoteQueued` | `(state: State, targetLineageId?: string) => Promotion` | Promotes exactly the oldest pending queued notification, or nothing. |
265
+ | `applyPromoted` | `(state: State, ids: ReadonlyArray<string>) => State` | Removes the ids a durable promotion record names, while replaying history. |
266
+
267
+ ## NotificationEvent
268
+
269
+ The journal event types this package owns. Import from
270
+ `@smthrs/notifications/NotificationEvent`.
271
+
272
+ | Export | Value or shape |
273
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
274
+ | `AdmittedEventType` | `"flows/notifications/Admitted"`. Frozen: the value is already durable in every journal this package has written to. |
275
+ | `PromotedEventType` | `"flows/notifications/Promoted"`. Frozen for the same reason. |
276
+ | `AdmissionDecision` | `"admitted" \| "coalesced" \| "rejected-full"`. The one declaration of the admission vocabulary. |
277
+ | `Admitted` | `{ notification: Notification; decision: AdmissionDecision; fingerprint?: string }`. |
278
+ | `Promoted` | `{ boundary: string; targetLineageId: string; ids: ReadonlyArray<string> }`. |
279
+ | `Event` | `Admitted \| Promoted`. |
280
+ | `admissionSourceId` | `(notificationId: string) => JournalEvent.SourceId`. The source every admission of one notification is written under. |
281
+ | `drainKey` | `(targetLineageId: string, boundary: string) => string`. One drain's identity, each component percent-encoded. |
282
+ | `drainSourceId` | `(targetLineageId: string, boundary: string) => JournalEvent.SourceId`. The source one drain's record is written under. |
283
+
284
+ A `rejected-full` decision is never written. The queue refuses a full queue in
285
+ the receipt alone, so the notification id stays admissible once a boundary
286
+ drains. The literal remains in `AdmissionDecision` because a reader must stay
287
+ total over a record any writer could have produced.
288
+
289
+ | Export | Signature | Returns |
290
+ | ------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
291
+ | `isAdmitted` | `(event: Event) => event is Admitted` | Whether the owned event is an admission record. |
292
+ | `isPromoted` | `(event: Event) => event is Promoted` | Whether the owned event is a promotion record. |
293
+ | `fromEntry` | `(entry: JournalEvent.Entry) => Option.Option<Event>` | The owned event a journal entry carries. Foreign entries and structurally invalid payloads answer `None`, so a projection over a shared journal stays total. An admission counts only under `admissionSourceId` of the notification it carries, and a promotion only under a `/notifications/drain/` source, so another producer's record with the same event type is ignored. The source scopes records; it does not authenticate the writer. |
294
+
295
+ ## Projection
296
+
297
+ Import from `@smthrs/notifications/Projection`.
298
+
299
+ | Export | Signature | What it does |
300
+ | -------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
301
+ | `derive` | `Journal.Projection<NotificationState.State>` | Re-derives pending notifications from admitted and promoted journal entries. Foreign entries leave the state unchanged. |
302
+
303
+ The projection starts at `NotificationState.empty(NotificationState.defaultCapacity)`.
304
+ Capacity gates new admissions; it does not truncate replay. `applyAdmission`
305
+ applies the decision each record committed, so a composition built with
306
+ `NotificationQueue.layerWith` above the default replays every admission it
307
+ wrote. The projected `capacity` stays at the default because no journal record
308
+ carries the bound the layer was built at, so read `items` rather than
309
+ `capacity`.
310
+
311
+ A versioned edit of a steer that is still pending replaces that item in place
312
+ and keeps its original sequence, the same way the queue reads its own history,
313
+ so an edited steer projects as one pending item.
314
+
315
+ ## SteerPayload
316
+
317
+ The steering vocabulary carried inside a notification payload. Import from
318
+ `@smthrs/notifications/SteerPayload`.
319
+
320
+ The vocabulary lives here rather than in either package that uses it: a control
321
+ plane admits a steer and a harness drains it, and neither may depend on the
322
+ other, so the shape they have to agree on belongs beneath both.
323
+
324
+ | Export | Shape |
325
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
326
+ | `Thinking` | `"none" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh"`. A subset of `ModelRequest.ReasoningEffort` in [`@smthrs/model`](/api/model), which also admits `"max"`; this package deliberately does not depend on it. |
327
+ | `MessagePayload` | `{ kind: "Message"; body: string }`. The body may be empty. |
328
+ | `SeatPayload` | `{ kind: "Seat"; seat: string }`. `seat` is non-empty. |
329
+ | `ThinkingPayload` | `{ kind: "Thinking"; thinking: Thinking }`. |
330
+ | `ToolsPayload` | `{ kind: "Tools"; toolNames: ReadonlyArray<string> }`. Non-empty, of non-empty strings. Additive only: steering can widen the active tool set and cannot narrow it. |
331
+ | `SteerPayload` | The union of the four. |
332
+
333
+ | Export | Signature | Returns |
334
+ | -------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
335
+ | `decode` | `(notification: Notification) => SteerPayload \| undefined` | The steering item a notification carries, or `undefined` when it carries none. A `system-event` is never a steer. Inside a human notification, a record with a `body` string and no `kind` reads as a message. |
336
+ | `encode` | `(item: SteerPayload) => SteerPayload` | The record the item is stored as. Every item is written with its `kind`, including a message. The result is assignable to `Notification.payload` and shares no mutable structure with `item`. |
337
+
338
+ ## Alerts
339
+
340
+ Run conditions that have lasted too long, turned into durable, coalesced,
341
+ delivered-once notifications. Import from `@smthrs/notifications/Alerts`.
342
+
343
+ ### Policy
344
+
345
+ | Export | Shape |
346
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
347
+ | `Severity` | `"info" \| "warning" \| "critical"`. |
348
+ | `Detector` | `{ field: string; value: string; eventTypes?: ReadonlyArray<string> }`. `field` names a payload key, `value` the value that means the condition holds, and `eventTypes` narrows which entries are consulted; an item ending in `.*`, such as `control.*`, names every event type with that prefix. |
349
+ | `Rule` | `{ afterMs: number; severity?: Severity; owner?: string; runbook?: string }`. `afterMs` is a whole, non-negative number of milliseconds. |
350
+ | `Policy` | `{ defaults?: { severity?; owner?; runbook? }; rules: Record<string, Rule>; detectors?: Record<string, Detector> }`. |
351
+ | `defaultDetectors` | `Readonly<Record<string, Detector>>`: `waiting-approval` and `failed` on `status`, `stalled` on `health`, `quota-parked` on `waitingReason`, each consulting only the `control.*` event types. |
352
+
353
+ An entry that carries a detector's field with the matching value opens the
354
+ condition; an entry that carries it with any other value closes it. A policy's
355
+ own `detectors` are merged over `defaultDetectors`.
356
+
357
+ ### Decisions
358
+
359
+ | Export | Shape |
360
+ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
361
+ | `Open` | `{ runId: string; condition: string; since: number }`. `since` is the journal time of the entry that opened the condition. |
362
+ | `Alert` | `{ runId; condition; since; firedAt; severity; coalescingKey; owner?; runbook? }`. `firedAt` is `since + afterMs`, never the wall clock of whichever tick noticed. |
363
+
364
+ | Export | Signature | Returns |
365
+ | --------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
366
+ | `conditions` | `(policy: Policy, runId: string, entries: ReadonlyArray<JournalEvent.Entry>) => ReadonlyArray<Open>` | Every condition a run's journal leaves open, with the time each opened. `entries` are oldest first. |
367
+ | `decide` | `(policy: Policy, open: ReadonlyArray<Open>, now: number) => ReadonlyArray<Alert>` | The alerts the policy raises for the conditions open at `now`. Pure: `now` decides whether an alert is raised and never appears in one. |
368
+ | `coalescingKey` | `(runId: string, condition: string) => string` | The key an alert coalesces on: one open condition on one run. Each component is percent-encoded, so neither can forge another pair's key. |
369
+ | `alertId` | `(alert: Pick<Alert, "coalescingKey" \| "since">) => string` | `alert:<coalescingKey>:<since>`. The identity a delivery is recorded under, stable for the life of one condition. A condition that clears and re-opens gets a new id. |
370
+
371
+ ### Sinks
372
+
373
+ | Export | Signature | What it is |
374
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
375
+ | `SinkService` | `{ deliver: (alert: Alert) => Effect.Effect<void, AlertError> }` | Where a raised alert is sent. `deliver` MUST be idempotent on `alertId(alert)`, and MUST fail when the page did not go out. |
376
+ | `Sink` | `Context.Service` under `/notifications/AlertSink` | The service tag. |
377
+ | `layerNoop` | `Layer<Sink>` | Accepts every alert and sends it nowhere. The admission and the delivery record still happen. |
378
+ | `defaultWebhookTimeout` | `Duration.Duration` | Ten seconds. |
379
+ | `layerWebhook` | `(options: { url: string; headers?: Record<string, string>; timeout?: Duration.Duration }) => Layer<Sink>` | POSTs each alert to one webhook using an owned Fetch client with redirects disabled and a scope per delivery. Injected `HttpClient` layers are not used. The body is the alert plus its `alertId`, and the same id is sent as an `Idempotency-Key` header, set after the caller's headers. A non-2xx answer is a failure; an endpoint that never answers fails after `timeout`. The delivery's HTTP span records the method, scheme, host, port and status, never the url's path, query or userinfo. |
380
+
381
+ | Export | Shape |
382
+ | ------------- | --------------------------------------------------------------------------------------------------------------- |
383
+ | `FailureCode` | `"sink_rejected" \| "sink_unreachable" \| "sink_timeout" \| "sink_misconfigured"`. |
384
+ | `AlertError` | Tagged `/notifications/AlertError`: `{ code: FailureCode; message: string; status?: number; reason?: string }`. |
385
+
386
+ `sink_misconfigured` is the one code no alert ever carries. `layerWebhook`
387
+ validates its `url` while the layer is built, before any alert exists, and a url
388
+ that is not an absolute `http:` or `https:` one dies there rather than failing
389
+ through the delivery channel: a scheme bound is what keeps a credential set in
390
+ `headers` from being handed to something that is not HTTP.
391
+
392
+ `AlertError` deliberately holds no request. A webhook request carries the
393
+ credential the deployment handed `layerWebhook`, and an error is logged, encoded,
394
+ and journaled in places a credential must never reach.
395
+
396
+ ### Runtime
397
+
398
+ | Export | Shape |
399
+ | ---------------- | ---------------------------------------------------------------------------------------------- |
400
+ | `Tick` | `{ delivered; failed; refused; suppressed }`, each a `ReadonlyArray<Alert>`. |
401
+ | `RuntimeService` | `{ tick: (runId: string) => Effect.Effect<Tick, Journal.JournalError \| NotificationError> }`. |
402
+ | `AlertRuntime` | `Context.Service` under `/notifications/AlertRuntime`. The service tag. |
403
+
404
+ | Field of `Tick` | Meaning |
405
+ | --------------- | ------------------------------------------------------------------------------------------------------- |
406
+ | `delivered` | Raised and accepted by the sink on this tick. |
407
+ | `failed` | The sink refused them. Retried on the next tick. |
408
+ | `refused` | The notification queue was at capacity. The sink was not called, and they are retried on the next tick. |
409
+ | `suppressed` | Already delivered, and not delivered again. |
410
+
411
+ | Export | Signature | What it provides |
412
+ | ------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
413
+ | `layer` | `(policy: Policy) => Layer<AlertRuntime, never, Journal.Journal \| NotificationQueue \| Sink>` | The alert runtime over one policy. The policy is decoded when the layer is built, so a rule with an impossible delay fails the composition by name. |
414
+
415
+ | Export | Value |
416
+ | -------------------- | ---------------------------------------------------------------------------------- |
417
+ | `deliveredEventType` | `"flows.alerts.delivered"`. One entry per delivered alert. |
418
+ | `failedEventType` | `"flows.alerts.failed"`. One entry per alert per failure code, never one per tick. |
419
+
420
+ A tick admits the alert, sends it, then journals the delivery. Admission is
421
+ idempotent and the send is not, so a crash between the first two costs a
422
+ duplicate admission the queue drops, while a crash between an accepted send and
423
+ the delivery record costs a duplicate page. That is why `deliver` is required to
424
+ deduplicate on `alertId`.
425
+
426
+ Both owned event types are excluded from condition detection: they are written
427
+ into the journal the detectors read, and they carry the alert's own vocabulary,
428
+ so reading them as evidence would let a page close the condition it paged about.
429
+
430
+ A `flows.alerts.delivered` entry suppresses a page only under the source the
431
+ alerter writes, `/notifications/alerts/<alertId>/delivered`. The same entry under
432
+ any other source is ignored. The journal does not bind a source to a writer, so
433
+ this keeps other producers' records out; it does not authenticate the writer.
434
+
435
+ See [`@smthrs/control`](/api/control) for the run conditions the entries come
436
+ from.
437
+
438
+ Versioned human steers use a positive safe integer. A higher version replaces the
439
+ pending payload at its original position, with the same owner and delivery
440
+ binding. Promotion consumes the input: a later version returns `consumed: true`
441
+ with the consumed notification, without writing or changing the delivered payload. Older versions are no-ops;
442
+ reusing a version with different content refuses. Replacement and drain share
443
+ the journal transaction, including rollback and cross-process serialization.
@@ -0,0 +1,144 @@
1
+ ---
2
+ title: "Admission and promotion"
3
+ description: "The two moments a notification passes through: admission by whoever wrote it, and promotion at a boundary the run chose. Delivery classes, coalescing, the capacity bound, the turn cutoff, and the drain identity."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ A notification passes through two moments, and nothing happens in between.
9
+
10
+ **Admission** is the writer's moment. Any process may admit at any time: an
11
+ operator steering from a CLI, a parent run signalling a child, a supervisor
12
+ raising an alert. Admission records the notification durably and returns.
13
+
14
+ **Promotion** is the reader's moment. The run picks it, at a point in its own
15
+ turn where reading a new instruction is safe, and asks the queue what it may
16
+ deliver.
17
+
18
+ Keeping the two apart is the point of the package. A notification that arrived
19
+ mid-turn and reached the model immediately would change what the model is
20
+ looking at while it is looking at it.
21
+
22
+ ## What a notification is addressed to
23
+
24
+ Every notification carries `targetLineageId`, and it is the address of the
25
+ reading half: the run itself, or one branch of the run that opens and closes
26
+ turns of its own. The queue treats the value as an opaque string and only ever
27
+ compares it for equality, so its shape is the host's choice. A host with no
28
+ branches passes the run id and is done. A host with branches passes the id of the
29
+ branch the running code belongs to, so a message meant for one branch is not
30
+ consumed by another that never asked for it. The samples on these pages use
31
+ `"run-1/root"`, which keeps the run and the branch visible in one string.
32
+ [`@smthrs/harness`](/api/harness) takes the value as
33
+ `Notifications.layer({ runId, lineageId })` and passes its own.
34
+
35
+ `provenance` is the other half of the addressing: `targetLineageId` says who
36
+ reads it, and `provenance.sourceActor` says who wrote it.
37
+
38
+ ## Two delivery classes
39
+
40
+ Every notification declares how it may reach the model, and `Notification.admissionClass`
41
+ reads that declaration back:
42
+
43
+ | Tag | Delivery class | When it reaches the model |
44
+ | ---------------- | -------------- | --------------------------------------------------------------- |
45
+ | `human-steer` | `steer` | In a batch, at the next turn close. |
46
+ | `human-followup` | `queue` | One at a time, when the run would otherwise have nothing to do. |
47
+ | `system-event` | `queue` | The same, and it may coalesce with other events under one key. |
48
+
49
+ The class is the whole difference between the first two. A follow-up is not
50
+ urgent enough to interrupt the turn in flight, so it waits for a boundary where
51
+ the run is idle. `DrainInput.wouldIdle` is how the run says it has reached one,
52
+ and a drain promotes a queued notification only when `wouldIdle` is true and no
53
+ steer was promoted at that boundary.
54
+
55
+ ## Admitting is idempotent on the id
56
+
57
+ `Notification.id` is the caller's idempotency key. Admitting it twice with the
58
+ same content is one notification: the second call reads the committed decision
59
+ back and answers `duplicate: true` with the same `seq`. The comparison is on a
60
+ canonical rendering of the content, with keys sorted and absent values dropped,
61
+ so two encodings that differ only in field order compare equal.
62
+
63
+ Admitting the same id with different content is a producer bug, and the queue
64
+ refuses it with `notification_id_reused` rather than overwriting either version.
65
+ That refusal is why an alert's fields are all derived from the journal: an alert
66
+ stamped with the wall clock of whichever tick read it would change content
67
+ between retries and become permanently undeliverable.
68
+
69
+ ## Coalescing collapses a chatty producer
70
+
71
+ Only a `system-event` coalesces, and only when it declares a `coalescingKey`.
72
+ A key is scoped to one `targetLineageId`. While an event with that key is
73
+ pending for that lineage, admitting another one under the same key replaces it
74
+ and answers `coalesced`. Ten updates about one condition are one pending
75
+ notification carrying the latest of them, and two branches reporting the same
76
+ condition under the same ordinary key are two pending notifications, because a
77
+ key names a condition and only the address names a reader.
78
+
79
+ The replacement substitutes the whole notification, id and provenance included,
80
+ so what a boundary delivers is the latest report rather than the first report's
81
+ envelope around a newer payload. It keeps the first admission's sequence, so
82
+ replay order stays stable: the notification holds the place in the queue that
83
+ the first report earned it. `Notification.coalesceKey` returns `null` for
84
+ everything else, which is what stops two separate human follow-ups from silently
85
+ becoming one.
86
+
87
+ ## The queue is bounded
88
+
89
+ `NotificationState.defaultCapacity` is 128 pending notifications per run. The
90
+ bound is what makes an undrained run a known quantity rather than a function of
91
+ how long it was ignored.
92
+
93
+ Admitting past the bound decides `rejected-full`. That decision retains nothing
94
+ and writes no journal entry, so the id stays admissible: once a boundary drains,
95
+ the same call succeeds. `admit` does not fail on a full queue, which is exactly
96
+ why the receipt has to be read. See
97
+ [Handle a full queue](../guides/handle-a-full-queue.md).
98
+
99
+ `NotificationQueue.layerWith({ capacity })` sets the bound for one composition.
100
+ A capacity that is not a finite number becomes zero, so a misconfigured bound
101
+ refuses everything loudly instead of retaining an unbounded backlog.
102
+
103
+ ## The cutoff holds a mid-turn steer
104
+
105
+ `DrainInput.cutoffSeq` is the journal sequence that opened the turn now closing.
106
+ `promoteSteers` delivers every steer admitted at or before it and holds the
107
+ rest. A steer that arrived while the turn was running is therefore delivered at
108
+ the next boundary, not the one that is closing around it.
109
+
110
+ Omitting `cutoffSeq` delivers everything pending for the lineage. That is the
111
+ right default for a caller that has no turn to compare against, and the wrong
112
+ one for a harness closing a turn, which knows the sequence and should pass it.
113
+
114
+ ## The unit of drain is a triple
115
+
116
+ A drain is identified by `(runId, targetLineageId, boundary)`. Two lineages
117
+ closing a turn under the same boundary name are two drains, recorded separately,
118
+ so neither suppresses the other. Each component is percent-encoded before the
119
+ identity is assembled, so a lineage id containing a slash cannot forge another
120
+ pair's identity and steal its delivery.
121
+
122
+ The identity is also the deduplication key. The first record committed for a
123
+ triple is the delivery: a second process draining the same boundary reads that
124
+ record back and reports the same notifications with `duplicate: true`, rather
125
+ than deciding a second time and disagreeing. A parked run that walks its
126
+ boundaries looking for one it has not consulted uses exactly that flag.
127
+
128
+ ## The pure state machine underneath
129
+
130
+ `NotificationState` is the same rules with no I/O and no journal: `empty`,
131
+ `admit`, `promoteSteers`, `promoteQueued`, and `pending` are ordinary functions
132
+ over an immutable `State`. `NotificationQueue` is those functions plus
133
+ durability, and `applyAdmission` and `applyPromoted` are the replay half, which
134
+ applies a committed decision rather than recomputing it.
135
+
136
+ That split is why the promotion rules can be tested directly, and why the
137
+ capacity bound belongs to `admit` alone. `applyAdmission` retains what a record
138
+ admitted without consulting it, so a deployment that raised the capacity replays
139
+ in full through `Projection.derive`. See
140
+ [the journal records](./journal-records.md).
141
+
142
+ Reserved `outside_change` payloads fail admission with the typed
143
+ `NotificationError` code `notification_refused` before any journal write.
144
+ They consume no pending capacity and cannot poison another notification’s drain.