@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/index.js CHANGED
@@ -1268,6 +1268,24 @@ var MlsOperationQueue = class {
1268
1268
  }
1269
1269
  };
1270
1270
 
1271
+ // ../connect-sdk-ts/dist/groupkey/receive-classifier.js
1272
+ function classifyFailedRow(i) {
1273
+ if (i.messageType === "welcome")
1274
+ return "defer";
1275
+ if (i.messageType !== "commit" && i.messageType !== "application")
1276
+ return "defer";
1277
+ if (i.heldGroupId !== null && i.rowGroupId === i.heldGroupId) {
1278
+ const epochForVerdict = i.messageType === "commit" ? i.liveCommitEpoch : i.snapshotEpoch;
1279
+ if (epochForVerdict === void 0 || typeof i.rowEpoch !== "number")
1280
+ return "defer";
1281
+ const dead = i.messageType === "commit" ? i.rowEpoch <= epochForVerdict : i.rowEpoch < epochForVerdict;
1282
+ return dead ? "permanentlySkipped" : "defer";
1283
+ }
1284
+ if (i.heldGroupId !== null && i.retiredGroupIds !== null && i.retiredGroupIds.includes(i.rowGroupId))
1285
+ return "retiredDrained";
1286
+ return "defer";
1287
+ }
1288
+
1271
1289
  // ../connect-sdk-ts/dist/groupkey/mls.js
1272
1290
  var LABEL = "agentvault-connect-v1";
1273
1291
  var DEFAULT_STUCK_THRESHOLD = 2;
@@ -1284,6 +1302,18 @@ var LeafRosterMismatchError = class extends Error {
1284
1302
  this.name = "LeafRosterMismatchError";
1285
1303
  }
1286
1304
  };
