@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.
- package/CHANGELOG.md +127 -0
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/cjs/AlertPolicy.d.ts +284 -0
- package/dist/cjs/AlertPolicy.d.ts.map +1 -0
- package/dist/cjs/AlertPolicy.js +124 -0
- package/dist/cjs/AlertPolicy.js.map +7 -0
- package/dist/cjs/AlertRuntime.d.ts +90 -0
- package/dist/cjs/AlertRuntime.d.ts.map +1 -0
- package/dist/cjs/AlertRuntime.js +166 -0
- package/dist/cjs/AlertRuntime.js.map +7 -0
- package/dist/cjs/AlertSink.d.ts +101 -0
- package/dist/cjs/AlertSink.d.ts.map +1 -0
- package/dist/cjs/AlertSink.js +44 -0
- package/dist/cjs/AlertSink.js.map +7 -0
- package/dist/cjs/Alerts.d.ts +57 -0
- package/dist/cjs/Alerts.d.ts.map +1 -0
- package/dist/cjs/Alerts.js +46 -0
- package/dist/cjs/Alerts.js.map +7 -0
- package/dist/cjs/Notification.d.ts +214 -0
- package/dist/cjs/Notification.d.ts.map +1 -0
- package/dist/cjs/Notification.js +62 -0
- package/dist/cjs/Notification.js.map +7 -0
- package/dist/cjs/NotificationEvent.d.ts +214 -0
- package/dist/cjs/NotificationEvent.d.ts.map +1 -0
- package/dist/cjs/NotificationEvent.js +84 -0
- package/dist/cjs/NotificationEvent.js.map +7 -0
- package/dist/cjs/NotificationQueue.d.ts +194 -0
- package/dist/cjs/NotificationQueue.d.ts.map +1 -0
- package/dist/cjs/NotificationQueue.js +477 -0
- package/dist/cjs/NotificationQueue.js.map +7 -0
- package/dist/cjs/NotificationState.d.ts +169 -0
- package/dist/cjs/NotificationState.d.ts.map +1 -0
- package/dist/cjs/NotificationState.js +110 -0
- package/dist/cjs/NotificationState.js.map +7 -0
- package/dist/cjs/Projection.d.ts +30 -0
- package/dist/cjs/Projection.d.ts.map +1 -0
- package/dist/cjs/Projection.js +53 -0
- package/dist/cjs/Projection.js.map +7 -0
- package/dist/cjs/SteerPayload.d.ts +177 -0
- package/dist/cjs/SteerPayload.d.ts.map +1 -0
- package/dist/cjs/SteerPayload.js +61 -0
- package/dist/cjs/SteerPayload.js.map +7 -0
- package/dist/cjs/index.d.ts +41 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +47 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/foldCache.d.ts +36 -0
- package/dist/cjs/internal/foldCache.d.ts.map +1 -0
- package/dist/cjs/internal/foldCache.js +54 -0
- package/dist/cjs/internal/foldCache.js.map +7 -0
- package/dist/cjs/internal/replay.d.ts +22 -0
- package/dist/cjs/internal/replay.d.ts.map +1 -0
- package/dist/cjs/internal/replay.js +46 -0
- package/dist/cjs/internal/replay.js.map +7 -0
- package/dist/cjs/layerWebhook.d.ts +60 -0
- package/dist/cjs/layerWebhook.d.ts.map +1 -0
- package/dist/cjs/layerWebhook.js +145 -0
- package/dist/cjs/layerWebhook.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/AlertPolicy.d.ts +284 -0
- package/dist/esm/AlertPolicy.d.ts.map +1 -0
- package/dist/esm/AlertPolicy.js +284 -0
- package/dist/esm/AlertPolicy.js.map +1 -0
- package/dist/esm/AlertRuntime.d.ts +90 -0
- package/dist/esm/AlertRuntime.d.ts.map +1 -0
- package/dist/esm/AlertRuntime.js +191 -0
- package/dist/esm/AlertRuntime.js.map +1 -0
- package/dist/esm/AlertSink.d.ts +101 -0
- package/dist/esm/AlertSink.d.ts.map +1 -0
- package/dist/esm/AlertSink.js +63 -0
- package/dist/esm/AlertSink.js.map +1 -0
- package/dist/esm/Alerts.d.ts +57 -0
- package/dist/esm/Alerts.d.ts.map +1 -0
- package/dist/esm/Alerts.js +57 -0
- package/dist/esm/Alerts.js.map +1 -0
- package/dist/esm/Notification.d.ts +214 -0
- package/dist/esm/Notification.d.ts.map +1 -0
- package/dist/esm/Notification.js +121 -0
- package/dist/esm/Notification.js.map +1 -0
- package/dist/esm/NotificationEvent.d.ts +214 -0
- package/dist/esm/NotificationEvent.d.ts.map +1 -0
- package/dist/esm/NotificationEvent.js +158 -0
- package/dist/esm/NotificationEvent.js.map +1 -0
- package/dist/esm/NotificationQueue.d.ts +194 -0
- package/dist/esm/NotificationQueue.d.ts.map +1 -0
- package/dist/esm/NotificationQueue.js +625 -0
- package/dist/esm/NotificationQueue.js.map +1 -0
- package/dist/esm/NotificationState.d.ts +169 -0
- package/dist/esm/NotificationState.d.ts.map +1 -0
- package/dist/esm/NotificationState.js +191 -0
- package/dist/esm/NotificationState.js.map +1 -0
- package/dist/esm/Projection.d.ts +30 -0
- package/dist/esm/Projection.d.ts.map +1 -0
- package/dist/esm/Projection.js +47 -0
- package/dist/esm/Projection.js.map +1 -0
- package/dist/esm/SteerPayload.d.ts +177 -0
- package/dist/esm/SteerPayload.d.ts.map +1 -0
- package/dist/esm/SteerPayload.js +139 -0
- package/dist/esm/SteerPayload.js.map +1 -0
- package/dist/esm/index.d.ts +41 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +41 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/foldCache.d.ts +36 -0
- package/dist/esm/internal/foldCache.d.ts.map +1 -0
- package/dist/esm/internal/foldCache.js +58 -0
- package/dist/esm/internal/foldCache.js.map +1 -0
- package/dist/esm/internal/replay.d.ts +22 -0
- package/dist/esm/internal/replay.d.ts.map +1 -0
- package/dist/esm/internal/replay.js +31 -0
- package/dist/esm/internal/replay.js.map +1 -0
- package/dist/esm/layerWebhook.d.ts +60 -0
- package/dist/esm/layerWebhook.d.ts.map +1 -0
- package/dist/esm/layerWebhook.js +186 -0
- package/dist/esm/layerWebhook.js.map +1 -0
- package/docs/README.md +146 -0
- package/docs/api.md +443 -0
- package/docs/concepts/admission-and-promotion.md +144 -0
- package/docs/concepts/alerting.md +138 -0
- package/docs/concepts/journal-records.md +132 -0
- package/docs/guides/alert-on-a-stuck-run.md +140 -0
- package/docs/guides/drain-at-a-turn-boundary.md +95 -0
- package/docs/guides/handle-a-full-queue.md +102 -0
- package/docs/guides/report-pending-notifications.md +92 -0
- package/docs/guides/send-alerts-to-a-webhook.md +155 -0
- package/docs/guides/steer-a-run.md +127 -0
- package/docs/guides/testing.md +161 -0
- package/docs/installation.md +123 -0
- package/docs/quickstart.md +159 -0
- package/docs/troubleshooting.md +254 -0
- package/package.json +182 -3
- package/src/AlertPolicy.ts +377 -0
- package/src/AlertRuntime.ts +280 -0
- package/src/AlertSink.ts +105 -0
- package/src/Alerts.ts +71 -0
- package/src/Notification.ts +171 -0
- package/src/NotificationEvent.ts +213 -0
- package/src/NotificationQueue.ts +856 -0
- package/src/NotificationState.ts +295 -0
- package/src/Projection.ts +48 -0
- package/src/SteerPayload.ts +198 -0
- package/src/index.ts +47 -0
- package/src/internal/foldCache.ts +72 -0
- package/src/internal/replay.ts +39 -0
- 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.
|