@securecoms/agent-bridge 0.9.0 → 0.11.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.
package/dist/bin.js CHANGED
@@ -1371,6 +1371,24 @@ var MlsOperationQueue = class {
1371
1371
  }
1372
1372
  };
1373
1373
 
1374
+ // ../connect-sdk-ts/dist/groupkey/receive-classifier.js
1375
+ function classifyFailedRow(i) {
1376
+ if (i.messageType === "welcome")
1377
+ return "defer";
1378
+ if (i.messageType !== "commit" && i.messageType !== "application")
1379
+ return "defer";
1380
+ if (i.heldGroupId !== null && i.rowGroupId === i.heldGroupId) {
1381
+ const epochForVerdict = i.messageType === "commit" ? i.liveCommitEpoch : i.snapshotEpoch;
1382
+ if (epochForVerdict === void 0 || typeof i.rowEpoch !== "number")
1383
+ return "defer";
1384
+ const dead = i.messageType === "commit" ? i.rowEpoch <= epochForVerdict : i.rowEpoch < epochForVerdict;
1385
+ return dead ? "permanentlySkipped" : "defer";
1386
+ }
1387
+ if (i.heldGroupId !== null && i.retiredGroupIds !== null && i.retiredGroupIds.includes(i.rowGroupId))
1388
+ return "retiredDrained";
1389
+ return "defer";
1390
+ }
1391
+
1374
1392
  // ../connect-sdk-ts/dist/groupkey/mls.js
1375
1393
  var LABEL = "agentvault-connect-v1";
1376
1394
  var DEFAULT_STUCK_THRESHOLD = 2;
@@ -1387,6 +1405,18 @@ var LeafRosterMismatchError = class extends Error {
1387
1405
  this.name = "LeafRosterMismatchError";
1388
1406
  }
1389
1407
  };