1305
+ var AmbiguousOwnLeafError = class extends Error {
1306
+ deviceId;
1307
+ totalLeaves;
1308
+ candidateLeaves;
1309
+ constructor(deviceId, totalLeaves, candidateLeaves) {
1310
+ 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.`);
1311
+ this.deviceId = deviceId;
1312
+ this.totalLeaves = totalLeaves;
1313
+ this.candidateLeaves = candidateLeaves;
1314
+ this.name = "AmbiguousOwnLeafError";
1315
+ }
1316
+ };
1287
1317
  var HealAddAfterRemoveError = class extends Error {
1288
1318
  constructor(cause) {
1289
1319
  super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
@@ -1303,7 +1333,7 @@ var ForgedCredentialError = class extends Error {
1303
1333
  this.name = "ForgedCredentialError";
1304
1334
  }
1305
1335
  };
1306
- var MlsGroupKeyProvider = class {
1336
+ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1307
1337
  o;
1308
1338
  suite;
1309
1339
  managers = /* @__PURE__ */ new Map();
@@ -1372,6 +1402,11 @@ var MlsGroupKeyProvider = class {
1372
1402
  * the past-epoch {@link permanentlySkipped} drain. Read via
1373
1403
  * {@link quarantineDrainedCount}. */
1374
1404
  quarantineDrained = 0;
1405
+ /** Cumulative count of rows ack-drained because their group_id is RETIRED for
1406
+ * the channel (superseded fork-loser traffic). Distinct from the same-group
1407
+ * past-epoch {@link permanentlySkipped} drain. Read via
1408
+ * {@link retiredDrainedCount}. */
1409
+ retiredDrained = 0;
1375
1410
  constructor(o) {
1376
1411
  this.o = o;
1377
1412
  this.suite = o.suite ?? "xchacha20poly1305";
@@ -1453,9 +1488,25 @@ var MlsGroupKeyProvider = class {
1453
1488
  * Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
1454
1489
  * Surfaces a wedged channel that would otherwise "look idle": the daemon serves
1455
1490
  * it on GET /v1/metrics and the web shell reads it via a selector. A channel is
1456
- * `stuck` once it has had `stuckThreshold` consecutive decrypt failures with no
1457
- * intervening successful decrypt. Returns a plain object keyed by channelId;
1458
- * absence of a key means zero errors observed for that channel.
1491
+ * `stuck` once it has had `stuckThreshold` GENUINELY-WEDGED decrypt failures
1492
+ * with no intervening successful decrypt. Returns a plain object keyed by
1493
+ * channelId; absence of a key means zero errors observed for that channel.
1494
+ *
1495
+ * `stuck` gates on `wedgedRun`, NOT `consecutiveErrors` — the same correction
1496
+ * the quarantine gate already got (P0a). `consecutiveErrors` is bumped at the
1497
+ * top of the catch for EVERY failure, INCLUDING the benign past-epoch
1498
+ * duplicate-commit churn that the `permanentlyDead` path immediately and
1499
+ * safely auto-drains. Deriving the user-visible health signal from it meant
1500
+ * ordinary at-least-once / committer-race traffic painted a perfectly healthy
1501
+ * channel red.
1502
+ *
1503
+ * That is the prod symptom of 2026-07-31: a freshly-welcomed federated partner
1504
+ * was queued the very Add commit its Welcome already contained. It joined the
1505
+ * correct group at the correct epoch, correctly identified the replayed commit
1506
+ * as dead and drained it — and still showed "This channel is stuck", because
1507
+ * the drain happened AFTER the error had been counted. `wedgedRun` counts only
1508
+ * rows left UN-acked (actually stuck), and a clean decrypt clears it, so a
1509
+ * genuine wedge still reports exactly as before.
1459
1510
  */
1460
1511
  errorStats() {
1461
1512
  const out = {};
@@ -1464,7 +1515,7 @@ var MlsGroupKeyProvider = class {
1464
1515
  decryptErrors: e.decryptErrors,
1465
1516
  consecutiveErrors: e.consecutiveErrors,
1466
1517
  lastErrorAt: e.lastErrorAt,
1467
- stuck: e.consecutiveErrors >= this.stuckThreshold,
1518
+ stuck: e.wedgedRun >= this.stuckThreshold,
1468
1519
  quarantined: e.quarantined
1469
1520
  };
1470
1521
  }
@@ -1513,6 +1564,10 @@ var MlsGroupKeyProvider = class {
1513
1564
  quarantineDrainedCount() {
1514
1565
  return this.quarantineDrained;
1515
1566
  }
1567
+ /** See {@link retiredDrained}. */
1568
+ retiredDrainedCount() {
1569
+ return this.retiredDrained;
1570
+ }
1516
1571
  /** Evict the live in-memory MLS group manager for a channel. Used when the
1517
1572
  * device leaves or deletes a channel: dropping the persisted store record is
1518
1573
  * not enough on its own, because a warm manager keeps decrypting until the
@@ -1581,6 +1636,9 @@ var MlsGroupKeyProvider = class {
1581
1636
  if (cached)
1582
1637
  return cached;
1583
1638
  const saved = await this.o.store.load(channelId);
1639
+ const again = this.managers.get(channelId);
1640
+ if (again)
1641
+ return again;
1584
1642
  if (!saved)
1585
1643
  throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
1586
1644
  const mgr = new MLSGroupManager(saved.ciphersuite);
@@ -2129,6 +2187,34 @@ var MlsGroupKeyProvider = class {
2129
2187
  * `removed_device_id` (instead of removed_identity_id); the server resolves the
2130
2188
  * device's identity, asserts it matches, and purges that ONE device.
2131
2189
  */
2190
+ /**
2191
+ * This committer's OWN leaf index in the given group's ratchet tree, or null
2192
+ * when it cannot be read.
2193
+ *
2194
+ * `MLSGroupManager.state` is marked `@internal` but is a declared (non-private)
2195
+ * field, and `state.privatePath.leafIndex` is the same value ts-mls itself uses
2196
+ * as the sender leaf when protecting application data. Reading it here avoids a
2197
+ * change to `@agentvault/crypto`; if a public accessor is added later this
2198
+ * should delegate to it.
2199
+ *
2200
+ * Returns null rather than throwing so every caller must decide explicitly what
2201
+ * an unknown own-leaf means.
2202
+ *
2203
+ * ⚠️ The one caller today does NOT fail closed on null. In
2204
+ * {@link removeDeviceFromLiveGroupUnlocked} the branch is
2205
+ * `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
2206
+ * identities at all, so a null return sends an OWN-identity removal down the
2207
+ * positional `leaves[pos]` path, which is exactly the add-order guess the
2208
+ * self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
2209
+ * permutes that mapping after any Remove). Null is therefore currently
2210
+ * indistinguishable from "foreign identity", and only the cardinality guard
2211
+ * stands between it and evicting the wrong device of the same human.
2212
+ * Correcting that is Phase 1b, not this comment.
2213
+ */
2214
+ ownLeafIndex(mgr) {
2215
+ const idx = mgr.state?.privatePath?.leafIndex;
2216
+ return typeof idx === "number" ? idx : null;
2217
+ }
2132
2218
  async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
2133
2219
  return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
2134
2220
  }
@@ -2141,7 +2227,17 @@ var MlsGroupKeyProvider = class {
2141
2227
  if (leaves.length !== opts.orderedDeviceIds.length) {
2142
2228
  throw new LeafRosterMismatchError();
2143
2229
  }
2144
- const leafIndex = leaves[pos];
2230
+ let leafIndex;
2231
+ const ownLeaf = this.ownLeafIndex(mgr);
2232
+ if (ownLeaf !== null && leaves.includes(ownLeaf)) {
2233
+ const others = leaves.filter((i) => i !== ownLeaf);
2234
+ if (others.length !== 1) {
2235
+ throw new AmbiguousOwnLeafError(deviceId, leaves.length, others.length);
2236
+ }
2237
+ leafIndex = others[0];
2238
+ } else {
2239
+ leafIndex = leaves[pos];
2240
+ }
2145
2241
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
2146
2242
  const priorEpoch = Number(mgr.epoch);
2147
2243
  const { commit } = await mgr.removeMembers([leafIndex]);
@@ -2394,7 +2490,7 @@ var MlsGroupKeyProvider = class {
2394
2490
  });
