@cohortapp/agent-sdk 2.14.0 → 2.16.0

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.
@@ -0,0 +1,497 @@
1
+ /**
2
+ * lib/assurance/room-budget.mjs — one interim per room per window, per seat.
3
+ *
4
+ * THE DEFECT THIS IS PART OF THE ANSWER TO
5
+ *
6
+ * The interim budget used to be per OBLIGATION and never per ROOM. Each inbound
7
+ * item opened its own debt with its own ack → progress → failure lifecycle, so
8
+ * N concurrent obligations in one channel emitted up to N acks, 2N progress
9
+ * pings and N failure notices — and nothing anywhere counted messages per room.
10
+ * Measured in `capital-formation` over 21 days: 9,187 messages from 8 agents,
11
+ * ~45 % exact duplicates, ~38 msg/h, and two independent timers narrating the
12
+ * same overlapping work at the SAME timestamp ("Still on this — 5 minutes in"
13
+ * beside "Still going — 15 minutes in"). Every individual decision was correct.
14
+ * The room was the thing nobody was counting.
15
+ *
16
+ * THE RULE, AND ITS HONEST SCOPE
17
+ *
18
+ * At most ONE interim per (service, channel) per window — default 15 minutes,
19
+ * `ASSURANCE_ROOM_INTERIM_WINDOW_MS` to change it — regardless of how many
20
+ * obligations are open in that room, **for this agent seat**.
21
+ *
22
+ * THAT LAST CLAUSE IS LOAD-BEARING AND WAS ONCE MISSING FROM THIS HEADER.
23
+ * The ledger lives under `AGENT_DIR`, which is one agent's repo on one Mac
24
+ * mini. There is no shared store between seats — the fleet is N machines with
25
+ * no common filesystem — so the guarantee this module can actually make is
26
+ * per (seat, service, channel), not per (service, channel).
27
+ *
28
+ * The arithmetic that follows from that, stated rather than glossed, because a
29
+ * reader who believes "one room ⇒ one message" will mis-size the next change:
30
+ * the measured room had 8 seats in it, so this gate's ceiling there is
31
+ * 8 × (60/15) = 32 interims/hour. The measured content-free rate in that room
32
+ * was ~38 msg/h × 37.8 % ≈ 14/h. 32 > 14, so THIS GATE ALONE WOULD NOT HAVE
33
+ * BEEN THE BINDING CONSTRAINT on the measured data. What removed the measured
34
+ * traffic is the whole stack, and mostly the other three gates:
35
+ *
36
+ * - the deleted progress branch — 961 messages → 0, unconditionally;
37
+ * - `sanitiseAckText`'s generic-opener
38
+ * rejection ⇒ null ⇒ send nothing — blocks the measured opening-ack
39
+ * population outright;
40
+ * - nothing at all before ACK_AFTER_MS
41
+ * plus the per-obligation `interimSaid`
42
+ * latch, inherited across retries — one interim per ask, ever.
43
+ *
44
+ * This gate is the BACKSTOP that bounds what survives all of those: it is what
45
+ * keeps a single seat holding twenty concurrent asks in one channel from
46
+ * emitting twenty bespoke, individually-defensible holding lines. Sized
47
+ * correctly, described correctly, and not credited with the whole fix.
48
+ *
49
+ * WHAT THE BUDGET GOVERNS, AND WHAT IT DOES NOT
50
+ *
51
+ * governed — interims: the holding acknowledgement and the plan note. These
52
+ * are courtesies. A room that has had one does not need another.
53
+ * ungoverned — outcome messages: failure, stale, interrupted, and the
54
+ * silent-success recovery. These carry information a human
55
+ * cannot get any other way, and rate-limiting one re-creates the
56
+ * original bug (a person who is never told their work died).
57
+ *
58
+ * They are not ungoverned ENTIRELY, though: see
59
+ * `claimRoomNotice` below, which suppresses a BYTE-IDENTICAL
60
+ * repeat of one into the same room. That is a different
61
+ * question from "has this room had enough" — a sentence
62
+ * indistinguishable from one already on screen cannot tell a
63
+ * reader which ask it is about, so the second copy carries no
64
+ * information at all, only volume.
65
+ *
66
+ * SHAPE. The decisions are pure and take the ledger as an argument; the durable
67
+ * edges (`claimRoomInterim`, `claimRoomNotice`) are the only functions that
68
+ * touch disk, and their clock and filesystem are both injected. The default
69
+ * window is resolved by `roomInterimWindowMs(env)` — a function, called at the
70
+ * EDGE, not a constant captured at module load: a constant folded at import
71
+ * time means an operator who exports the env var after the first import gets
72
+ * the stale default with no error, and means a pure module read the
73
+ * environment on the way past.
74
+ *
75
+ * FAIL-OPEN DIRECTION, chosen deliberately. An unreadable or unwritable ledger
76
+ * ALLOWS the message and reports `degraded: true`. A JSON file that will not
77
+ * parse must never be the reason a waiting human hears nothing; the cost of
78
+ * that direction is bounded by every other gate still standing, and `degraded`
79
+ * makes the condition countable instead of silent.
80
+ *
81
+ * @module lib/assurance/room-budget
82
+ */
83
+
84
+ "use strict";
85
+
86
+ import { readFileSync } from "node:fs";
87
+ import { join } from "node:path";
88
+ import { writeJsonAtomic } from "../fs-atomic.mjs";
89
+
90
+ const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
91
+
92
+ /**
93
+ * How long one interim buys a room's silence, absent an override.
94
+ *
95
+ * Fifteen minutes is chosen against the measured session distribution (p50
96
+ * 14.7 min): a room hears at most one holding line per typical unit of work,
97
+ * which is the point at which an interim is still a courtesy rather than a nag.
98
+ *
99
+ * A plain constant, with no environment in it, so the pure functions below can
100
+ * fall back to it without reading anything.
101
+ */
102
+ export const DEFAULT_ROOM_INTERIM_WINDOW_MS = 15 * 60_000;
103
+
104
+ /**
105
+ * How long one outcome notice suppresses a byte-identical copy of itself in the
106
+ * same room. Same default, separately named and separately overridable: the two
107
+ * are different judgements and collapsing them would mean tuning one retunes
108
+ * the other by accident.
109
+ */
110
+ export const DEFAULT_ROOM_NOTICE_WINDOW_MS = 15 * 60_000;
111
+
112
+ /**
113
+ * The configured interim window. A FUNCTION, read at the edge — see the header.
114
+ * @param {object} [env]
115
+ * @returns {number}
116
+ */
117
+ export function roomInterimWindowMs(env = process.env) {
118
+ return num(env && env.ASSURANCE_ROOM_INTERIM_WINDOW_MS, DEFAULT_ROOM_INTERIM_WINDOW_MS);
119
+ }
120
+
121
+ /**
122
+ * The configured identical-notice window. Same reasoning as above.
123
+ * @param {object} [env]
124
+ * @returns {number}
125
+ */
126
+ export function roomNoticeWindowMs(env = process.env) {
127
+ return num(env && env.ASSURANCE_ROOM_NOTICE_WINDOW_MS, DEFAULT_ROOM_NOTICE_WINDOW_MS);
128
+ }
129
+
130
+ /** Entries older than this are landfill: they can no longer refuse anything. */
131
+ const RETAIN_WINDOWS = 2;
132
+
133
+ /** The shape on disk. `v` is there so a future migration is not a guess. */
134
+ export function _emptyLedger() {
135
+ return { v: 1, rooms: {}, notices: {} };
136
+ }
137
+
138
+ /**
139
+ * The room's identity: the service and the channel, normalised.
140
+ *
141
+ * Deliberately NOT the thread, the sender or the obligation. A thread-scoped
142
+ * budget would have permitted exactly the flood that was measured — eight
143
+ * agents, dozens of threads, one very loud channel.
144
+ *
145
+ * @param {object} [o] {service, channel}
146
+ * @returns {string}
147
+ */
148
+ export function roomKey(o) {
149
+ const a = o && typeof o === "object" ? o : {};
150
+ const norm = (v) => String(v == null ? "" : v).trim().toLowerCase() || "unknown";
151
+ return `${norm(a.service)}|${norm(a.channel)}`;
152
+ }
153
+
154
+ /**
155
+ * A stable, dependency-free digest of a message's text.
156
+ *
157
+ * FNV-1a over the trimmed, whitespace-collapsed string. It only ever has to
158
+ * answer "is this the same sentence again", so a 32-bit non-cryptographic hash
159
+ * is the right size; a collision costs one suppressed notice, against a
160
+ * baseline of N identical ones.
161
+ *
162
+ * @param {unknown} text
163
+ * @returns {string}
164
+ */
165
+ export function textDigest(text) {
166
+ const s = String(text == null ? "" : text).trim().replace(/\s+/g, " ");
167
+ let h = 0x811c9dc5;
168
+ for (let i = 0; i < s.length; i++) {
169
+ h ^= s.charCodeAt(i);
170
+ h = Math.imul(h, 0x01000193) >>> 0;
171
+ }
172
+ return `${h.toString(36)}.${s.length}`;
173
+ }
174
+
175
+ /** The identical-notice key: the room, the notice id, and the sentence itself. */
176
+ export function noticeKey(o) {
177
+ const a = o && typeof o === "object" ? o : {};
178
+ const notice = String(a.notice == null ? "" : a.notice).trim().toLowerCase() || "notice";
179
+ return `${roomKey(a)}|${notice}|${textDigest(a.text)}`;
180
+ }
181
+
182
+ /** One of the ledger's maps, or an empty one — never a throw, never a null deref. */
183
+ function mapOf(ledger, field) {
184
+ if (!ledger || typeof ledger !== "object") return {};
185
+ const r = ledger[field];
186
+ return r && typeof r === "object" && !Array.isArray(r) ? r : {};
187
+ }
188
+
189
+ const roomsOf = (ledger) => mapOf(ledger, "rooms");
190
+ const noticesOf = (ledger) => mapOf(ledger, "notices");
191
+
192
+ /** A stamp we are willing to reason from, or null. */
193
+ function stampAt(map, key) {
194
+ const v = map[key];
195
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
196
+ }
197
+
198
+ /**
199
+ * PURE. Is `now` still inside the window opened at `lastAt`?
200
+ *
201
+ * `now - lastAt` and not `Math.abs(...)`: a clock that has stepped BACKWARDS
202
+ * (NTP correction, a restored snapshot) yields a negative elapsed, which is
203
+ * less than any window and therefore reads as "inside it". A free message on
204
+ * every backwards step is how a budget becomes decorative.
205
+ */
206
+ function insideWindow(now, lastAt, windowMs) {
207
+ return !Number.isFinite(now) || now - lastAt < windowMs;
208
+ }
209
+
210
+ /**
211
+ * PURE. May this room hear an interim right now?
212
+ *
213
+ * @param {object} o
214
+ * @param {object} o.ledger the ledger as read (any shape; garbage fails open)
215
+ * @param {string} o.service
216
+ * @param {string} o.channel
217
+ * @param {number} o.now
218
+ * @param {number} [o.windowMs] defaults to DEFAULT_ROOM_INTERIM_WINDOW_MS —
219
+ * the constant, never the environment; the edge resolves the configured
220
+ * value and passes it in.
221
+ * @returns {{allowed:boolean, reason:string, room:string, lastAt:number|null}}
222
+ */
223
+ export function interimAllowed(o = {}) {
224
+ const room = roomKey(o);
225
+ const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : DEFAULT_ROOM_INTERIM_WINDOW_MS;
226
+ const lastAt = stampAt(roomsOf(o.ledger), room);
227
+ if (lastAt == null) return { allowed: true, reason: "room-budget-free", room, lastAt: null };
228
+ if (insideWindow(o.now, lastAt, windowMs)) {
229
+ return { allowed: false, reason: "room-budget-spent", room, lastAt };
230
+ }
231
+ return { allowed: true, reason: "room-budget-window-elapsed", room, lastAt };
232
+ }
233
+
234
+ /**
235
+ * PURE. Has this EXACT sentence already been said in this room, recently?
236
+ *
237
+ * @param {object} o {ledger, service, channel, notice, text, now, windowMs}
238
+ * @returns {{allowed:boolean, reason:string, room:string, key:string, lastAt:number|null}}
239
+ */
240
+ export function noticeAllowed(o = {}) {
241
+ const room = roomKey(o);
242
+ const key = noticeKey(o);
243
+ const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : DEFAULT_ROOM_NOTICE_WINDOW_MS;
244
+ const lastAt = stampAt(noticesOf(o.ledger), key);
245
+ if (lastAt == null) return { allowed: true, reason: "room-notice-new", room, key, lastAt: null };
246
+ if (insideWindow(o.now, lastAt, windowMs)) {
247
+ return { allowed: false, reason: "room-notice-duplicate", room, key, lastAt };
248
+ }
249
+ return { allowed: true, reason: "room-notice-window-elapsed", room, key, lastAt };
250
+ }
251
+
252
+ /**
253
+ * PURE. The ledger that results from this room having spoken at `now`.
254
+ * Returns a NEW object; the input is never mutated.
255
+ */
256
+ export function recordInterim(o = {}) {
257
+ const rooms = { ...roomsOf(o.ledger), [roomKey(o)]: Number.isFinite(o.now) ? o.now : 0 };
258
+ return pruneLedger({
259
+ ledger: { v: 1, rooms, notices: { ...noticesOf(o.ledger) } },
260
+ now: o.now,
261
+ windowMs: o.windowMs,
262
+ noticeWindowMs: o.noticeWindowMs,
263
+ });
264
+ }
265
+
266
+ /**
267
+ * PURE. The ledger that results from this sentence having been said at `now`.
268
+ * Returns a NEW object; the input is never mutated.
269
+ */
270
+ export function recordNotice(o = {}) {
271
+ const notices = { ...noticesOf(o.ledger), [noticeKey(o)]: Number.isFinite(o.now) ? o.now : 0 };
272
+ return pruneLedger({
273
+ ledger: { v: 1, rooms: { ...roomsOf(o.ledger) }, notices },
274
+ now: o.now,
275
+ windowMs: o.windowMs,
276
+ noticeWindowMs: o.noticeWindowMs,
277
+ });
278
+ }
279
+
280
+ /**
281
+ * PURE. Drop entries that can no longer refuse anything.
282
+ *
283
+ * Two windows of slack rather than one, so a boundary case is never pruned into
284
+ * a free message.
285
+ */
286
+ export function pruneLedger(o = {}) {
287
+ const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : DEFAULT_ROOM_INTERIM_WINDOW_MS;
288
+ const noticeWindowMs = Number.isFinite(o.noticeWindowMs) ? o.noticeWindowMs : DEFAULT_ROOM_NOTICE_WINDOW_MS;
289
+ const now = Number.isFinite(o.now) ? o.now : null;
290
+ const rooms = roomsOf(o.ledger);
291
+ const notices = noticesOf(o.ledger);
292
+ if (now == null) return { v: 1, rooms: { ...rooms }, notices: { ...notices } };
293
+ const sift = (map, win) => {
294
+ const keep = {};
295
+ for (const k of Object.keys(map)) {
296
+ const at = stampAt(map, k);
297
+ if (at == null) continue; // a corrupt entry refuses nothing; drop it
298
+ if (now - at < win * RETAIN_WINDOWS) keep[k] = at;
299
+ }
300
+ return keep;
301
+ };
302
+ return { v: 1, rooms: sift(rooms, windowMs), notices: sift(notices, noticeWindowMs) };
303
+ }
304
+
305
+ /**
306
+ * Where the durable ledger lives — beside the obligations it governs, at the
307
+ * path the design names.
308
+ *
309
+ * It is NOT an obligation record, and the ledger's readers must not treat it as
310
+ * one: `assurance.listObligations` skips this basename by name. A JSON file in
311
+ * a directory whose reader parses every `*.json` as a debt was, before that
312
+ * guard, a phantom record with no `key` and no `state` that `pruneObligations`
313
+ * tried to unlink once a minute.
314
+ */
315
+ export function ledgerPath(agentRoot) {
316
+ return join(String(agentRoot || "."), "state", "obligations", "room-budget.json");
317
+ }
318
+
319
+ /** The basename the obligation reader must skip. Exported so it is one string. */
320
+ export const LEDGER_BASENAME = "room-budget.json";
321
+
322
+ /**
323
+ * Read the durable ledger. Absent or corrupt reads as empty, flagged.
324
+ * @returns {{v:number, rooms:object, notices:object, degraded?:boolean}}
325
+ */
326
+ export function readLedger(o = {}) {
327
+ const impl = o.deps && o.deps.readImpl;
328
+ if (typeof impl === "function") {
329
+ try {
330
+ const led = impl(ledgerPath(o.agentRoot));
331
+ return led && typeof led === "object" ? led : _emptyLedger();
332
+ } catch { return { ..._emptyLedger(), degraded: true }; } // an injected reader that throws is still "nothing known"
333
+ }
334
+ let raw;
335
+ try { raw = readFileSync(ledgerPath(o.agentRoot), "utf-8"); }
336
+ catch { return _emptyLedger(); } // absent is the normal first-run case, not a fault
337
+ try {
338
+ const led = JSON.parse(raw);
339
+ return led && typeof led === "object" ? led : { ..._emptyLedger(), degraded: true };
340
+ } catch {
341
+ // A torn or hand-edited file. Fail OPEN and SAY SO — see the module header.
342
+ return { ..._emptyLedger(), degraded: true };
343
+ }
344
+ }
345
+
346
+ /** Persist, reporting a write failure rather than swallowing it. */
347
+ function persist(o, next, room) {
348
+ const write = (o.deps && o.deps.writeImpl) || writeJsonAtomic;
349
+ try { write(ledgerPath(o.agentRoot), next); return true; }
350
+ catch (err) {
351
+ // The message still goes out — see the fail-open note in the header — but a
352
+ // spend that did not persist is a budget that is not being kept, so it is
353
+ // reported rather than swallowed.
354
+ console.warn(`[room-budget] could not persist spend for ${room}: ${err.message}`);
355
+ return false;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * THE DURABLE EDGE for interims. Decide, and if the answer is yes, spend.
361
+ *
362
+ * Read-decide-write is not atomic. Two callers can therefore both claim one
363
+ * window, and there are two distinct ways for that to happen:
364
+ *
365
+ * ACROSS PROCESSES — two daemons sharing one AGENT_DIR, which is what an
366
+ * operator has while debugging beside the launchd job. Same exposure the
367
+ * obligation ledger already carries; the sweep's `foreignOwnerAlive` guard
368
+ * covers it from the other side.
369
+ *
370
+ * WITHIN ONE PROCESS — two overlapping sweeps on the 60-second health timer.
371
+ * This one is likelier, because branch (e) peeks with `commit:false`, then
372
+ * awaits a model call, and only commits after a successful send: a daemon
373
+ * holding enough eligible obligations spends more than a tick in generation
374
+ * alone. It is closed at the other end — `sweepObligations` refuses to run
375
+ * re-entrantly — rather than with a lock here, because a lock in this
376
+ * function would serialise every caller in the fleet to fix one caller's
377
+ * overlap, and a stuck lock file would gag a room permanently.
378
+ *
379
+ * Either way the failure mode is one extra message against a baseline of up to
380
+ * N per room.
381
+ *
382
+ * @param {object} o
383
+ * @param {string} o.service
384
+ * @param {string} o.channel
385
+ * @param {number} o.now INJECTED clock; omitting it is a caller bug
386
+ * @param {string} o.agentRoot
387
+ * @param {number} [o.windowMs] defaults to the CONFIGURED window
388
+ * @param {boolean} [o.commit=true] false asks the question WITHOUT spending the
389
+ * budget. The caller needs this because the message it wants to send may
390
+ * not exist yet: the interim's text comes from a model call that can
391
+ * fail, and a budget spent on a message that was never composed gags the
392
+ * room for fifteen minutes in exchange for nothing.
393
+ * @param {object} [o.deps] {readImpl, writeImpl}
394
+ * @returns {{allowed:boolean, reason:string, room:string, lastAt:number|null, degraded?:boolean, committed?:boolean}}
395
+ */
396
+ export function claimRoomInterim(o = {}) {
397
+ const room = roomKey(o);
398
+ if (!Number.isFinite(o.now)) {
399
+ // No clock, no decision. Refusing is right here and only here: the caller
400
+ // has a defect, and a defective caller must not be handed a budget it
401
+ // cannot account for.
402
+ return { allowed: false, reason: "no-clock", room, lastAt: null };
403
+ }
404
+ const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : roomInterimWindowMs();
405
+ const ledger = readLedger(o);
406
+ const degraded = ledger.degraded === true;
407
+ const verdict = interimAllowed({ ledger, service: o.service, channel: o.channel, now: o.now, windowMs });
408
+ if (!verdict.allowed) return degraded ? { ...verdict, degraded } : verdict;
409
+ if (o.commit === false) return degraded ? { ...verdict, degraded, committed: false } : { ...verdict, committed: false };
410
+
411
+ const next = recordInterim({ ledger, service: o.service, channel: o.channel, now: o.now, windowMs });
412
+ const ok = persist(o, next, room);
413
+ if (!ok) return { ...verdict, degraded: true, committed: true };
414
+ return degraded ? { ...verdict, degraded, committed: true } : { ...verdict, committed: true };
415
+ }
416
+
417
+ /**
418
+ * THE DURABLE EDGE for outcome notices: may this EXACT sentence be said here
419
+ * again?
420
+ *
421
+ * WHY THIS EXISTS, given that outcome notices are deliberately ungoverned by
422
+ * the interim budget. The per-obligation dedupe (`assurance.noticeAlreadySaid`)
423
+ * is keyed on the obligation RECORD, so it suppresses the repeat that R4
424
+ * produced — notice, retry, a fresh record under the same key, notice again.
425
+ * It cannot suppress SIBLINGS: twenty separate asks in one room whose sessions
426
+ * all die the same way are twenty separate records, and each composes the same
427
+ * fixed sentence, because `composeFailure`'s stale text has no per-ask content
428
+ * in it at all. Measured shape, and the one the live sample showed: the same
429
+ * failure sentence three times in one minute.
430
+ *
431
+ * The test applied here is therefore NOT "has this room had enough". It is
432
+ * "would this add anything": a sentence byte-identical to one already on screen
433
+ * cannot tell the reader which ask it is about, so the second copy is volume
434
+ * without information. A notice that differs — a different cause, a different
435
+ * excerpt, "retrying" versus "I have stopped" — is NOT suppressed, at any
436
+ * volume, because it carries a fact the reader does not have.
437
+ *
438
+ * The escalation record is written per obligation regardless, so nothing is
439
+ * lost operationally: an operator still sees every dead ask in needs-attention.
440
+ *
441
+ * @param {object} o
442
+ * @param {string} o.service
443
+ * @param {string} o.channel
444
+ * @param {string} o.notice the notice id (see assurance.NOTICE)
445
+ * @param {string} o.text the exact composed sentence
446
+ * @param {number} o.now INJECTED clock
447
+ * @param {string} o.agentRoot
448
+ * @param {number} [o.windowMs]
449
+ * @param {boolean} [o.commit=true]
450
+ * @param {object} [o.deps] {readImpl, writeImpl}
451
+ * @returns {{allowed:boolean, reason:string, room:string, key:string, lastAt:number|null, degraded?:boolean, committed?:boolean}}
452
+ */
453
+ export function claimRoomNotice(o = {}) {
454
+ const room = roomKey(o);
455
+ const key = noticeKey(o);
456
+ if (!Number.isFinite(o.now)) {
457
+ // FAIL OPEN, and the OPPOSITE way to claimRoomInterim. A missing clock must
458
+ // not be able to swallow "your work died": the interim is a courtesy that
459
+ // is safe to drop, and this is not.
460
+ return { allowed: true, reason: "no-clock-fail-open", room, key, lastAt: null, degraded: true };
461
+ }
462
+ const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : roomNoticeWindowMs();
463
+ const ledger = readLedger(o);
464
+ const degraded = ledger.degraded === true;
465
+ const verdict = noticeAllowed({
466
+ ledger, service: o.service, channel: o.channel, notice: o.notice, text: o.text, now: o.now, windowMs,
467
+ });
468
+ if (!verdict.allowed) return degraded ? { ...verdict, degraded } : verdict;
469
+ if (o.commit === false) return degraded ? { ...verdict, degraded, committed: false } : { ...verdict, committed: false };
470
+
471
+ const next = recordNotice({
472
+ ledger, service: o.service, channel: o.channel, notice: o.notice, text: o.text, now: o.now, noticeWindowMs: windowMs,
473
+ });
474
+ const ok = persist(o, next, room);
475
+ if (!ok) return { ...verdict, degraded: true, committed: true };
476
+ return degraded ? { ...verdict, degraded, committed: true } : { ...verdict, committed: true };
477
+ }
478
+
479
+ export default {
480
+ DEFAULT_ROOM_INTERIM_WINDOW_MS,
481
+ DEFAULT_ROOM_NOTICE_WINDOW_MS,
482
+ roomInterimWindowMs,
483
+ roomNoticeWindowMs,
484
+ roomKey,
485
+ noticeKey,
486
+ textDigest,
487
+ interimAllowed,
488
+ noticeAllowed,
489
+ recordInterim,
490
+ recordNotice,
491
+ pruneLedger,
492
+ ledgerPath,
493
+ LEDGER_BASENAME,
494
+ readLedger,
495
+ claimRoomInterim,
496
+ claimRoomNotice,
497
+ };