1408
+ var AmbiguousOwnLeafError = class extends Error {
1409
+ deviceId;
1410
+ totalLeaves;
1411
+ candidateLeaves;
1412
+ constructor(deviceId, totalLeaves, candidateLeaves) {
1413
+ super(`removeDeviceFromLiveGroup: cannot identify device ${deviceId}'s leaf \u2014 the identity owns ${totalLeaves} leaves and ${candidateLeaves} remain after excluding this committer's own. Leaf credentials carry the identity, not the device, so the target is genuinely ambiguous. Refusing rather than evicting a device that was never revoked.`);
1414
+ this.deviceId = deviceId;
1415
+ this.totalLeaves = totalLeaves;
1416
+ this.candidateLeaves = candidateLeaves;
1417
+ this.name = "AmbiguousOwnLeafError";
1418
+ }
1419
+ };
1390
1420
  var HealAddAfterRemoveError = class extends Error {
1391
1421
  constructor(cause) {
1392
1422
  super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
@@ -1406,7 +1436,7 @@ var ForgedCredentialError = class extends Error {
1406
1436
  this.name = "ForgedCredentialError";
1407
1437
  }
1408
1438
  };
1409
- var MlsGroupKeyProvider = class {
1439
+ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1410
1440
  o;
1411
1441
  suite;
1412
1442
  managers = /* @__PURE__ */ new Map();
@@ -1475,6 +1505,11 @@ var MlsGroupKeyProvider = class {
1475
1505
  * the past-epoch {@link permanentlySkipped} drain. Read via
1476
1506
  * {@link quarantineDrainedCount}. */
1477
1507
  quarantineDrained = 0;
1508
+ /** Cumulative count of rows ack-drained because their group_id is RETIRED for
1509
+ * the channel (superseded fork-loser traffic). Distinct from the same-group
1510
+ * past-epoch {@link permanentlySkipped} drain. Read via
1511
+ * {@link retiredDrainedCount}. */
1512
+ retiredDrained = 0;
1478
1513
  constructor(o) {
1479
1514
  this.o = o;
1480
1515
  this.suite = o.suite ?? "xchacha20poly1305";
@@ -1556,9 +1591,25 @@ var MlsGroupKeyProvider = class {
1556
1591
  * Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
1557
1592
  * Surfaces a wedged channel that would otherwise "look idle": the daemon serves
1558
1593
  * it on GET /v1/metrics and the web shell reads it via a selector. A channel is
1559
- * `stuck` once it has had `stuckThreshold` consecutive decrypt failures with no
1560
- * intervening successful decrypt. Returns a plain object keyed by channelId;
1561
- * absence of a key means zero errors observed for that channel.
1594
+ * `stuck` once it has had `stuckThreshold` GENUINELY-WEDGED decrypt failures
1595
+ * with no intervening successful decrypt. Returns a plain object keyed by
1596
+ * channelId; absence of a key means zero errors observed for that channel.
1597
+ *
1598
+ * `stuck` gates on `wedgedRun`, NOT `consecutiveErrors` — the same correction
1599
+ * the quarantine gate already got (P0a). `consecutiveErrors` is bumped at the
1600
+ * top of the catch for EVERY failure, INCLUDING the benign past-epoch
1601
+ * duplicate-commit churn that the `permanentlyDead` path immediately and
1602
+ * safely auto-drains. Deriving the user-visible health signal from it meant
1603
+ * ordinary at-least-once / committer-race traffic painted a perfectly healthy
1604
+ * channel red.
1605
+ *
1606
+ * That is the prod symptom of 2026-07-31: a freshly-welcomed federated partner
1607
+ * was queued the very Add commit its Welcome already contained. It joined the
1608
+ * correct group at the correct epoch, correctly identified the replayed commit
1609
+ * as dead and drained it — and still showed "This channel is stuck", because
1610
+ * the drain happened AFTER the error had been counted. `wedgedRun` counts only
1611
+ * rows left UN-acked (actually stuck), and a clean decrypt clears it, so a
1612
+ * genuine wedge still reports exactly as before.
1562
1613
  */
1563
1614
  errorStats() {
1564
1615
  const out = {};
@@ -1567,7 +1618,7 @@ var MlsGroupKeyProvider = class {
1567
1618
  decryptErrors: e.decryptErrors,
1568
1619
  consecutiveErrors: e.consecutiveErrors,
1569
1620
  lastErrorAt: e.lastErrorAt,
1570
- stuck: e.consecutiveErrors >= this.stuckThreshold,
1621
+ stuck: e.wedgedRun >= this.stuckThreshold,
1571
1622
  quarantined: e.quarantined
1572
1623
  };
1573
1624
  }
@@ -1616,6 +1667,10 @@ var MlsGroupKeyProvider = class {
1616
1667
  quarantineDrainedCount() {
1617
1668
  return this.quarantineDrained;
1618
1669
  }
1670
+ /** See {@link retiredDrained}. */
1671
+ retiredDrainedCount() {
1672
+ return this.retiredDrained;
1673
+ }
1619
1674
  /** Evict the live in-memory MLS group manager for a channel. Used when the
1620
1675
  * device leaves or deletes a channel: dropping the persisted store record is
1621
1676
  * not enough on its own, because a warm manager keeps decrypting until the
@@ -1684,6 +1739,9 @@ var MlsGroupKeyProvider = class {
1684
1739
  if (cached)
1685
1740
  return cached;
1686
1741
  const saved = await this.o.store.load(channelId);
1742
+ const again = this.managers.get(channelId);
1743
+ if (again)
1744
+ return again;
1687
1745
  if (!saved)
1688
1746
  throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
1689
1747
  const mgr = new MLSGroupManager(saved.ciphersuite);
@@ -2232,6 +2290,34 @@ var MlsGroupKeyProvider = class {
2232
2290
  * `removed_device_id` (instead of removed_identity_id); the server resolves the
2233
2291
  * device's identity, asserts it matches, and purges that ONE device.
2234
2292
  */
2293
+ /**
2294
+ * This committer's OWN leaf index in the given group's ratchet tree, or null
2295
+ * when it cannot be read.
2296
+ *
2297
+ * `MLSGroupManager.state` is marked `@internal` but is a declared (non-private)
2298
+ * field, and `state.privatePath.leafIndex` is the same value ts-mls itself uses
2299
+ * as the sender leaf when protecting application data. Reading it here avoids a
2300
+ * change to `@agentvault/crypto`; if a public accessor is added later this
2301
+ * should delegate to it.
2302
+ *
2303
+ * Returns null rather than throwing so every caller must decide explicitly what
2304
+ * an unknown own-leaf means.
2305
+ *
2306
+ * ⚠️ The one caller today does NOT fail closed on null. In
2307
+ * {@link removeDeviceFromLiveGroupUnlocked} the branch is
2308
+ * `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
2309
+ * identities at all, so a null return sends an OWN-identity removal down the
2310
+ * positional `leaves[pos]` path, which is exactly the add-order guess the
2311
+ * self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
2312
+ * permutes that mapping after any Remove). Null is therefore currently
2313
+ * indistinguishable from "foreign identity", and only the cardinality guard
2314
+ * stands between it and evicting the wrong device of the same human.
2315
+ * Correcting that is Phase 1b, not this comment.
2316
+ */
2317
+ ownLeafIndex(mgr) {
2318
+ const idx = mgr.state?.privatePath?.leafIndex;
2319
+ return typeof idx === "number" ? idx : null;
2320
+ }
2235
2321
  async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
2236
2322
  return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
2237
2323
  }
@@ -2244,7 +2330,17 @@ var MlsGroupKeyProvider = class {
2244
2330
  if (leaves.length !== opts.orderedDeviceIds.length) {
2245
2331
  throw new LeafRosterMismatchError();
2246
2332
  }
2247
- const leafIndex = leaves[pos];
2333
+ let leafIndex;
2334
+ const ownLeaf = this.ownLeafIndex(mgr);
2335
+ if (ownLeaf !== null && leaves.includes(ownLeaf)) {
2336
+ const others = leaves.filter((i) => i !== ownLeaf);
2337
+ if (others.length !== 1) {
2338
+ throw new AmbiguousOwnLeafError(deviceId, leaves.length, others.length);
2339
+ }
2340
+ leafIndex = others[0];
2341
+ } else {
2342
+ leafIndex = leaves[pos];
2343
+ }
2248
2344
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
2249
2345
  const priorEpoch = Number(mgr.epoch);
2250
2346
  const { commit } = await mgr.removeMembers([leafIndex]);
@@ -2497,7 +2593,7 @@ var MlsGroupKeyProvider = class {
2497
2593
  });
2498
2594
  await this.o.store.save(channelId, mgr.exportState());
2499
2595
  this.managers.set(channelId, mgr);
2500
- await this.o.store.deletePendingBundle(this.o.deviceId);
2596
+ return { staleMemberDeviceIds: reinit.stale_member_device_ids ?? [] };
2501
2597
  });
2502
2598
  }
2503
2599
  /** Returns true when an in-memory MLS manager is live for the channel.
@@ -2533,13 +2629,27 @@ var MlsGroupKeyProvider = class {
2533
2629
  }
2534
2630
  }
2535
2631
  /**
2536
- * Return the number of leaves the given identity holds in the live tree for
2537
- * `channelId`. Loads the manager from the store if not already cached.
2632
+ * Return the number of leaves the given identity holds in THIS DEVICE'S view
2633
+ * of the ratchet tree for `channelId`. Reads the cached manager, else loads
2634
+ * one from the store; it makes no backend call, so the answer is only as
2635
+ * current as the commits this device has already processed. A device that is
2636
+ * behind, forked, or holding a superseded group answers from that state, not
2637
+ * from the server's.
2638
+ *
2639
+ * ⚠️ THROWS (`no MLS state for channel …`) when there is no local state at
2640
+ * all — it does not report 0 for "we have not joined yet". Callers that may
2641
+ * run before {@link ensureSession} must handle the throw; see
2642
+ * {@link hasLeafForIdentity} for the non-throwing, cache-only variant.
2538
2643
  *
2539
2644
  * Primarily used by the gate (S20) to assert that a peer-committer 409-retry
2540
- * does NOT create a duplicate leaf for an already-added device. A return value
2541
- * of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
2542
- * duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
2645
+ * does NOT create a duplicate leaf for an already-added device. In THAT
2646
+ * scenario each identity owns exactly one device, so 1 is healthy and > 1
2647
+ * means the S3 guard failed to suppress the re-Add. That reading does NOT
2648
+ * generalise: leaf credentials encode the IDENTITY, not the device, so a
2649
+ * multi-device identity legitimately holds ONE LEAF PER DEVICE — the same
2650
+ * premise {@link removeDeviceFromLiveGroupUnlocked}'s
2651
+ * `leaves.length !== orderedDeviceIds.length` guard is built on. Read the
2652
+ * count against the identity's expected device count, not against 1.
2543
2653
  */
2544
2654
  async leafCountForIdentity(channelId, identityId) {
2545
2655
  const mgr = await this.manager(channelId);
@@ -2573,7 +2683,60 @@ var MlsGroupKeyProvider = class {
2573
2683
  * ⇒ refuse, never call verify), then structural signer check, then attestation
2574
2684
  * verify against the NON-EMPTY pinned key, then post-join group-id assertion.
2575
2685
  */
2686
+ /** C4 (split-brain Task 4): back-off for REFUSED supersedes, keyed
2687
+ * `${channelId}:${groupId}`. A refused supersede leaves its row un-acked by
2688
+ * design (it is not a stray), but pre-back-off that meant a full re-verify —
2689
+ * including a fetchResetAttestation HTTP round-trip — on EVERY receive poll,
2690
+ * forever, for a request that can never succeed (the nine production split
2691
+ * channels all carry NULL-attestation legacy groups).
2692
+ *
2693
+ * ⚠️ ONLY POST-FETCH refusals enter the back-off. The step-0 pin guard and
2694
+ * the retired-group check run BEFORE fetchResetAttestation, so they
2695
+ * contribute zero HTTP to the storm — and the pin guard's precondition is
2696
+ * TRANSIENT (cold owner cache / momentarily anomalous roster; the bridge
2697
+ * warms the cache before receiveOnce for exactly this reason). Backing a
2698
+ * pre-fetch refusal off buys nothing and converts a one-cycle stall into a
2699
+ * ten-minute outage on a valid recovery.
2700
+ *
2701
+ * Of the backed-off refusals: no-attestation, unparseable content,
2702
+ * channel/group mismatch and post-join mismatch are DETERMINISTIC per
2703
+ * (channel, group) — the attestation columns are written at group creation
2704
+ * and never change. Signer-mismatch, signature-verify and no-bundle are
2705
+ * ASSUMED stable for the window; they are pin- or bundle-dependent, but by
2706
+ * the time they run the fetch has already been paid, so suppressing the
2707
+ * window bounds the storm at the cost of a ≤10-minute delay if the pin or
2708
+ * bundle changes. Note the suppression means the reason is not RE-OBSERVED
2709
+ * during the window — the underlying precondition can change freely; the
2710
+ * window is a cost bound, not a claim of immutability.
2711
+ *
2712
+ * The reason lives in the VALUE for observability; the clearing rule lives
2713
+ * in the KEY: the owner minting a FIXED replacement group arrives with a
2714
+ * NEW group id — a different key, tried immediately. The un-openable path
2715
+ * (verified Welcome, KP mismatch) is EXEMPT: onSupersedeUnopenable's own
2716
+ * cooldown governs it. In-memory only: a reload/restart costs one extra
2717
+ * fetch per stranded row — reload-bounded, not poll-bounded. */
2718
+ supersedeRefusals = /* @__PURE__ */ new Map();
2719
+ static SUPERSEDE_REFUSAL_BACKOFF_MS = 10 * 6e4;
2720
+ /** True while (channelId, groupId) is inside its refusal back-off window.
2721
+ * @internal exposed for diagnostics only — not part of the stable surface. */
2722
+ supersedeBackoffActive(channelId, groupId) {
2723
+ const rec = this.supersedeRefusals.get(`${channelId}:${groupId}`);
2724
+ return rec !== void 0 && Date.now() < rec.until;
2725
+ }
2576
2726
  async trySupersede(channelId, row, heldGroupId) {
2727
+ const refuse = (msg) => {
2728
+ this.recordDecryptError(row, new Error(msg));
2729
+ const now = Date.now();
2730
+ for (const [k, v] of this.supersedeRefusals) {
2731
+ if (now >= v.until)
2732
+ this.supersedeRefusals.delete(k);
2733
+ }
2734
+ this.supersedeRefusals.set(`${channelId}:${row.group_id}`, {
2735
+ reason: msg,
2736
+ until: now + _MlsGroupKeyProvider.SUPERSEDE_REFUSAL_BACKOFF_MS
2737
+ });
2738
+ return false;
2739
+ };
2577
2740
  const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
2578
2741
  if (!owner || !owner.publicKeyHex) {
2579
2742
  this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
@@ -2586,33 +2749,27 @@ var MlsGroupKeyProvider = class {
2586
2749
  }
2587
2750
  const att = await this.o.backend.fetchResetAttestation(row.group_id);
2588
2751
  if (!att) {
2589
- this.recordDecryptError(row, new Error(`supersede refused: no attestation for group ${row.group_id}`));
2590
- return false;
2752
+ return refuse(`supersede refused: no attestation for group ${row.group_id}`);
2591
2753
  }
2592
2754
  if (att.signerIdentityId !== owner.identityId) {
2593
- this.recordDecryptError(row, new Error("supersede refused: signer is not the pinned owner"));
2594
- return false;
2755
+ return refuse("supersede refused: signer is not the pinned owner");
2595
2756
  }
2596
2757
  let content;
2597
2758
  try {
2598
2759
  content = JSON.parse(att.signedContent);
2599
2760
  } catch {
2600
- this.recordDecryptError(row, new Error("supersede refused: unparseable signed_content"));
2601
- return false;
2761
+ return refuse("supersede refused: unparseable signed_content");
2602
2762
  }
2603
2763
  if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
2604
- this.recordDecryptError(row, new Error("supersede refused: attestation channel/group mismatch"));
2605
- return false;
2764
+ return refuse("supersede refused: attestation channel/group mismatch");
2606
2765
  }
2607
2766
  const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
2608
2767
  if (!ok) {
2609
- this.recordDecryptError(row, new Error("supersede refused: signature does not verify against pinned owner key"));
2610
- return false;
2768
+ return refuse("supersede refused: signature does not verify against pinned owner key");
2611
2769
  }
2612
2770
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2613
2771
  if (!bundle) {
2614
- this.recordDecryptError(row, new Error("supersede refused: no pending KeyPackage bundle to open the new Welcome"));
2615
- return false;
2772
+ return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
2616
2773
  }
2617
2774
  return await this.locks.run(channelId, async () => {
2618
2775
  const mgr = new MLSGroupManager();
@@ -2637,14 +2794,14 @@ var MlsGroupKeyProvider = class {
2637
2794
  return false;
2638
2795
  }
2639
2796
  if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
2640
- this.recordDecryptError(row, new Error("supersede refused: post-join group id != attested new_group_id"));
2641
- return false;
2797
+ return refuse("supersede refused: post-join group id != attested new_group_id");
2642
2798
  }
2643
2799
  this.evictChannelState(channelId);
2644
2800
  await this.o.store.delete(channelId);
2645
2801
  await this.o.store.discardStaging(channelId);
2646
2802
  await this.o.store.addRetiredGroupId(channelId, heldGroupId);
2647
2803
  this.errors.delete(channelId);
2804
+ this.supersedeRefusals.delete(`${channelId}:${row.group_id}`);
2648
2805
  this.managers.set(channelId, mgr);
2649
2806
  await this.o.store.save(channelId, mgr.exportState());
2650
2807
  await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
@@ -2653,16 +2810,45 @@ var MlsGroupKeyProvider = class {
2653
2810
  }
2654
2811
  async receiveOnce(deviceId) {
2655
2812
  const rows = await this.o.backend.pullDelivery(deviceId);
2656
- if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
2657
- console.warn(`[trace] receiveOnce pulled ${rows.length}: ` + rows.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
2658
- }
2659
2813
  const appEntries = [];
2660
2814
  const handshakeAcked = [];
2661
2815
  const supersededChannelIds = [];
2662
- const order = { welcome: 0, commit: 1, application: 2 };
2663
- rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
2664
- for (const row of rows) {
2816
+ const welcomes = rows.filter((r) => r.message_type === "welcome");
2817
+ const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
2818
+ const ordered = [...welcomes, ...nonWelcomes];
2819
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
2820
+ console.warn(`[trace] receiveOnce pulled ${ordered.length}: ` + ordered.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
2821
+ }
2822
+ const classCtx = /* @__PURE__ */ new Map();
2823
+ const classCtxFor = async (cid) => {
2824
+ let ctx = classCtx.get(cid);
2825
+ if (!ctx) {
2826
+ let heldGroupId = null;
2827
+ let snapshotEpoch;
2828
+ let retired = null;
2829
+ try {
2830
+ const mgr = await this.manager(cid);
2831
+ heldGroupId = new TextDecoder().decode(mgr.groupId);
2832
+ snapshotEpoch = Number(mgr.epoch);
2833
+ } catch {
2834
+ heldGroupId = null;
2835
+ snapshotEpoch = void 0;
2836
+ }
2837
+ try {
2838
+ retired = await this.o.store.loadRetiredGroupIds(cid);
2839
+ } catch {
2840
+ retired = null;
2841
+ }
2842
+ ctx = { heldGroupId, retired, snapshotEpoch, liveCommitEpoch: snapshotEpoch };
2843
+ classCtx.set(cid, ctx);
2844
+ }
2845
+ return ctx;
2846
+ };
2847
+ for (const row of ordered) {
2665
2848
  try {
2849
+ if (row.message_type !== "welcome" && row.connect_channel_id) {
2850
+ await classCtxFor(row.connect_channel_id);
2851
+ }
2666
2852
  if (row.message_type === "welcome") {
2667
2853
  const wcid = row.connect_channel_id;
2668
2854
  if (wcid) {
@@ -2681,9 +2867,15 @@ var MlsGroupKeyProvider = class {
2681
2867
  if (heldGroupId !== null && heldGroupId === row.group_id) {
2682
2868
  handshakeAcked.push(row.queue_id);
2683
2869
  } else if (heldGroupId !== null && heldGroupId !== row.group_id) {
2684
- const ok = await this.trySupersede(wcid, row, heldGroupId);
2685
- if (ok)
2686
- supersededChannelIds.push(wcid);
2870
+ if (this.supersedeBackoffActive(wcid, row.group_id)) {
2871
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2872
+ console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} g=${row.group_id.slice(0, 8)} -> SKIP(refusal back-off)`);
2873
+ }
2874
+ } else {
2875
+ const ok = await this.trySupersede(wcid, row, heldGroupId);
2876
+ if (ok)
2877
+ supersededChannelIds.push(wcid);
2878
+ }
2687
2879
  }
2688
2880
  } else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2689
2881
  console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} -> NO-LOCAL-STATE (leave un-acked for join path)`);
@@ -2693,15 +2885,25 @@ var MlsGroupKeyProvider = class {
2693
2885
  const cid = row.connect_channel_id;
2694
2886
  if (!cid)
2695
2887
  continue;
2888
+ let advancedEpoch;
2696
2889
  await this.locks.run(cid, async () => {
2697
2890
  const mgr = await this.manager(cid);
2698
2891
  await mgr.decrypt(hexToBytes(row.payload));
2699
2892
  await this.o.store.save(cid, mgr.exportState());
2893
+ advancedEpoch = Number(mgr.epoch);
2700
2894
  });
2701
2895
  this.recordDecryptSuccess(cid);
2896
+ if (advancedEpoch !== void 0) {
2897
+ const ctx = classCtx.get(cid);
2898
+ if (ctx)
2899
+ ctx.liveCommitEpoch = advancedEpoch;
2900
+ }
2702
2901
  handshakeAcked.push(row.queue_id);
2703
2902
  } else {
2704
2903
  const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
2904
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
2905
+ throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
2906
+ }
2705
2907
  const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
2706
2908
  const aad = await buildAssociatedData({
2707
2909
  message_id: envelope.message_id,
@@ -2729,18 +2931,25 @@ var MlsGroupKeyProvider = class {
2729
2931
  this.recordDecryptError(row, err);
2730
2932
  const cid = row.connect_channel_id;
2731
2933
  let drained = false;
2732
- if (cid && typeof row.epoch === "number") {
2733
- let liveEpoch;
2734
- try {
2735
- liveEpoch = await this.currentEpoch(cid);
2736
- } catch {
2737
- liveEpoch = void 0;
2738
- }
2739
- const permanentlyDead = liveEpoch !== void 0 && (row.message_type === "commit" ? row.epoch <= liveEpoch : row.message_type === "application" ? row.epoch < liveEpoch : false);
2740
- if (permanentlyDead) {
2934
+ if (cid && row.message_type !== "welcome") {
2935
+ const ctx = classCtx.get(cid);
2936
+ const verdict = classifyFailedRow({
2937
+ messageType: row.message_type,
2938
+ rowGroupId: row.group_id,
2939
+ rowEpoch: typeof row.epoch === "number" ? row.epoch : void 0,
2940
+ heldGroupId: ctx?.heldGroupId ?? null,
2941
+ retiredGroupIds: ctx?.retired ?? null,
2942
+ snapshotEpoch: ctx?.snapshotEpoch,
2943
+ liveCommitEpoch: ctx?.liveCommitEpoch
2944
+ });
2945
+ if (verdict === "permanentlySkipped") {
2741
2946
  handshakeAcked.push(row.queue_id);
2742
2947
  this.permanentlySkipped++;
2743
2948
  drained = true;
2949
+ } else if (verdict === "retiredDrained") {
2950
+ handshakeAcked.push(row.queue_id);
2951
+ this.retiredDrained++;
2952
+ drained = true;
2744
2953
  }
2745
2954
  }
2746
2955
  if (!drained && cid) {
@@ -2748,8 +2957,10 @@ var MlsGroupKeyProvider = class {
2748
2957
  if (e) {
2749
2958
  e.wedgedRun += 1;
2750
2959
  if (e.wedgedRun >= this.quarantineThreshold) {
2751
- handshakeAcked.push(row.queue_id);
2752
- this.quarantineDrained++;
2960
+ if (row.message_type !== "welcome") {
2961
+ handshakeAcked.push(row.queue_id);
2962
+ this.quarantineDrained++;
2963
+ }
2753
2964
  this.quarantineChannel(cid);
2754
2965
  }
2755
2966
  }
@@ -3386,19 +3597,8 @@ var RestMlsBackend = class {
3386
3597
  created: j.created === void 0 ? true : Boolean(j.created)
3387
3598
  };
3388
3599
  }
3389
- async reinitConnectChannelGroup(channelId, memberDeviceIds) {
3390
- const committer = memberDeviceIds[0];
3391
- if (!committer)
3392
- throw new Error("reinitConnectChannelGroup: empty member set");
3393
- const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
3394
- member_device_ids: memberDeviceIds,
3395
- committer_device_id: committer
3396
- });
3397
- return {
3398
- group_id: String(j.group_id),
3399
- current_epoch: Number(j.current_epoch),
3400
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
3401
- };
3600
+ async reinitConnectChannelGroup() {
3601
+ throw new Error("reinitConnectChannelGroup was removed (it minted unattested MLS groups that orphaned channel members permanently). Use resetReinitConnectChannelGroup with an owner attestation.");
3402
3602
  }
3403
3603
  async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
3404
3604
  const committer = memberDeviceIds[0];
@@ -3416,7 +3616,11 @@ var RestMlsBackend = class {
3416
3616
  return {
3417
3617
  group_id: String(j.group_id),
3418
3618
  current_epoch: Number(j.current_epoch),
3419
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
3619
+ member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3620
+ // Absent on a backend that predates the dropped-member report; an empty
3621
+ // list is the correct read of "the server told us nothing", since the old
3622
+ // server also never dropped anyone for an expired KeyPackage.
3623
+ stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
3420
3624
  };
3421
3625
  }
3422
3626
  async fetchKeyPackages(deviceIds) {
@@ -5355,11 +5559,33 @@ var BridgeAgent = class _BridgeAgent {
5355
5559
  return;
5356
5560
  }
5357
5561
  this.providerCapabilityMissing = false;
5358
- let chans;
5359
- try {
5360
- chans = await this.listChannels();
5361
- } catch (e) {
5362
- console.warn("[bridge] selfHealSweep aborted: listChannels failed:", e);
5562
+ const enclaves = await this.enclavesToCover();
5563
+ this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
5564
+ const seen = /* @__PURE__ */ new Set();
5565
+ const chans = [];
5566
+ const failed = [];
5567
+ let listedAny = false;
5568
+ for (const encId of enclaves) {
5569
+ try {
5570
+ for (const c of await this.listChannels(encId)) {
5571
+ if (seen.has(c.channel_id)) continue;
5572
+ seen.add(c.channel_id);
5573
+ chans.push(c);
5574
+ }
5575
+ listedAny = true;
5576
+ this.enclaveListEverSucceeded.add(encId);
5577
+ this.enclaveListWarned.delete(encId);
5578
+ } catch (e) {
5579
+ failed.push(encId);
5580
+ if (!this.enclaveListWarned.has(encId)) {
5581
+ this.enclaveListWarned.add(encId);
5582
+ console.warn(`[bridge] selfHealSweep: listChannels failed for enclave ${encId}; skipping (warn-once per streak):`, e);
5583
+ }
5584
+ }
5585
+ }
5586
+ this.sweepEnclavesFailed = failed;
5587
+ if (!listedAny) {
5588
+ console.warn("[bridge] selfHealSweep aborted: every enclave listing failed");
5363
5589
  return;
5364
5590
  }
5365
5591
  for (const c of chans) {
@@ -5705,7 +5931,7 @@ var BridgeAgent = class _BridgeAgent {
5705
5931
  * the other enclaves in the set.
5706
5932
  */
5707
5933
  async refreshChannelMeta() {
5708
- const enclaves = /* @__PURE__ */ new Set([this.cfg.enclaveId, ...this.store.distinctEnclaveIds()]);
5934
+ const enclaves = await this.enclavesToCover();
5709
5935
  const now = Date.now();
5710
5936
  for (const encId of enclaves) {
5711
5937
  try {
@@ -5718,6 +5944,89 @@ var BridgeAgent = class _BridgeAgent {
5718
5944
  }
5719
5945
  }
5720
5946
  }
5947
+ /** Cache for the server-side enclave enumeration (see enclavesToCover). */
5948
+ enclaveEnumCache = null;
5949
+ /** Enclaves whose channel listing failed on the NEWEST sweep (audit HIGH:
5950
+ * skip-one deleted the sweep's partial-coverage signal — observations_stale
5951
+ * is a global max, so one healthy enclave masked a failed one). Surfaced on
5952
+ * health() so partial coverage is never silent. */
5953
+ sweepEnclavesFailed = [];
5954
+ /** Enclave ids whose channel listing has EVER succeeded this process, seeded
5955
+ * with HOME (home is always expected to list; without the seed, a home
5956
+ * failure right after a restart would read as "never succeeded" and slip
5957
+ * through the expected-gap branch — weaker than the old abort-on-failure).
5958
+ * A failure of a member of this set is a REGRESSION and degrades health; a
5959
+ * failure of a never-succeeded enclave (partner: tenant-scoped 404, permanent
5960
+ * pending backend authz) is an EXPECTED gap — reported, not degrading, or
5961
+ * every federated agent would peg degraded forever. */
5962
+ enclaveListEverSucceeded = /* @__PURE__ */ new Set();
5963
+ /** Warn-once-per-failure-streak, keyed by enclave id (cleared on success):
5964
+ * a permanently-404ing partner enclave must not log 1440 lines a day. */
5965
+ enclaveListWarned = /* @__PURE__ */ new Set();
5966
+ static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
5967
+ enclaveEnumWarned = false;
5968
+ /**
5969
+ * C1 (split-brain Task 3): every enclave this agent must COVER — for the
5970
+ * self-heal sweep and for channel-meta refresh alike. ONE helper on purpose:
5971
+ * these two call sites had drifted (refreshChannelMeta looped home+learned,
5972
+ * selfHealSweep looped nothing), and the drift is exactly what left both
5973
+ * production agents with zero recovery coverage in their second enclave.
5974
+ *
5975
+ * The set is SERVER enumeration ∪ home ∪ store-learned:
5976
+ *
5977
+ * - SERVER (`GET /api/v1/enclaves`, membership-scoped, federated included)
5978
+ * is the authoritative source. The store CANNOT be: channel_meta's only
5979
+ * writers are listChannels itself (circular — you learn enclave B by
5980
+ * having already listed B) and the receive pump's successfully-decrypted
5981
+ * loop, and a FORKED agent decrypts nothing, so the agent that most needs
5982
+ * sweeping in enclave B is precisely the one whose store never learned B.
5983
+ * - home + learned stay in the union as the degraded-mode floor: if the
5984
+ * enumeration endpoint is down, we sweep what we can rather than nothing,
5985
+ * and we warn (once) so the gap is diagnosable.
5986
+ *
5987
+ * The server result is cached for ENCLAVE_ENUM_TTL_MS so per-ingest-cycle
5988
+ * callers (refreshChannelMeta) do not add an HTTP round-trip per cycle;
5989
+ * membership changes are picked up within one TTL, which matches the sweep
5990
+ * cadence this exists to serve.
5991
+ *
5992
+ * ⚠️ Residual gap, stated: the enumeration RETURNS cross-tenant partner
5993
+ * enclaves, but `list_channels_in_enclave` is tenant-scoped and 404s them
5994
+ * under this agent's bearer — so a partner enclave in this set still yields
5995
+ * no channels. Callers must treat a per-enclave listing failure as skip-one,
5996
+ * never abort-all. Closing that needs backend authz work, not daemon work.
5997
+ */
5998
+ async enclavesToCover() {
5999
+ const now = Date.now();
6000
+ if (!this.enclaveEnumCache || now - this.enclaveEnumCache.at > _BridgeAgent.ENCLAVE_ENUM_TTL_MS) {
6001
+ try {
6002
+ const url = `${this.cfg.apiBase}/api/v1/enclaves?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
6003
+ const res = await this.fetchImpl(url, {
6004
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` }
6005
+ });
6006
+ if (!res.ok) throw new Error(`list_enclaves failed: ${res.status}`);
6007
+ const rows = await res.json();
6008
+ this.enclaveEnumCache = { ids: rows.map((r) => r.enclave_id), at: Date.now() };
6009
+ this.enclaveEnumWarned = false;
6010
+ } catch (e) {
6011
+ if (!this.enclaveEnumWarned) {
6012
+ this.enclaveEnumWarned = true;
6013
+ console.warn(
6014
+ "[bridge] enclave enumeration failed; sweep coverage degraded to home+learned enclaves:",
6015
+ e
6016
+ );
6017
+ }
6018
+ }
6019
+ }
6020
+ const fresh = this.enclaveEnumCache && Date.now() - this.enclaveEnumCache.at <= _BridgeAgent.ENCLAVE_ENUM_TTL_MS;
6021
+ if (fresh) {
6022
+ return /* @__PURE__ */ new Set([this.cfg.enclaveId, ...this.enclaveEnumCache.ids]);
6023
+ }
6024
+ return /* @__PURE__ */ new Set([
6025
+ this.cfg.enclaveId,
6026
+ ...this.store.distinctEnclaveIds(),
6027
+ ...this.enclaveEnumCache?.ids ?? []
6028
+ ]);
6029
+ }
5721
6030
  /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
5722
6031
  freshRoster(channelId) {
5723
6032
  const hit = this.channelRoster.get(channelId);
@@ -6314,6 +6623,9 @@ var BridgeAgent = class _BridgeAgent {
6314
6623
  const stalePeriodMs = _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS;
6315
6624
  const observationsStale = this.providerCapabilityMissing || channels.length === 0 || Date.now() - newest > stalePeriodMs;
6316
6625
  const divergedChannels = channels.filter((o) => o.diverged === true).length;
6626
+ const sweepCoverageLost = this.sweepEnclavesFailed.some(
6627
+ (id) => this.enclaveListEverSucceeded.has(id)
6628
+ );
6317
6629
  const liveChannels = channels.map((o) => ({
6318
6630
  ...o,
6319
6631
  last_decrypt_at: this.provider.lastDecryptAtFor?.(o.channel_id) ?? o.last_decrypt_at
@@ -6328,8 +6640,9 @@ var BridgeAgent = class _BridgeAgent {
6328
6640
  observations_stale: observationsStale,
6329
6641
  diverged_channels: divergedChannels,
6330
6642
  // A2: the truthful roll-up gates read instead of `status` (D-3).
6331
- degraded: divergedChannels > 0 || observationsStale,
6643
+ degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost,
6332
6644
  channels: liveChannels,
6645
+ sweep_enclaves_failed: [...this.sweepEnclavesFailed],
6333
6646
  identity_id: this.cfg.identityId,
6334
6647
  joined_channels: joined,
6335
6648
  buffered: this.buffer.length,
@@ -6337,6 +6650,8 @@ var BridgeAgent = class _BridgeAgent {
6337
6650
  filtered_room: this.filteredRoomMessages,
6338
6651
  rate_limited: this.rateLimitedSends,
6339
6652
  permanently_skipped: this.provider.permanentlySkippedCount(),
6653
+ quarantine_drained: this.provider.quarantineDrainedCount(),
6654
+ retired_drained: this.provider.retiredDrainedCount(),
6340
6655
  failed_attachments: this.failedAttachments,
6341
6656
  delivery_pings: this.pingsReceived,
6342
6657
  agent_sessions: this.authSessions,
@@ -6382,8 +6697,11 @@ var BridgeAgent = class _BridgeAgent {
6382
6697
  filteredRoomMessages: this.filteredRoomMessages,
6383
6698
  rateLimitedSends: this.rateLimitedSends,
6384
6699
  permanentlySkipped: this.provider.permanentlySkippedCount(),
6700
+ quarantineDrained: this.provider.quarantineDrainedCount(),
6701
+ retiredDrained: this.provider.retiredDrainedCount(),
6385
6702
  welcomeRequestsSent: this.welcomeRequestsSent,
6386
- failedAttachments: this.failedAttachments
6703
+ failedAttachments: this.failedAttachments,
6704
+ build: BRIDGE_VERSION
6387
6705
  };
6388
6706
  }
6389
6707
  async close() {