2395
2491
  await this.o.store.save(channelId, mgr.exportState());
2396
2492
  this.managers.set(channelId, mgr);
2397
- await this.o.store.deletePendingBundle(this.o.deviceId);
2493
+ return { staleMemberDeviceIds: reinit.stale_member_device_ids ?? [] };
2398
2494
  });
2399
2495
  }
2400
2496
  /** Returns true when an in-memory MLS manager is live for the channel.
@@ -2430,13 +2526,27 @@ var MlsGroupKeyProvider = class {
2430
2526
  }
2431
2527
  }
2432
2528
  /**
2433
- * Return the number of leaves the given identity holds in the live tree for
2434
- * `channelId`. Loads the manager from the store if not already cached.
2529
+ * Return the number of leaves the given identity holds in THIS DEVICE'S view
2530
+ * of the ratchet tree for `channelId`. Reads the cached manager, else loads
2531
+ * one from the store; it makes no backend call, so the answer is only as
2532
+ * current as the commits this device has already processed. A device that is
2533
+ * behind, forked, or holding a superseded group answers from that state, not
2534
+ * from the server's.
2535
+ *
2536
+ * ⚠️ THROWS (`no MLS state for channel …`) when there is no local state at
2537
+ * all — it does not report 0 for "we have not joined yet". Callers that may
2538
+ * run before {@link ensureSession} must handle the throw; see
2539
+ * {@link hasLeafForIdentity} for the non-throwing, cache-only variant.
2435
2540
  *
2436
2541
  * Primarily used by the gate (S20) to assert that a peer-committer 409-retry
2437
- * does NOT create a duplicate leaf for an already-added device. A return value
2438
- * of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
2439
- * duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
2542
+ * does NOT create a duplicate leaf for an already-added device. In THAT
2543
+ * scenario each identity owns exactly one device, so 1 is healthy and > 1
2544
+ * means the S3 guard failed to suppress the re-Add. That reading does NOT
2545
+ * generalise: leaf credentials encode the IDENTITY, not the device, so a
2546
+ * multi-device identity legitimately holds ONE LEAF PER DEVICE — the same
2547
+ * premise {@link removeDeviceFromLiveGroupUnlocked}'s
2548
+ * `leaves.length !== orderedDeviceIds.length` guard is built on. Read the
2549
+ * count against the identity's expected device count, not against 1.
2440
2550
  */
