@securecoms/agent-bridge 0.23.0 → 0.24.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.
@@ -239,6 +239,18 @@ export interface BridgeAgentDeps {
239
239
  * timer delays.
240
240
  */
241
241
  rng?: () => number;
242
+ /**
243
+ * How long start() waits for its boot drain before it stops blocking on it
244
+ * (default {@link BOOT_DRAIN_WAIT_MS_DEFAULT}). Past it, start() finishes and
245
+ * the boot key-package publish is DEFERRED until an ingest cycle settles:
246
+ * see start(). Injected so tests do not wait a minute.
247
+ */
248
+ bootDrainWaitMs?: number;
249
+ /**
250
+ * SEC-06 #18: the wait between in-process reply publish retries after a rate
251
+ * limit. Defaults to an unref'd setTimeout. Injected so tests do not sleep.
252
+ */
253
+ replySleep?: (ms: number) => Promise<void>;
242
254
  /**
243
255
  * Task 4 (self-hosted fast voice): test seam for the fast-path LLM client.
244
256
  * Defaults to `new FastLlmClient(cfg.voice.fastPath, fetchImpl)` when
@@ -293,6 +305,27 @@ export declare const DEAD_LETTER_NOTICE_INTERVAL_MS: number;
293
305
  * agent's own membership row before deciding.
294
306
  */
295
307
  export declare const PAUSE_RECHECK_MS = 30000;
308
+ /**
309
+ * Stranded sender (PR #751 review H-1): how often a channel held after a send
310
+ * 409 `mls.group_superseded` re-reads its held group to see whether it
311
+ * converged (a local read, no network).
312
+ */
313
+ export declare const SUPERSEDE_RECHECK_MS = 30000;
314
+ /**
315
+ * Stranded sender (PR #751 review H-1): the longest a channel's inbound is
316
+ * held, without spending delivery attempts, after a send 409
317
+ * `mls.group_superseded`. Convergence needs the OWNER's client online (a
318
+ * forked or stranded member cannot self-heal), so the bound must cover an
319
+ * owner who is away for a working day or a night, not the ~150 s the
320
+ * dead-letter budget (5 attempts, 2^n x 5 s) allows. 24 h does, and it sits
321
+ * well inside the 7-day reply-intent retention, so a held message never
322
+ * outlives the intent that answers it. The hold is measured from the FIRST
323
+ * 409 (a repeat 409 does not extend it). When it expires the channel is
324
+ * released and the ordinary budget applies again (the refund stops), so a
325
+ * channel that never converges still dead-letters, about 24 h later instead
326
+ * of about 150 s later.
327
+ */
328
+ export declare const SUPERSEDE_HOLD_MAX_MS: number;
296
329
  /**
297
330
  * TTL for the Phase 2 channelMeta cache. `is_dm` is immutable for a channel_id,
298
331
  * so this exists only so a cached channel `name` re-resolves after a channel
@@ -334,13 +367,53 @@ export declare class InvalidMessageIdError extends Error {
334
367
  }
335
368
  /**
336
369
  * Deterministic message id for the voice fast-path reply to inbound turn
337
- * `messageId`: SHA-256 of `fast:<messageId>`, first 16 bytes, RFC 4122
338
- * version/variant nibbles set. Live finding 2026-09-06: the previous
339
- * `fast-<messageId>` string failed `resolveMessageId` ("message_id must be a
340
- * UUID") on every turn, so the fast path never sent once. Same input → same
341
- * UUID, so the idempotency argument in `runFastPath`'s doc still holds.
370
+ * `messageId`, formatted as an RFC 4122 UUID. Live finding 2026-09-06: the
371
+ * previous `fast-<messageId>` string failed `resolveMessageId` ("message_id
372
+ * must be a UUID") on every turn, so the fast path never sent once. Same input
373
+ * → same UUID, so the idempotency argument in `runFastPath`'s doc still holds.
374
+ *
375
+ * SEC-06 #18: KEYED with the identity-seed-derived reply key and bound to the
376
+ * agent and channel. It used to be SHA-256 of `fast:<messageId>` alone, which
377
+ * any channel member could compute and publish under first (the agent's fast
378
+ * reply then collides on the backend's message_id key and never publishes),
379
+ * and which two agents fast-answering the same room message shared. Its scope
380
+ * (`fast:`) never meets a runtime reply's (`reply:`).
342
381
  */
343
- export declare function fastReplyId(messageId: string): string;
382
+ export declare function fastReplyId(key: Uint8Array, identityId: string, channelId: string, messageId: string): string;
383
+ /**
384
+ * SEC-06 #18: how many times one reply publish is retried in-process after a
385
+ * rate limit (the bridge's room floor, or a backend 429) before the failure is
386
+ * surfaced. A surfaced failure leaves the reply intent pending, so the
387
+ * redelivery republishes the SAME reply rather than re-running the model.
388
+ */
389
+ export declare const REPLY_RATE_LIMIT_RETRIES = 3;
390
+ /** SEC-06 #18: bounds on one rate-limit wait (ms). */
391
+ export declare const REPLY_RATE_LIMIT_MIN_WAIT_MS = 250;
392
+ export declare const REPLY_RATE_LIMIT_MAX_WAIT_MS = 30000;
393
+ /**
394
+ * SEC-06 #18 (#706 @1fe47aa2): an EXACT retry that arrives while its original
395
+ * is still in its transaction gets 409 `message_in_flight`. That is
396
+ * retry-later, never "re-encrypt": the original may yet be accepted. The same
397
+ * bytes are resent after 1 s, 2 s, 4 s; after that the failure surfaces
398
+ * (intent still pending) and the redelivery backoff and delivery cap bound it.
399
+ */
400
+ export declare const REPLY_IN_FLIGHT_RETRIES = 3;
401
+ export declare const REPLY_IN_FLIGHT_BASE_WAIT_MS = 1000;
402
+ /** PR #716 review HIGH-1: bound on part advances after slot collisions, per send. */
403
+ export declare const REPLY_MAX_PART_ADVANCES = 8;
404
+ /** Options shared by `send` and `sendA2A`. */
405
+ export interface SendOptions {
406
+ /** Phase 3 F3: whether this send is a substantive agent turn (default true). */
407
+ agentTurn?: boolean;
408
+ /**
409
+ * SEC-06 #18: the inbound message this send answers. Makes the send an
410
+ * idempotent reply: a deterministic message_id plus a durable intent written
411
+ * before the publish.
412
+ */
413
+ inReplyTo?: string;
414
+ /** SEC-06 #18: caller idempotency key (WS path); names the reply part. */
415
+ idempotencyKey?: string;
416
+ }
344
417
  /**
345
418
  * B7 (FileSafe vault): a file-backed {@link OwnerAuthorityStore} for the
346
419
  * daemon's owner-pin TOFU state (mirrors B4b's browser-side store, which
@@ -383,6 +456,25 @@ export declare class SendForbiddenError extends Error {
383
456
  readonly channelId: string;
384
457
  constructor(channelId: string);
385
458
  }
459
+ /**
460
+ * Stranded sender (spec 2026-09-26 §7.4): the backend refused a publish with
461
+ * 409 `mls.group_superseded`: the envelope was sealed under a group that is no
462
+ * longer the channel's active one. Nothing was stored, nothing fanned out.
463
+ *
464
+ * TERMINAL for THIS send (no in-place retry; a re-seal under the same held
465
+ * group would be refused the same way), but NOT for the reply: the answered
466
+ * inbound message is not retired and a reply intent stays pending, so the
467
+ * reply is republished once the bridge holds the active group. The bridge
468
+ * never wipes on it (no `healWedgedChannel`, no `resetChannelForRejoin`).
469
+ *
470
+ * `activeGroupId` is the group the backend named, or null when the channel has
471
+ * no active group (fail closed: nothing to converge to).
472
+ */
473
+ export declare class GroupSupersededSendError extends Error {
474
+ readonly channelId: string;
475
+ readonly activeGroupId: string | null;
476
+ constructor(channelId: string, activeGroupId: string | null);
477
+ }
386
478
  /** Thrown by `agent.action.*` when a runId (or nodeId within a run) is unknown
387
479
  * or the run has already settled — settle() is terminal, mirroring the client
388
480
  * ingest side's "later partials for a settled run are dropped" rule. */
@@ -435,6 +527,8 @@ export interface AgentActionAPI {
435
527
  wrap<T>(runId: string, fn: () => T | Promise<T>, opts: AgentActionWrapOpts): Promise<T>;
436
528
  }
437
529
  export declare const BRIDGE_VERSION: string;
530
+ /** start() waits this long for its boot drain before it stops blocking on it. */
531
+ export declare const BOOT_DRAIN_WAIT_MS_DEFAULT = 60000;
438
532
  export declare class BridgeAgent {
439
533
  private readonly cfg;
440
534
  private readonly transport;
@@ -509,8 +603,39 @@ export declare class BridgeAgent {
509
603
  * Bounded; oldest forgotten first.
510
604
  */
511
605
  private readonly retiredRuntimeIds;
606
+ /** SEC-06 #18: HMAC key for deterministic reply ids and body MACs. */
607
+ private readonly replyIdKey;
608
+ /**
609
+ * PR #716 review MEDIUM-5: in-flight idempotent sends and intent replays.
610
+ * close() waits for them before it closes the store.
611
+ */
612
+ private readonly replyWork;
613
+ /** SEC-06 #18: channels whose redelivered message is being republished from its intent. */
614
+ private readonly replayingChannels;
615
+ /** SEC-06 #18: in-process locks: one resolve+claim per scope, one publish per part. */
616
+ private readonly replyIntentLocks;
617
+ private readonly replySleep;
618
+ /** SEC-06 #18: redelivered messages answered from a reply intent, not the model. */
619
+ private replyIntentReplays;
620
+ /** SEC-06 #18: in-process reply publish retries after a rate limit. */
621
+ private replyRateLimitRetries;
512
622
  /** Phase 3 F1: one pending membership recheck per paused channel holding work. */
513
623
  private readonly pauseRecheckTimers;
624
+ /**
625
+ * Stranded sender (PR #751 review H-1): channels whose send was refused 409
626
+ * `mls.group_superseded`, with the group held at that moment and when the
627
+ * hold began. Their inbound is held (never acked, no attempt spent) until
628
+ * the held group changes, a send succeeds, or SUPERSEDE_HOLD_MAX_MS passes.
629
+ */
630
+ private readonly supersededChannels;
631
+ /** H-1: channels whose hold EXPIRED; their 409s spend attempts again. */
632
+ private readonly supersedeHoldExpired;
633
+ /** H-1: one pending held-group recheck per held channel. */
634
+ private readonly supersedeRecheckTimers;
635
+ /** H-1: runtime messages whose CURRENT delivery attempt may be refunded once. */
636
+ private readonly supersedeRefundable;
637
+ /** M-1: channels this process has used as a FileSafe vault channel. */
638
+ private readonly vaultChannelIds;
514
639
  /** Phase 3 F3: consecutive agent turns per channel. */
515
640
  private readonly agentHops;
516
641
  private readonly maxAgentHops;
@@ -671,10 +796,55 @@ export declare class BridgeAgent {
671
796
  private readonly selfHealSweepEnabled;
672
797
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
673
798
  private static readonly SELF_HEAL_SWEEP_INTERVAL_MS;
799
+ /**
800
+ * A trigger that finds the in-flight ingest cycle older than this logs
801
+ * `receive.ingest_stalled` once for that cycle. Log only: the trigger still
802
+ * queues behind it (see ingestOnce). Two missed safety ticks.
803
+ */
804
+ private static readonly INGEST_STALL_LOG_MS;
805
+ /**
806
+ * An in-flight ingest cycle older than this is abandoned by the next trigger
807
+ * (see ingestOnce). A WALL-CLOCK age for the whole cycle, not a bound on any
808
+ * one request: a cycle makes many requests in sequence, each with its own
809
+ * budget, so a cycle that is only slow can pass it (see ingestOnce for what
810
+ * that costs).
811
+ */
812
+ private static readonly INGEST_ABANDON_MS;
674
813
  private static readonly SELF_HEAL_SWEEP_JITTER_MS;
675
814
  private selfHealTimer?;
676
815
  private bootSweepTimer?;
677
816
  private readonly rng;
817
+ private readonly bootDrainWaitMs;
818
+ /** Resolved (and cleared) each time an ingest cycle settles: see start(). */
819
+ private ingestSettledWaiters;
820
+ /** The boot key-package publish, when start() had to defer it. Test seam. */
821
+ private deferredBootPublish;
822
+ /**
823
+ * True from the moment start() begins its boot drain until an ingest cycle
824
+ * has SETTLED (the drain itself, or the valve's replacement). While it is
825
+ * true, no path outside that drain may rotate the device key package: see
826
+ * kpRotationRefusedByBootDrain.
827
+ */
828
+ private bootDrainPending;
829
+ /**
830
+ * #811 L-1: channels whose onWedged heal the boot drain REFUSED (a send the
831
+ * drain started quarantined them outside its exemption, #799 M-2). The SDK
832
+ * fires onWedged once per episode, so nothing else would ask again until a
833
+ * sweep proves the channel stuck. Signalled once after the drain settles:
834
+ * see signalWedgesRefusedByBootDrain. A set, so bounded by the channel count;
835
+ * a refusal can only happen while `bootDrainPending`, once per process.
836
+ */
837
+ private readonly wedgeSignalsRefusedByBootDrain;
838
+ /** #811 L-1: the post-drain pass has run; a refusal queued after it (its
839
+ * catch ran on a later microtask than the settle) kicks the pass itself. */
840
+ private wedgeSignalsAfterBootDrainArmed;
841
+ /** #811 L-2: the rotation sites whose boot-drain refusal was already logged. */
842
+ private readonly bootDrainRefusalLogged;
843
+ /** The running ingest cycle's exemption, for everything the cycle runs.
844
+ * ⛔ Read ONLY through drainExemptionAtCallIn(), and only where the drain
845
+ * calls in: timers created inside a cycle inherit this context, so a check
846
+ * that read it anywhere else would exempt them too (#789 LOW). */
847
+ private readonly ingestCycleContext;
678
848
  private readonly rateFloor;
679
849
  /**
680
850
  * agent.action.* run state, keyed by runId.
@@ -734,6 +904,10 @@ export declare class BridgeAgent {
734
904
  private readonly waiters;
735
905
  private pingHandle?;
736
906
  private safetyTimer?;
907
+ /** The ingest cycle now running, if any (see ingestOnce: single-flight). */
908
+ private ingestInFlight;
909
+ /** The ONE follow-up cycle every trigger during the in-flight cycle shares. */
910
+ private ingestFollowUp;
737
911
  private runtimeConfigTimer?;
738
912
  private readonly listeners;
739
913
  private startedAt;
@@ -813,6 +987,96 @@ export declare class BridgeAgent {
813
987
  */
814
988
  private capBuffer;
815
989
  start(): Promise<void>;
990
+ /**
991
+ * #811 L-1: send the re-Welcome signal ONCE for each channel whose onWedged
992
+ * heal the boot drain refused. Runs after the drain settled and after the
993
+ * boot key-package publish, so `bootDrainPending` is false and nothing here
994
+ * can be refused again.
995
+ *
996
+ * ⛔ Never a wipe. The refused heal already ran its wipe step (under its own
997
+ * anti-thrash floor) before it reached the signal. This pass only re-sends
998
+ * the half that was refused, and only for a channel that STILL holds no
999
+ * state: one that holds state now rejoined meanwhile (or someone else put
1000
+ * state there), and asking for it is the "ask without repudiating" churn
1001
+ * healWedgedChannel itself refuses. The signal is the same one the
1002
+ * no-local-state Task-11 branch and the sweep send, under the same shared
1003
+ * per-channel cooldown. Never throws: each channel has its own catch.
1004
+ */
1005
+ private signalWedgesRefusedByBootDrain;
1006
+ /** #811 L-1: queue a channel whose wedge heal the boot drain refused. */
1007
+ private queueWedgeSignalAfterBootDrain;
1008
+ /**
1009
+ * Arm the self-heal sweeps: the periodic one and the once-at-boot one.
1010
+ *
1011
+ * ⛔ Only after the boot drain has settled AND the boot key-package publish
1012
+ * has run. selfHealSweep opens with rotateDeviceKeyPackage(), which can
1013
+ * delete a TTL-lapsed bundle, and a Welcome still waiting in the boot drain
1014
+ * is sealed to that bundle: it would be discarded and acked (#789
1015
+ * verification NO-GO: rng 0 fired the boot sweep inside the slow-drain
1016
+ * window).
1017
+ */
1018
+ private armSelfHealSweeps;
1019
+ /**
1020
+ * Defence in depth for DRAIN BEFORE ROTATE (see start()). True, and logged,
1021
+ * when a key-package rotation is attempted while the boot drain is still
1022
+ * unsettled from OUTSIDE that drain: a send's supersede convergence, a
1023
+ * sweep, any future caller. Such a rotation can delete the held bundle that
1024
+ * a Welcome still inside the drain is sealed to.
1025
+ *
1026
+ * The drain's own calls are exempt: the SDK's onWedged / onSupersedeUnopenable
1027
+ * heals fired from its receive pass (the onWedged heal is detached), and its
1028
+ * phase-0 re-Welcome signal. That ordering is unchanged from before the boot
1029
+ * wait was bounded, and refusing it would change the drain. #789 LOW: the
1030
+ * exemption is an explicit DrainExemption handed down from those three
1031
+ * call-in points, never the inherited AsyncLocalStorage context, so a timer
1032
+ * or sweep scheduled inside the drain that calls a rotator is refused, as is
1033
+ * the late work of a cycle the valve abandoned. The hook call-ins DO read
1034
+ * the context, so sends the cycle starts and the send-side convergence run
1035
+ * outside it (#799 M-2; see DrainExemption for the residual).
1036
+ * Only the bridge-level rotators route through here. The SDK's own
1037
+ * publishes (ensureSession bootstrap, vault owner, reset supersede) mint only
1038
+ * when NO bundle is held, so they delete nothing.
1039
+ */
1040
+ /**
1041
+ * The boot KeyPackage publish (architect ruling on the #793 M1 proposal, 1B).
1042
+ * Runs only once the boot drain settled (`bootDrainPending` is already false).
1043
+ * The plain publish can no longer rotate an expired KeyPackage without a drain
1044
+ * receipt, and the bridge's boot drain is its own ingest cycle, which cannot
1045
+ * mint an SDK receipt. So the helper runs its OWN drain here (one pull, joins
1046
+ * only channels with no state, right after the boot ingest) and rotates only
1047
+ * with that receipt. A failed read must never fail the boot: fall back to the
1048
+ * plain publish, which mints when no bundle is held and never deletes.
1049
+ */
1050
+ private bootReconcileKeyPackage;
1051
+ private kpRotationRefusedByBootDrain;
1052
+ /**
1053
+ * #799 L-1: give back a re-Welcome cooldown spent on a signal the boot drain
1054
+ * refused (the same restore as a "deferred" outcome). `last` is the value
1055
+ * read before the signal: an earlier request time, or -Infinity for never.
1056
+ * True when `e` was that refusal: it is a wait, already logged once per
1057
+ * site (kpRotationRefusedByBootDrain), so the caller logs no failure line
1058
+ * for it (#811 L-2).
1059
+ */
1060
+ private restoreWelcomeCooldownIfRefused;
1061
+ /**
1062
+ * #799 M-2: run `fn` OUTSIDE the ingest cycle's AsyncLocalStorage context.
1063
+ * For a SEND a cycle starts (the fast-path reply, the summarize answer) and
1064
+ * for the send-side supersede convergence: a send is not the drain, but it
1065
+ * can reach the SDK's onSupersedeUnopenable hook (its reconcileForSend
1066
+ * receive pass, or the send-path adopt), and the hook reads the context at
1067
+ * call-in. Outside it, the hook finds no exemption and a rotation while the
1068
+ * boot drain is unsettled is refused.
1069
+ */
1070
+ private outsideIngestCycle;
1071
+ /**
1072
+ * The running ingest cycle's exemption, when the caller runs inside one.
1073
+ * Call it ONLY synchronously at a place where the drain itself calls in (the
1074
+ * two SDK hooks and the phase-0 signal), and pass the result down
1075
+ * explicitly. Nothing else may read the context: see ingestCycleContext.
1076
+ * Whether it still counts is decided at the rotator (`live`), because a
1077
+ * detached heal can hold it past the moment the valve abandons its cycle.
1078
+ */
1079
+ private drainExemptionAtCallIn;
816
1080
  /** C3: a random 0..SELF_HEAL_SWEEP_JITTER_MS decorrelation offset. */
817
1081
  private sweepJitter;
818
1082
  /**
@@ -841,15 +1105,32 @@ export declare class BridgeAgent {
841
1105
  * "private KeyPackage key no longer held" and the Welcome is discarded. A bare
842
1106
  * re-Welcome re-seals to the SAME diverged published KP — un-openable again —
843
1107
  * looping until the channel quarantines. So:
844
- * - published == held → publishKeyPackage() (no-op while held; mints only if
845
- * somehow none is held), then re-Welcome re-seals to the current KP we hold.
846
- * - published != held → forceRepublishKeyPackage() re-syncs the server to a
847
- * bundle we hold, so the owner's re-Welcome is openable. Orphan-safe: a
848
- * diverged published KP means every other pending Welcome sealed via the
849
- * server is un-openable too, so replacing the shared per-device bundle
850
- * strands nothing that was openable (see heldBundleMatchesPublishedKp).
1108
+ * - published == held → kept (no-op publish), then re-Welcome re-seals to
1109
+ * the current KP we hold.
1110
+ * - published != held → provider.reconcileHeldKeyPackage DRAINS the queued
1111
+ * Welcomes first, then rotates, so the owner's re-Welcome is openable and
1112
+ * nothing already queued is stranded (architect ruling on the #793 M1
1113
+ * proposal). The text below describes the pre-ruling hazard.
1114
+ * ⛔ NOT ORPHAN-SAFE, and nothing here drains first. `false` covers two
1115
+ * different states. (a) The server vends DIFFERENT bytes: Welcomes sealed
1116
+ * since that publish are un-openable anyway, but ones sealed to the held
1117
+ * bundle before it may still be queued. (b) The server vends NOTHING because
1118
+ * the held KeyPackage EXPIRED: it is absent, not diverged, and every Welcome
1119
+ * sealed to it before expiry is still queued AND still openable with the
1120
+ * held bundle. The unconditional delete strands both. Disclosed residual
1121
+ * (#792 review M1); the proposed fix is one shared SDK rotate helper that
1122
+ * drains before it deletes.
851
1123
  * getLastPublishedKpId then names the reconciled KP so the owner re-seals to it.
852
1124
  * Callers own their cooldown/rate-limit gate and try/catch — this does neither.
1125
+ *
1126
+ * ⛔ An UNDETERMINED answer never rotates. The predicate is the THROWING
1127
+ * variant: a failed batch KeyPackage read (a read deadline, 401/403, network)
1128
+ * or a failed held-bundle store read REJECTS out of here, so nothing is
1129
+ * published, nothing is deleted and no Welcome is requested. The catching
1130
+ * variant answered `false` for those, which routed an outage to
1131
+ * forceRepublishKeyPackage(): it deleted the device-wide bundle and every
1132
+ * Welcome already queued to it, for any channel, was discarded un-openable
1133
+ * (N-2). Every caller catches, and retries on its own cadence.
853
1134
  */
854
1135
  private signalHeldKpWelcome;
855
1136
  /**
@@ -869,7 +1150,11 @@ export declare class BridgeAgent {
869
1150
  * group (`continue`, no signal). ANY failure along the way (probe throws,
870
1151
  * signal throws) is swallowed: a wedge heal must never throw back into the
871
1152
  * SDK's onWedged caller. Single-shot per wedge episode — full rejoin
872
- * reliability is P1, not this hook's job.
1153
+ * reliability is P1, not this hook's job. #811 L-1: the one exception is a
1154
+ * signal the boot drain REFUSED (a send the drain started fired onWedged
1155
+ * outside its exemption): the channel is queued and signalled once after
1156
+ * the drain settles (signalWedgesRefusedByBootDrain), since the SDK will
1157
+ * not fire onWedged again for this episode.
873
1158
  */
874
1159
  private healWedgedChannel;
875
1160
  /**
@@ -994,27 +1279,236 @@ export declare class BridgeAgent {
994
1279
  * already resolves internally) propagates unchanged.
995
1280
  */
996
1281
  private sendWithSelfHealTerminal;
1282
+ /**
1283
+ * Stranded sender (spec 2026-09-26 §7.4, architect M-6 ruling + audit N-1):
1284
+ * the ONE place a bridge publish meets 409 `mls.group_superseded`. Every
1285
+ * `transport.publish` in this file goes through it, so the rule cannot be
1286
+ * skipped by a send path:
1287
+ *
1288
+ * 1. Terminal for the send: the typed {@link GroupSupersededSendError} is
1289
+ * thrown, and the SDK send helpers never retry it in place.
1290
+ * 2. NEVER a wipe. `healWedgedChannel` / `resetChannelForRejoin` are not
1291
+ * called: a wipe drops the held state, and the next Welcome then lands on
1292
+ * a device with no local state, where the join takes the first Welcome
1293
+ * for the channel with NO attestation check. A server-chosen response
1294
+ * code must not launder a refused supersede into an unverified join.
1295
+ * 3. With a pinned owner: `adoptSupersededGroup` once (see
1296
+ * {@link convergeAfterSendSuperseded}).
1297
+ * 4. The non-destructive signal (ditto).
1298
+ *
1299
+ * Any other error passes through unchanged.
1300
+ */
1301
+ private publishEnvelope;
1302
+ /**
1303
+ * PR #751 review M-1 (plan invariant F-1): adoption and the Welcome signal
1304
+ * run only on a channel KNOWN not to be a FileSafe vault channel. FileSafe
1305
+ * halts on a vault group mismatch by design and must never adopt, and the
1306
+ * SDK's adoptSupersededGroup leaves the vault skip to its caller.
1307
+ *
1308
+ * Fail closed. A channel is allowed only when the enclave channel listing
1309
+ * has shown it (the listing excludes hidden channels, and `vault` is the
1310
+ * only kind that is ever hidden; the self-heal sweep works from the same
1311
+ * listing, so this is the sweep's own scope) AND this process has never
1312
+ * used it as a vault channel. An unlisted channel gets one listing refresh;
1313
+ * still unlisted, it is skipped (a vault channel after a restart lands here,
1314
+ * as does a channel whose listing is unavailable).
1315
+ */
1316
+ private supersedeConvergenceAllowed;
1317
+ /**
1318
+ * H-1: hold `channelId`'s buffered inbound after a send 409. The FIRST 409
1319
+ * starts the hold; a repeat keeps its start (the bound is never extended)
1320
+ * and a channel whose hold already expired is not held again until it
1321
+ * converges or sends. Never throws.
1322
+ */
1323
+ private holdSuperseded;
1324
+ /**
1325
+ * H-1: whether `channelId`'s buffered work must be HELD (a send 409 is
1326
+ * outstanding and the hold has not expired). Holding arms a recheck so the
1327
+ * release does not depend on new traffic. Synchronous: it only reads the
1328
+ * map; the held-group read happens in {@link recheckSupersedeHold}.
1329
+ */
1330
+ private heldForSupersede;
1331
+ private scheduleSupersedeRecheck;
1332
+ /**
1333
+ * H-1: release the hold once the held group CHANGED (an adoption, a
1334
+ * receive-path supersede, or a rejoin converged it), or once it expired.
1335
+ * Otherwise keep holding, re-armed while work is buffered for the channel.
1336
+ */
1337
+ private recheckSupersedeHold;
1338
+ private expireSupersedeHold;
1339
+ private releaseSupersedeHold;
1340
+ private clearSupersedeHold;
1341
+ /**
1342
+ * H-1: a reply to `inReplyTo` was refused 409 `mls.group_superseded`. That
1343
+ * says nothing about the message, so the delivery that produced the reply
1344
+ * does not count against its dead-letter budget: give its attempt back, in
1345
+ * memory and durably. At most once per delivery (a multi-part reply cannot
1346
+ * refund twice), and not once the channel's hold has expired (then the
1347
+ * ordinary budget bounds a channel that never converges).
1348
+ */
1349
+ private refundSupersededDelivery;
1350
+ /**
1351
+ * Stranded sender, §7.4 rules 3 and 4, in that order.
1352
+ *
1353
+ * Rule 3 (audit N-1): with a PINNED owner, `adoptSupersededGroup(channelId,
1354
+ * activeGroupId, pinnedOwner)` once. The owner comes ONLY from the bridge's
1355
+ * own pin ({@link resolvePinnedOwnerHook}: the cached structural owner AND a
1356
+ * persisted pin that matches it), never from the server's roster. It is the
1357
+ * same verified core `trySupersede` runs on receive with the same pin, so it
1358
+ * adds no trust; it converges without waiting for the next receive pass.
1359
+ * Without a pin: no adoption.
1360
+ *
1361
+ * Rule 4 (architect M-6): the sweep's non-destructive diverged-channel
1362
+ * action, `signalHeldKpWelcome(activeGroupId)`, at most once per the SHARED
1363
+ * per-channel `lastWelcomeRequest` cooldown (the same map and floor as the
1364
+ * self-heal sweep, so a 409 storm cannot out-pace the sweep's own cadence).
1365
+ *
1366
+ * Both are skipped when there is nothing to converge to (no active group:
1367
+ * fail closed). The signal is also skipped when the bridge already holds
1368
+ * the group the backend named (an adoption just converged it, or it
1369
+ * converged between the seal and the answer): a request for a group we hold
1370
+ * costs the committer a Remove+Add and cannot be consumed. Never throws: a
1371
+ * failed adoption or signal must not mask the typed send error.
1372
+ */
1373
+ private convergeAfterSendSuperseded;
1374
+ /**
1375
+ * The terminal classification of {@link sendWithSelfHealTerminal}, shared
1376
+ * with the SEC-06 #18 cached-envelope resend (which publishes prebuilt bytes
1377
+ * and so does not go through sendWithSelfHeal).
1378
+ */
1379
+ private publishTerminal;
997
1380
  /**
998
1381
  * `opts.agentTurn` (default true): whether this send is a SUBSTANTIVE agent
999
1382
  * turn for the Phase 3 F3 hop count. Notices (daily limit, dead letter) and
1000
1383
  * continuation parts of one multi-part reply pass false (final-wave I2).
1384
+ *
1385
+ * SEC-06 #18: `opts.inReplyTo` / `opts.idempotencyKey` make the send an
1386
+ * idempotent reply (see {@link sendIdempotent}); `messageId` must then be
1387
+ * absent, because the id is derived.
1001
1388
  */
1002
- send(channelId: string, text: string, messageId?: string, opts?: {
1003
- agentTurn?: boolean;
1004
- }): Promise<{
1389
+ send(channelId: string, text: string, messageId?: string, opts?: SendOptions): Promise<{
1005
1390
  message_id: string;
1006
1391
  status: "sent";
1007
1392
  }>;
1393
+ /** PR #716 review MEDIUM-5: register reply work so close() can wait for it. */
1394
+ private trackReplyWork;
1008
1395
  sendA2A(channelId: string, a2a: {
1009
1396
  a2a_version: string;
1010
1397
  kind: "message" | "task" | "artifact";
1011
1398
  object: Record<string, unknown>;
1012
- }, messageId?: string, opts?: {
1013
- agentTurn?: boolean;
1014
- }): Promise<{
1399
+ }, messageId?: string, opts?: SendOptions): Promise<{
1015
1400
  message_id: string;
1016
1401
  status: "sent";
1017
1402
  }>;
1403
+ /**
1404
+ * Seal and publish one application message under `id`: the room/a2a rate
1405
+ * floor, join reconcile, and the shared self-heal seam. `onEnvelope` runs
1406
+ * for every envelope built, BEFORE it is published (SEC-06 #18 persists it
1407
+ * there so a retry can resend the exact bytes).
1408
+ */
1409
+ private publishBody;
1410
+ private enforceBodyRateFloor;
1411
+ /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
1412
+ private afterPublish;
1413
+ private isIdempotentSend;
1414
+ /**
1415
+ * SEC-06 #18: publish a reply part idempotently.
1416
+ *
1417
+ * 1. Resolve and claim the intent under one lock per scope. An explicit
1418
+ * idempotency_key names the part. Without one, a send whose body equals
1419
+ * the MOST RECENT part of this inbound is a retry of that part (PR #716
1420
+ * review MEDIUM-2: an older identical part is not matched, so A, B, A are
1421
+ * three messages); anything else is a new part, numbered from the STORE
1422
+ * (1 + the highest recorded ordinal, review HIGH-1), never from memory,
1423
+ * so a restart cannot reuse a published part's id.
1424
+ * 2. The claim is durable BEFORE any publish. An existing intent for the
1425
+ * slot is authoritative when it holds this body; one holding a different
1426
+ * body is skipped past (the next ordinal).
1427
+ * 3. Publish it (see {@link publishIntent}).
1428
+ */
1429
+ private sendIdempotent;
1430
+ /**
1431
+ * SEC-06 #18 / PR #716 review HIGH-1: resolve and claim an unkeyed part of
1432
+ * the reply to `inReplyTo`. Caller holds the scope lock. `matchRetry` false
1433
+ * after a slot collision: the body must go to a NEW part.
1434
+ */
1435
+ private claimUnkeyedPart;
1436
+ /** SEC-06 #18: run `fn` after every earlier holder of `lockKey` settled. */
1437
+ private withReplyLock;
1438
+ /**
1439
+ * SEC-06 #18: publish one pending intent, at most one publish in flight per
1440
+ * intent in this process. A sent intent returns its id without publishing.
1441
+ *
1442
+ * - The row is verified first (review LOW-6); a row that fails is dropped.
1443
+ * - A TERMINAL refusal abandons the reply's unpublished parts (the inbound
1444
+ * is retired by the caller), or the one keyed intent, so no sealed reply
1445
+ * text lingers (review LOW-8).
1446
+ * - A slot collision (review HIGH-1): an unkeyed part is marked skipped and
1447
+ * its body re-claimed at the next ordinal; a keyed one is refused with
1448
+ * {@link IdempotencyConflictError}.
1449
+ *
1450
+ * Returns the id the reply was published under (a moved part's new id).
1451
+ */
1452
+ private publishIntent;
1453
+ /**
1454
+ * PR #716 review LOW-6: the row's id derives from its own identity, channel,
1455
+ * scope and part, and its body matches its MAC (which binds id and channel).
1456
+ */
1457
+ private intentVerifies;
1458
+ /**
1459
+ * SEC-06 #18 item 3: a rate-limited reply retries the PUBLISH in-process
1460
+ * with backoff, never the model turn; so does an exact resend whose
1461
+ * original is still in flight. After REPLY_RATE_LIMIT_RETRIES a rate limit
1462
+ * surfaces as the bridge's own `rate_limited`, and after
1463
+ * REPLY_IN_FLIGHT_RETRIES an in-flight answer surfaces as an ambiguous
1464
+ * failure. Either way the intent stays pending, so the redelivery
1465
+ * republishes the same reply.
1466
+ */
1467
+ private publishIntentWithRetry;
1468
+ /**
1469
+ * One publish attempt of a pending intent.
1470
+ *
1471
+ * If a signed envelope was persisted by an earlier attempt, resend THOSE
1472
+ * BYTES: the earlier attempt may have been accepted, and the backend
1473
+ * replays the original ack only for identical canonical bytes (#706; a
1474
+ * resealed retry of an accepted message is a 409). Never re-encrypt a reply
1475
+ * that may already have been accepted. The #706 @1fe47aa2 table:
1476
+ * - 200 (`idempotent_replay`): done, the original ack.
1477
+ * - 409 `message_in_flight`, 429 `tenant_busy`, 503 `server_busy`: retry
1478
+ * the same bytes later ({@link ReplyInFlightError}).
1479
+ * - 409 `replay_nonce` on the cached bytes: the earlier attempt burned the
1480
+ * nonce and did NOT commit, so nothing is stored under this id: re-seal
1481
+ * (same id, fresh nonce, current epoch).
1482
+ * - 400 `mls.stale_epoch`: answered only for a NEW id: re-seal.
1483
+ * - 409 `message_id_conflict`: the id is held by OTHER bytes, so it is a
1484
+ * slot collision ({@link ReplyIdCollisionError}), never "delivered".
1485
+ * - 400 `mls.group_header_required` (stranded sender): the cached bytes
1486
+ * carry no group header and this population must name its group.
1487
+ * Answered before the nonce is burned and before anything is stored:
1488
+ * re-seal (a fresh seal captures the group, so it carries the header).
1489
+ * - 409 `mls.group_superseded` (stranded sender): step 4b answers a stored
1490
+ * id first, so reaching this answer proves NOTHING is stored under the
1491
+ * id; the nonce was burned at step 5. Clear the bytes, and stop: the send
1492
+ * is terminal ({@link GroupSupersededSendError}, spec §7.4), and a
1493
+ * re-seal now would seal under the same superseded group. The NEXT
1494
+ * attempt re-seals, after the bridge holds the active group.
1495
+ *
1496
+ * Stranded sender, carriage (spec §7.3): a cached envelope is resent with the
1497
+ * group recorded for those exact bytes (the store checks the digest), or with
1498
+ * NO header when none is recorded (an intent from before this change). It is
1499
+ * never re-sealed pre-emptively for lack of a header.
1500
+ *
1501
+ * "Never publish twice" rests on the #706 contract plus one invariant of
1502
+ * this function: the cached envelope ALWAYS holds the last bytes posted
1503
+ * under this id. The envelope is saved (awaited) BEFORE every fresh POST,
1504
+ * and it is cleared only after the backend answered `mls.stale_epoch` or
1505
+ * `replay_nonce` for those very bytes, which #706 defines as "not accepted"
1506
+ * (nothing is stored under the id). So a POST of our own possibly-accepted
1507
+ * bytes is always a resend of those same bytes, which #706 answers with the
1508
+ * original ack; a conflict can only mean the slot belongs to another
1509
+ * message (for example an old reply whose intent was pruned).
1510
+ */
1511
+ private publishIntentOnce;
1018
1512
  private sendDeps;
1019
1513
  private receiveDeps;
1020
1514
  sendAttachment(channelId: string, filePath: string, caption?: string): Promise<{
@@ -1023,6 +1517,14 @@ export declare class BridgeAgent {
1023
1517
  }>;
1024
1518
  private vaultRoot;
1025
1519
  private vaultAuthHeaders;
1520
+ /**
1521
+ * SEC-06 #11 (FB-P1-024): every backend call this class makes directly goes
1522
+ * through the SDK's one deadline helper, so a stalled backend or proxy can
1523
+ * never hold a lane forever. `read` for idempotent GETs, `control` for
1524
+ * writes; blob transfers use `fetchWithDeadline` with a size-scaled budget.
1525
+ * A timeout rejects with the SDK's `FetchDeadlineError`.
1526
+ */
1527
+ private fetchBounded;
1026
1528
  /** M6 (Task 11): idempotent get-or-create resolve of the enclave's hidden
1027
1529
  * vault channel id — used as the fallback when a caller omits
1028
1530
  * vaultChannelId. Mirrors connect-web's filesafe-api.ts resolveVaultChannel
@@ -1116,7 +1618,7 @@ export declare class BridgeAgent {
1116
1618
  *
1117
1619
  * Dedupe: `fastPathSeen` is keyed by messageId so a redelivered/duplicate
1118
1620
  * InboxEntry (e.g. after an ack race) never fires the fast LLM — and never
1119
- * double-sends — twice for the same turn. The `fastReplyId(messageId)` send
1621
+ * double-sends — twice for the same turn. The `fastReplyId(...)` send
1120
1622
  * id is itself also deterministic, so even a duplicate call that slipped past the
1121
1623
  * in-memory dedupe (e.g. a process restart) resolves to the same envelope
1122
1624
  * message id rather than a second spoken reply.
@@ -1374,7 +1876,63 @@ export declare class BridgeAgent {
1374
1876
  */
1375
1877
  private actionWrap;
1376
1878
  protected startReceivePump(): void;
1879
+ /**
1880
+ * Run one ingest cycle, SINGLE-FLIGHT. Every trigger goes through here: the
1881
+ * delivery-ping pump, the 25 s safety tick, and start()'s boot drain.
1882
+ *
1883
+ * Two cycles must never run at once. Both pull the same unacked rows, custody
1884
+ * serves the same application message to both, and each copy feeds
1885
+ * per-message state before the copies merge in the runtime inbox: the
1886
+ * agent-hop counter counted one message twice, and the inflated run
1887
+ * suppressed and ACKED the next agent-authored message (a silent loss). Two
1888
+ * concurrent reconcileJoin passes over one channel's Welcome are not a
1889
+ * state this pump should be able to reach either.
1890
+ *
1891
+ * A trigger that lands while a cycle is in flight is NOT satisfied by that
1892
+ * cycle: it pulled before the trigger, so a message the ping announced could
1893
+ * sit until the next safety tick. It waits for ONE follow-up cycle, shared by
1894
+ * every trigger that arrived during the in-flight one, and its promise
1895
+ * settles when that follow-up settles. So "ingestOnce() resolved" still means
1896
+ * "a cycle that started after my call has run", and a burst of pings costs
1897
+ * at most one extra cycle.
1898
+ *
1899
+ * A slow cycle is NOT bypassed: a trigger behind it queues, and past
1900
+ * INGEST_STALL_LOG_MS logs `receive.ingest_stalled` once.
1901
+ *
1902
+ * THE VALVE. A cycle older than INGEST_ABANDON_MS is abandoned by the next
1903
+ * trigger: `receive.ingest_abandoned` is logged, the gate forgets it, and
1904
+ * every waiter queued behind it is released to a fresh cycle. It exists
1905
+ * because a cycle can still wait on something with no deadline: every
1906
+ * RestMlsBackend READ, ack and nack is bounded (read 10 s, control 15 s),
1907
+ * but its state-changing MLS writes (distribute, request-welcome, init,
1908
+ * reinit, key-package publish, commit lease) are deliberately not, and a
1909
+ * channel lock can be held by a send waiting on one. Without the valve, one
1910
+ * such await that never settles stops inbound for the life of the process,
1911
+ * where the pre-single-flight code's overlapping 25 s tick kept rows flowing.
1912
+ *
1913
+ * ⚠️ What the 10 minutes is and is not. It is the age of the whole cycle,
1914
+ * not a per-request bound: each request carries its own budget, and a cycle
1915
+ * makes many in sequence (a pull, a join per unjoined channel, a roster read
1916
+ * per channel, one decrypt pass over up to 100 rows, an ack). So a cycle
1917
+ * that is merely slow, and still progressing, can be abandoned too.
1918
+ *
1919
+ * ⚠️ What a late wake costs. The abandoned cycle keeps running if it ever
1920
+ * wakes, now beside the replacement: the overlap this gate exists to stop.
1921
+ * Two consequences, both at-least-once, neither a loss:
1922
+ * - Hop counting: the per-message idempotency in AgentHopTracker absorbs
1923
+ * the duplicate sighting.
1924
+ * - Redelivery to the runtime: the replacement may already have staged,
1925
+ * delivered AND had the runtime ack a message the abandoned cycle still
1926
+ * holds. The runtime ack removes it from `runtimePendingIds`, so the late
1927
+ * cycle's stageRuntimeMessage stages it again and the runtime is handed an
1928
+ * ALREADY-ACKED message a second time. The first-party runtime dedupes by
1929
+ * message_id; a third-party consumer of the bridge sees a duplicate. The
1930
+ * delivery row's own double ack is a no-op at the server
1931
+ * (`acked_at IS NULL`).
1932
+ */
1377
1933
  private ingestOnce;
1934
+ private startIngestCycle;
1935
+ private ingestCycle;
1378
1936
  drain(channelId: string | undefined, waitSeconds: number): Promise<BridgeMessage[]>;
1379
1937
  /** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
1380
1938
  subscribe(listener: () => void): () => void;
@@ -1390,6 +1948,21 @@ export declare class BridgeAgent {
1390
1948
  * backlog replays one message per channel at a time after a restart.
1391
1949
  */
1392
1950
  takeDeliverable(busyChannels: ReadonlySet<string>): BridgeMessage[];
1951
+ private hasReplyIntent;
1952
+ /**
1953
+ * SEC-06 #18: answer a redelivered message from its reply intent. Counts as
1954
+ * a delivery attempt (so the cap and backoff still bound it) and holds the
1955
+ * channel like an in-flight message. Success acknowledges the inbound; an
1956
+ * ambiguous failure requeues it behind the usual backoff, still intercepted.
1957
+ */
1958
+ private startReplyReplay;
1959
+ /**
1960
+ * Publish every pending part of `m`'s reply, in order, from the intent
1961
+ * (same id, same text), then record the reply receipt. A TERMINAL refusal
1962
+ * (paused, forbidden) resolves too: the inbound is retired, exactly as the
1963
+ * live send path retires it. Any other failure rejects.
1964
+ */
1965
+ private replayReplyIntents;
1393
1966
  /**
1394
1967
  * Phase 3 F1: gate ONE delivery of `message` to the runtime consumer. Returns
1395
1968
  * true when it may be sent (and counts the attempt durably), false when the
@@ -1532,6 +2105,15 @@ export declare class BridgeAgent {
1532
2105
  /** SDK-6: cumulative rows ack-drained because their group_id is RETIRED for
1533
2106
  * the channel (superseded fork-loser traffic). */
1534
2107
  retired_drained: number;
2108
+ /** INV-CUSTODY-DURABLE §5.C.6: rows silently drained past expires_at. */
2109
+ expired_drained: number;
2110
+ /** INV-CUSTODY-DURABLE §5.C.3: a decrypted, un-acked row is memory-only
2111
+ * (its durable write failed); a restart now would lose it. */
2112
+ custody_persist_failing: boolean;
2113
+ custody_persist_failures: number;
2114
+ /** #772 audit M2/M1: custody reads failing; the store's breaker open. */
2115
+ custody_read_failing: boolean;
2116
+ custody_store_unavailable: boolean;
1535
2117
  /** Track 5: cumulative artifact_share messages where download/decrypt/digest failed (fail-soft — acked, no redeliver). */
1536
2118
  failed_attachments: number;
1537
2119
  /** Task 4 (self-hosted fast voice): cumulative fast-path replies actually sent. */
@@ -1580,6 +2162,26 @@ export declare class BridgeAgent {
1580
2162
  * not be resolved. `null` folds into `degraded` exactly like `true`: healthy
1581
2163
  * requires a positive, current proof of non-expiry. */
1582
2164
  keypackage_expired: boolean | null;
2165
+ /** #792 review O4: FAILED held-vs-published KeyPackage reads (a read
2166
+ * deadline, 401/403, 429, network, a store read that throws). Since #792 a
2167
+ * failed read defers the re-Welcome request instead of rotating, so a
2168
+ * PERSISTENT failure is a device that silently never asks to be re-added.
2169
+ * `_consecutive` resets on the next read that returns an answer; alert on
2170
+ * it staying above zero across several sweeps. Not folded into `degraded`:
2171
+ * the read runs only when a channel needs a heal, so a stale streak can
2172
+ * outlive the need. A mismatch is an answer, never counted here. */
2173
+ keypackage_match_read_failures: number;
2174
+ keypackage_match_read_failures_consecutive: number;
2175
+ /** ms since epoch of the newest failed read, or null. */
2176
+ keypackage_match_read_last_failed_at: number | null;
2177
+ /** Architect ruling on the #793 M1 proposal, 1D: reconciles that DEFERRED
2178
+ * (an in-lock drain arm) and that were REFUSED on a full delivery page.
2179
+ * A device that keeps refusing is one the server cannot vend: alert on the
2180
+ * `_consecutive` streak. */
2181
+ keypackage_reconcile_deferred: number;
2182
+ keypackage_reconcile_deferred_consecutive: number;
2183
+ keypackage_reconcile_refused_page_full: number;
2184
+ keypackage_reconcile_refused_page_full_consecutive: number;
1583
2185
  /** A2 (dr-evil F3, decision D-3): the truthful roll-up — true when any
1584
2186
  * channel is definitively diverged OR the observations cannot be trusted
1585
2187
  * (stale/absent). Additive: `status` stays "ok" until the flip ships as
@@ -1647,6 +2249,10 @@ export declare class BridgeAgent {
1647
2249
  /** Phase 3 F3: agent-authored messages not answered by the loop guard. */
1648
2250
  agentLoopSuppressed: number;
1649
2251
  rateLimitedSends: number;
2252
+ /** SEC-06 #18: redelivered messages answered from a reply intent (no model run). */
2253
+ replyIntentReplays: number;
2254
+ /** SEC-06 #18: in-process reply publish retries after a rate limit. */
2255
+ replyRateLimitRetries: number;
1650
2256
  permanentlySkipped: number;
1651
2257
  /** SDK-6: cumulative rows ack-drained via QUARANTINE, distinct from the
1652
2258
  * same-group past-epoch permanentlySkipped drain. */
@@ -1654,6 +2260,9 @@ export declare class BridgeAgent {
1654
2260
  /** SDK-6: cumulative rows ack-drained because their group_id is RETIRED for
1655
2261
  * the channel (superseded fork-loser traffic). */
1656
2262
  retiredDrained: number;
2263
+ expiredDrained: number;
2264
+ /** INV-CUSTODY-DURABLE §5.C.3 custody health snapshot. */
2265
+ custody: ReturnType<MlsGroupKeyProvider["custodyHealth"]>;
1657
2266
  welcomeRequestsSent: number;
1658
2267
  /** Track 5: cumulative artifact_share failures (download/decrypt/digest). */
1659
2268
  failedAttachments: number;