2441
2551
  async leafCountForIdentity(channelId, identityId) {
2442
2552
  const mgr = await this.manager(channelId);
@@ -2470,7 +2580,60 @@ var MlsGroupKeyProvider = class {
2470
2580
  * ⇒ refuse, never call verify), then structural signer check, then attestation
2471
2581
  * verify against the NON-EMPTY pinned key, then post-join group-id assertion.
2472
2582
  */
2583
+ /** C4 (split-brain Task 4): back-off for REFUSED supersedes, keyed
2584
+ * `${channelId}:${groupId}`. A refused supersede leaves its row un-acked by
2585
+ * design (it is not a stray), but pre-back-off that meant a full re-verify —
2586
+ * including a fetchResetAttestation HTTP round-trip — on EVERY receive poll,
2587
+ * forever, for a request that can never succeed (the nine production split
2588
+ * channels all carry NULL-attestation legacy groups).
2589
+ *
2590
+ * ⚠️ ONLY POST-FETCH refusals enter the back-off. The step-0 pin guard and
2591
+ * the retired-group check run BEFORE fetchResetAttestation, so they
2592
+ * contribute zero HTTP to the storm — and the pin guard's precondition is
2593
+ * TRANSIENT (cold owner cache / momentarily anomalous roster; the bridge
2594
+ * warms the cache before receiveOnce for exactly this reason). Backing a
2595
+ * pre-fetch refusal off buys nothing and converts a one-cycle stall into a
2596
+ * ten-minute outage on a valid recovery.
2597
+ *
2598
+ * Of the backed-off refusals: no-attestation, unparseable content,
2599
+ * channel/group mismatch and post-join mismatch are DETERMINISTIC per
2600
+ * (channel, group) — the attestation columns are written at group creation
2601
+ * and never change. Signer-mismatch, signature-verify and no-bundle are
2602
+ * ASSUMED stable for the window; they are pin- or bundle-dependent, but by
2603
+ * the time they run the fetch has already been paid, so suppressing the
2604
+ * window bounds the storm at the cost of a ≤10-minute delay if the pin or
2605
+ * bundle changes. Note the suppression means the reason is not RE-OBSERVED
2606
+ * during the window — the underlying precondition can change freely; the
2607
+ * window is a cost bound, not a claim of immutability.
2608
+ *
2609
+ * The reason lives in the VALUE for observability; the clearing rule lives
2610
+ * in the KEY: the owner minting a FIXED replacement group arrives with a
2611
+ * NEW group id — a different key, tried immediately. The un-openable path
2612
+ * (verified Welcome, KP mismatch) is EXEMPT: onSupersedeUnopenable's own
2613
+ * cooldown governs it. In-memory only: a reload/restart costs one extra
2614
+ * fetch per stranded row — reload-bounded, not poll-bounded. */
2615
+ supersedeRefusals = /* @__PURE__ */ new Map();
2616
+ static SUPERSEDE_REFUSAL_BACKOFF_MS = 10 * 6e4;
2617
+ /** True while (channelId, groupId) is inside its refusal back-off window.
2618
+ * @internal exposed for diagnostics only — not part of the stable surface. */
2619
+ supersedeBackoffActive(channelId, groupId) {
2620
+ const rec = this.supersedeRefusals.get(`${channelId}:${groupId}`);
2621
+ return rec !== void 0 && Date.now() < rec.until;
2622
+ }
2473
2623
  async trySupersede(channelId, row, heldGroupId) {
2624
+ const refuse = (msg) => {
2625
+ this.recordDecryptError(row, new Error(msg));
2626
+ const now = Date.now();
2627
+ for (const [k, v] of this.supersedeRefusals) {
2628
+ if (now >= v.until)
2629
+ this.supersedeRefusals.delete(k);
2630
+ }
2631
+ this.supersedeRefusals.set(`${channelId}:${row.group_id}`, {
2632
+ reason: msg,
2633
+ until: now + _MlsGroupKeyProvider.SUPERSEDE_REFUSAL_BACKOFF_MS
2634
+ });
2635
+ return false;
2636
+ };
2474
2637
  const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
2475
2638
  if (!owner || !owner.publicKeyHex) {
2476
2639
  this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
@@ -2483,33 +2646,27 @@ var MlsGroupKeyProvider = class {
2483
2646
  }
2484
2647
  const att = await this.o.backend.fetchResetAttestation(row.group_id);
2485
2648
  if (!att) {
2486
- this.recordDecryptError(row, new Error(`supersede refused: no attestation for group ${row.group_id}`));
2487
- return false;
2649
+ return refuse(`supersede refused: no attestation for group ${row.group_id}`);
2488
2650
  }
2489
2651
  if (att.signerIdentityId !== owner.identityId) {
2490
- this.recordDecryptError(row, new Error("supersede refused: signer is not the pinned owner"));
2491
- return false;
2652
+ return refuse("supersede refused: signer is not the pinned owner");
2492
2653
  }
2493
2654
  let content;
2494
2655
  try {
2495
2656
  content = JSON.parse(att.signedContent);
2496
2657
  } catch {
2497
- this.recordDecryptError(row, new Error("supersede refused: unparseable signed_content"));
2498
- return false;
2658
+ return refuse("supersede refused: unparseable signed_content");
2499
2659
  }
2500
2660
  if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
2501
- this.recordDecryptError(row, new Error("supersede refused: attestation channel/group mismatch"));
2502
- return false;
2661
+ return refuse("supersede refused: attestation channel/group mismatch");
2503
2662
  }
2504
2663
  const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
2505
2664
  if (!ok) {
2506
- this.recordDecryptError(row, new Error("supersede refused: signature does not verify against pinned owner key"));
2507
- return false;
2665
+ return refuse("supersede refused: signature does not verify against pinned owner key");
2508
2666
  }
2509
2667
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2510
2668
  if (!bundle) {
2511
- this.recordDecryptError(row, new Error("supersede refused: no pending KeyPackage bundle to open the new Welcome"));
2512
- return false;
2669
+ return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
2513
2670
  }
2514
2671
  return await this.locks.run(channelId, async () => {
2515
2672
  const mgr = new MLSGroupManager();
@@ -2534,14 +2691,14 @@ var MlsGroupKeyProvider = class {
2534
2691
  return false;
2535
2692
  }
2536
2693
  if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
2537
- this.recordDecryptError(row, new Error("supersede refused: post-join group id != attested new_group_id"));
2538
- return false;
2694
+ return refuse("supersede refused: post-join group id != attested new_group_id");
2539
2695
  }
2540
2696
  this.evictChannelState(channelId);
2541
2697
  await this.o.store.delete(channelId);
2542
2698
  await this.o.store.discardStaging(channelId);
2543
2699
  await this.o.store.addRetiredGroupId(channelId, heldGroupId);
2544
2700
  this.errors.delete(channelId);
2701
+ this.supersedeRefusals.delete(`${channelId}:${row.group_id}`);
2545
2702
  this.managers.set(channelId, mgr);
2546
2703
  await this.o.store.save(channelId, mgr.exportState());
2547
2704
  await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
@@ -2550,16 +2707,45 @@ var MlsGroupKeyProvider = class {
2550
2707
  }
2551
2708
  async receiveOnce(deviceId) {
2552
2709
  const rows = await this.o.backend.pullDelivery(deviceId);
2553
- if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
2554
- 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(", "));
2555
- }
2556
2710
  const appEntries = [];
2557
2711
  const handshakeAcked = [];
2558
2712
  const supersededChannelIds = [];
2559
- const order = { welcome: 0, commit: 1, application: 2 };
2560
- rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
2561
- for (const row of rows) {
2713
+ const welcomes = rows.filter((r) => r.message_type === "welcome");
2714
+ const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
2715
+ const ordered = [...welcomes, ...nonWelcomes];
2716
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
2717
+ 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(", "));
2718
+ }
2719
+ const classCtx = /* @__PURE__ */ new Map();
2720
+ const classCtxFor = async (cid) => {
2721
+ let ctx = classCtx.get(cid);
2722
+ if (!ctx) {
2723
+ let heldGroupId = null;
2724
+ let snapshotEpoch;
2725
+ let retired = null;
2726
+ try {
2727
+ const mgr = await this.manager(cid);
2728
+ heldGroupId = new TextDecoder().decode(mgr.groupId);
2729
+ snapshotEpoch = Number(mgr.epoch);
2730
+ } catch {
2731
+ heldGroupId = null;
2732
+ snapshotEpoch = void 0;
2733
+ }
2734
+ try {
2735
+ retired = await this.o.store.loadRetiredGroupIds(cid);
2736
+ } catch {
2737
+ retired = null;
2738
+ }
2739
+ ctx = { heldGroupId, retired, snapshotEpoch, liveCommitEpoch: snapshotEpoch };
2740
+ classCtx.set(cid, ctx);
2741
+ }
2742
+ return ctx;
2743
+ };
2744
+ for (const row of ordered) {
2562
2745
  try {
2746
+ if (row.message_type !== "welcome" && row.connect_channel_id) {
2747
+ await classCtxFor(row.connect_channel_id);
2748
+ }
2563
2749
  if (row.message_type === "welcome") {
2564
2750
  const wcid = row.connect_channel_id;
2565
2751
  if (wcid) {
@@ -2578,9 +2764,15 @@ var MlsGroupKeyProvider = class {
2578
2764
  if (heldGroupId !== null && heldGroupId === row.group_id) {
2579
2765
  handshakeAcked.push(row.queue_id);
2580
2766
  } else if (heldGroupId !== null && heldGroupId !== row.group_id) {
2581
- const ok = await this.trySupersede(wcid, row, heldGroupId);
2582
- if (ok)
2583
- supersededChannelIds.push(wcid);
2767
+ if (this.supersedeBackoffActive(wcid, row.group_id)) {
2768
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2769
+ console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} g=${row.group_id.slice(0, 8)} -> SKIP(refusal back-off)`);
2770
+ }
2771
+ } else {
2772
+ const ok = await this.trySupersede(wcid, row, heldGroupId);
2773
+ if (ok)
2774
+ supersededChannelIds.push(wcid);
2775
+ }
2584
2776
  }
2585
2777
  } else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2586
2778
  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)`);
@@ -2590,15 +2782,25 @@ var MlsGroupKeyProvider = class {
2590
2782
  const cid = row.connect_channel_id;
2591
2783
  if (!cid)
2592
2784
  continue;
2785
+ let advancedEpoch;
2593
2786
  await this.locks.run(cid, async () => {
2594
2787
  const mgr = await this.manager(cid);
2595
2788
  await mgr.decrypt(hexToBytes(row.payload));
2596
2789
  await this.o.store.save(cid, mgr.exportState());
2790
+ advancedEpoch = Number(mgr.epoch);
2597
2791
  });
2598
2792
  this.recordDecryptSuccess(cid);
2793
+ if (advancedEpoch !== void 0) {
2794
+ const ctx = classCtx.get(cid);
2795
+ if (ctx)
2796
+ ctx.liveCommitEpoch = advancedEpoch;
2797
+ }
2599
2798
  handshakeAcked.push(row.queue_id);
2600
2799
  } else {
2601
2800
  const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
2801
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
2802
+ throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
2803
+ }
2602
2804
  const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
2603
2805
  const aad = await buildAssociatedData({
2604
2806
  message_id: envelope.message_id,
@@ -2626,18 +2828,25 @@ var MlsGroupKeyProvider = class {
2626
2828
  this.recordDecryptError(row, err);
2627
2829
  const cid = row.connect_channel_id;
2628
2830
  let drained = false;
2629
- if (cid && typeof row.epoch === "number") {
2630
- let liveEpoch;
2631
- try {
2632
- liveEpoch = await this.currentEpoch(cid);
2633
- } catch {
2634
- liveEpoch = void 0;
2635
- }
2636
- const permanentlyDead = liveEpoch !== void 0 && (row.message_type === "commit" ? row.epoch <= liveEpoch : row.message_type === "application" ? row.epoch < liveEpoch : false);
2637
- if (permanentlyDead) {
2831
+ if (cid && row.message_type !== "welcome") {
2832
+ const ctx = classCtx.get(cid);
2833
+ const verdict = classifyFailedRow({
2834
+ messageType: row.message_type,
2835
+ rowGroupId: row.group_id,
2836
+ rowEpoch: typeof row.epoch === "number" ? row.epoch : void 0,
2837
+ heldGroupId: ctx?.heldGroupId ?? null,
2838
+ retiredGroupIds: ctx?.retired ?? null,
2839
+ snapshotEpoch: ctx?.snapshotEpoch,
2840
+ liveCommitEpoch: ctx?.liveCommitEpoch
2841
+ });
2842
+ if (verdict === "permanentlySkipped") {
2638
2843
  handshakeAcked.push(row.queue_id);
2639
2844
  this.permanentlySkipped++;
2640
2845
  drained = true;
2846
+ } else if (verdict === "retiredDrained") {
2847
+ handshakeAcked.push(row.queue_id);
2848
+ this.retiredDrained++;
2849
+ drained = true;
2641
2850
  }
2642
2851
  }
2643
2852
  if (!drained && cid) {
@@ -2645,8 +2854,10 @@ var MlsGroupKeyProvider = class {
2645
2854
  if (e) {
2646
2855
  e.wedgedRun += 1;
2647
2856
  if (e.wedgedRun >= this.quarantineThreshold) {
2648
- handshakeAcked.push(row.queue_id);
2649
- this.quarantineDrained++;
2857
+ if (row.message_type !== "welcome") {
2858
+ handshakeAcked.push(row.queue_id);
2859
+ this.quarantineDrained++;
2860
+ }
2650
2861
  this.quarantineChannel(cid);
2651
2862
  }
2652
2863
  }
@@ -3283,19 +3494,8 @@ var RestMlsBackend = class {
3283
3494
  created: j.created === void 0 ? true : Boolean(j.created)
3284
3495
  };
3285
3496
  }
3286
- async reinitConnectChannelGroup(channelId, memberDeviceIds) {
3287
- const committer = memberDeviceIds[0];
3288
- if (!committer)
3289
- throw new Error("reinitConnectChannelGroup: empty member set");
3290
- const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
3291
- member_device_ids: memberDeviceIds,
3292
- committer_device_id: committer
3293
- });
3294
- return {
3295
- group_id: String(j.group_id),
3296
- current_epoch: Number(j.current_epoch),
3297
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
3298
- };
3497
+ async reinitConnectChannelGroup() {
3498
+ throw new Error("reinitConnectChannelGroup was removed (it minted unattested MLS groups that orphaned channel members permanently). Use resetReinitConnectChannelGroup with an owner attestation.");
3299
3499
  }
3300
3500
  async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
3301
3501
  const committer = memberDeviceIds[0];
@@ -3313,7 +3513,11 @@ var RestMlsBackend = class {
3313
3513
  return {
3314
3514
  group_id: String(j.group_id),
3315
3515
  current_epoch: Number(j.current_epoch),
3316
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
3516
+ member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3517
+ // Absent on a backend that predates the dropped-member report; an empty
3518
+ // list is the correct read of "the server told us nothing", since the old
3519
+ // server also never dropped anyone for an expired KeyPackage.
3520
+ stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
3317
3521
  };
3318
3522
  }
3319
3523
  async fetchKeyPackages(deviceIds) {
@@ -5211,11 +5415,33 @@ var BridgeAgent = class _BridgeAgent {
5211
5415
  return;
5212
5416
  }
5213
5417
  this.providerCapabilityMissing = false;
5214
- let chans;
5215
- try {
5216
- chans = await this.listChannels();
5217
- } catch (e) {
5218
- console.warn("[bridge] selfHealSweep aborted: listChannels failed:", e);
5418
+ const enclaves = await this.enclavesToCover();
5419
+ this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
5420
+ const seen = /* @__PURE__ */ new Set();
5421
+ const chans = [];
5422
+ const failed = [];
5423
+ let listedAny = false;
5424
+ for (const encId of enclaves) {
5425
+ try {
5426
+ for (const c of await this.listChannels(encId)) {
5427
+ if (seen.has(c.channel_id)) continue;
5428
+ seen.add(c.channel_id);
5429
+ chans.push(c);
5430
+ }
5431
+ listedAny = true;
5432
+ this.enclaveListEverSucceeded.add(encId);
5433
+ this.enclaveListWarned.delete(encId);
5434
+ } catch (e) {
5435
+ failed.push(encId);
5436
+ if (!this.enclaveListWarned.has(encId)) {
5437
+ this.enclaveListWarned.add(encId);
5438
+ console.warn(`[bridge] selfHealSweep: listChannels failed for enclave ${encId}; skipping (warn-once per streak):`, e);
5439
+ }
5440
+ }
5441
+ }
5442
+ this.sweepEnclavesFailed = failed;
5443
+ if (!listedAny) {
5444
+ console.warn("[bridge] selfHealSweep aborted: every enclave listing failed");
5219
5445
  return;
5220
5446
  }
5221
5447
  for (const c of chans) {
@@ -5561,7 +5787,7 @@ var BridgeAgent = class _BridgeAgent {
5561
5787
  * the other enclaves in the set.
5562
5788
  */
5563
5789
  async refreshChannelMeta() {
5564
- const enclaves = /* @__PURE__ */ new Set([this.cfg.enclaveId, ...this.store.distinctEnclaveIds()]);
5790
+ const enclaves = await this.enclavesToCover();
5565
5791
  const now = Date.now();
5566
5792
  for (const encId of enclaves) {
5567
5793
  try {
@@ -5574,6 +5800,89 @@ var BridgeAgent = class _BridgeAgent {
5574
5800
  }
5575
5801
  }
5576
5802
  }
5803
+ /** Cache for the server-side enclave enumeration (see enclavesToCover). */
5804
+ enclaveEnumCache = null;
5805
+ /** Enclaves whose channel listing failed on the NEWEST sweep (audit HIGH:
5806
+ * skip-one deleted the sweep's partial-coverage signal — observations_stale
5807
+ * is a global max, so one healthy enclave masked a failed one). Surfaced on
5808
+ * health() so partial coverage is never silent. */
5809
+ sweepEnclavesFailed = [];
5810
+ /** Enclave ids whose channel listing has EVER succeeded this process, seeded
5811
+ * with HOME (home is always expected to list; without the seed, a home
5812
+ * failure right after a restart would read as "never succeeded" and slip
5813
+ * through the expected-gap branch — weaker than the old abort-on-failure).
5814
+ * A failure of a member of this set is a REGRESSION and degrades health; a
5815
+ * failure of a never-succeeded enclave (partner: tenant-scoped 404, permanent
5816
+ * pending backend authz) is an EXPECTED gap — reported, not degrading, or
5817
+ * every federated agent would peg degraded forever. */
5818
+ enclaveListEverSucceeded = /* @__PURE__ */ new Set();
5819
+ /** Warn-once-per-failure-streak, keyed by enclave id (cleared on success):
5820
+ * a permanently-404ing partner enclave must not log 1440 lines a day. */
5821
+ enclaveListWarned = /* @__PURE__ */ new Set();
5822
+ static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
5823
+ enclaveEnumWarned = false;
5824
+ /**
5825
+ * C1 (split-brain Task 3): every enclave this agent must COVER — for the
5826
+ * self-heal sweep and for channel-meta refresh alike. ONE helper on purpose:
5827
+ * these two call sites had drifted (refreshChannelMeta looped home+learned,
5828
+ * selfHealSweep looped nothing), and the drift is exactly what left both
5829
+ * production agents with zero recovery coverage in their second enclave.
5830
+ *
5831
+ * The set is SERVER enumeration ∪ home ∪ store-learned:
5832
+ *
5833
+ * - SERVER (`GET /api/v1/enclaves`, membership-scoped, federated included)
5834
+ * is the authoritative source. The store CANNOT be: channel_meta's only
5835
+ * writers are listChannels itself (circular — you learn enclave B by
5836
+ * having already listed B) and the receive pump's successfully-decrypted
5837
+ * loop, and a FORKED agent decrypts nothing, so the agent that most needs
5838
+ * sweeping in enclave B is precisely the one whose store never learned B.
5839
+ * - home + learned stay in the union as the degraded-mode floor: if the
5840
+ * enumeration endpoint is down, we sweep what we can rather than nothing,
5841
+ * and we warn (once) so the gap is diagnosable.
5842
+ *
5843
+ * The server result is cached for ENCLAVE_ENUM_TTL_MS so per-ingest-cycle
5844
+ * callers (refreshChannelMeta) do not add an HTTP round-trip per cycle;
5845
+ * membership changes are picked up within one TTL, which matches the sweep
5846
+ * cadence this exists to serve.
5847
+ *
5848
+ * ⚠️ Residual gap, stated: the enumeration RETURNS cross-tenant partner
5849
+ * enclaves, but `list_channels_in_enclave` is tenant-scoped and 404s them
5850
+ * under this agent's bearer — so a partner enclave in this set still yields
5851
+ * no channels. Callers must treat a per-enclave listing failure as skip-one,
5852
+ * never abort-all. Closing that needs backend authz work, not daemon work.
5853
+ */
5854
+ async enclavesToCover() {
5855
+ const now = Date.now();
5856
+ if (!this.enclaveEnumCache || now - this.enclaveEnumCache.at > _BridgeAgent.ENCLAVE_ENUM_TTL_MS) {
5857
+ try {
5858
+ const url = `${this.cfg.apiBase}/api/v1/enclaves?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
5859
+ const res = await this.fetchImpl(url, {
5860
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` }
5861
+ });
5862
+ if (!res.ok) throw new Error(`list_enclaves failed: ${res.status}`);
5863
+ const rows = await res.json();
5864
+ this.enclaveEnumCache = { ids: rows.map((r) => r.enclave_id), at: Date.now() };
5865
+ this.enclaveEnumWarned = false;
5866
+ } catch (e) {
5867
+ if (!this.enclaveEnumWarned) {
5868
+ this.enclaveEnumWarned = true;
5869
+ console.warn(
5870
+ "[bridge] enclave enumeration failed; sweep coverage degraded to home+learned enclaves:",
5871
+ e
5872
+ );
5873
+ }
5874
+ }
5875
+ }
5876
+ const fresh = this.enclaveEnumCache && Date.now() - this.enclaveEnumCache.at <= _BridgeAgent.ENCLAVE_ENUM_TTL_MS;
5877
+ if (fresh) {
5878
+ return /* @__PURE__ */ new Set([this.cfg.enclaveId, ...this.enclaveEnumCache.ids]);
5879
+ }
5880
+ return /* @__PURE__ */ new Set([
5881
+ this.cfg.enclaveId,
5882
+ ...this.store.distinctEnclaveIds(),
5883
+ ...this.enclaveEnumCache?.ids ?? []
5884
+ ]);
5885
+ }
5577
5886
  /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
5578
5887
  freshRoster(channelId) {
5579
5888
  const hit = this.channelRoster.get(channelId);
@@ -6170,6 +6479,9 @@ var BridgeAgent = class _BridgeAgent {
6170
6479
  const stalePeriodMs = _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS;
6171
6480
  const observationsStale = this.providerCapabilityMissing || channels.length === 0 || Date.now() - newest > stalePeriodMs;
6172
6481
  const divergedChannels = channels.filter((o) => o.diverged === true).length;
6482
+ const sweepCoverageLost = this.sweepEnclavesFailed.some(
6483
+ (id) => this.enclaveListEverSucceeded.has(id)
6484
+ );
6173
6485
  const liveChannels = channels.map((o) => ({
6174
6486
  ...o,
6175
6487
  last_decrypt_at: this.provider.lastDecryptAtFor?.(o.channel_id) ?? o.last_decrypt_at
@@ -6184,8 +6496,9 @@ var BridgeAgent = class _BridgeAgent {
6184
6496
  observations_stale: observationsStale,
6185
6497
  diverged_channels: divergedChannels,
6186
6498
  // A2: the truthful roll-up gates read instead of `status` (D-3).
6187
- degraded: divergedChannels > 0 || observationsStale,
6499
+ degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost,
6188
6500
  channels: liveChannels,
6501
+ sweep_enclaves_failed: [...this.sweepEnclavesFailed],
6189
6502
  identity_id: this.cfg.identityId,
6190
6503
  joined_channels: joined,
6191
6504
  buffered: this.buffer.length,
@@ -6193,6 +6506,8 @@ var BridgeAgent = class _BridgeAgent {
6193
6506
  filtered_room: this.filteredRoomMessages,
6194
6507
  rate_limited: this.rateLimitedSends,
6195
6508
  permanently_skipped: this.provider.permanentlySkippedCount(),
6509
+ quarantine_drained: this.provider.quarantineDrainedCount(),
6510
+ retired_drained: this.provider.retiredDrainedCount(),
6196
6511
  failed_attachments: this.failedAttachments,
6197
6512
  delivery_pings: this.pingsReceived,
6198
6513
  agent_sessions: this.authSessions,
@@ -6238,8 +6553,11 @@ var BridgeAgent = class _BridgeAgent {
6238
6553
  filteredRoomMessages: this.filteredRoomMessages,
6239
6554
  rateLimitedSends: this.rateLimitedSends,
6240
6555
  permanentlySkipped: this.provider.permanentlySkippedCount(),
6556
+ quarantineDrained: this.provider.quarantineDrainedCount(),
6557
+ retiredDrained: this.provider.retiredDrainedCount(),
6241
6558
  welcomeRequestsSent: this.welcomeRequestsSent,
6242
- failedAttachments: this.failedAttachments
6559
+ failedAttachments: this.failedAttachments,
6560
+ build: BRIDGE_VERSION
6243
6561
  };
6244
6562
  }
6245
6563
  async close() {