@securecoms/agent-bridge 0.10.0 → 0.11.1

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
@@ -1216,6 +1216,28 @@ async function agentPausedErrorFromResponse(res) {
1216
1216
  const err = detail?.error;
1217
1217
  return err === "agent_paused" ? new AgentPausedError() : null;
1218
1218
  }
1219
+ var MalformedCommitterWorkError = class extends ConnectSdkError {
1220
+ channelId;
1221
+ field;
1222
+ receivedType;
1223
+ constructor(channelId, field, receivedType) {
1224
+ super(`committer-work for channel ${channelId} returned a malformed body: \`${field}\` is ${receivedType}, expected array`);
1225
+ this.channelId = channelId;
1226
+ this.field = field;
1227
+ this.receivedType = receivedType;
1228
+ }
1229
+ };
1230
+ var MalformedGroupRosterError = class extends ConnectSdkError {
1231
+ endpoint;
1232
+ channelId;
1233
+ receivedType;
1234
+ constructor(endpoint, channelId, receivedType) {
1235
+ super(`${endpoint} for channel ${channelId} returned a malformed group roster: \`member_device_ids\` is ${receivedType}, expected a non-empty array`);
1236
+ this.endpoint = endpoint;
1237
+ this.channelId = channelId;
1238
+ this.receivedType = receivedType;
1239
+ }
1240
+ };
1219
1241
 
1220
1242
  // ../connect-sdk-ts/dist/envelope-builder.js
1221
1243
  async function buildOutboundEnvelope(p) {
@@ -1324,8 +1346,23 @@ import { hmac } from "@noble/hashes/hmac";
1324
1346
  import { sha256 as sha2562 } from "@noble/hashes/sha256";
1325
1347
  import { bytesToHex } from "@agentvault/crypto";
1326
1348
 
1349
+ // ../connect-sdk-ts/dist/groupkey/crypto-patch-guard.js
1350
+ import { MLSGroupManager } from "@agentvault/crypto";
1351
+ var CryptoPatchMissingError = class extends Error {
1352
+ constructor() {
1353
+ super("MLSGroupManager.leafIndicesForKeyPackage is missing \u2014 the @agentvault/crypto patch did not apply. Expected patches/@agentvault+crypto+0.4.1.patch to be applied by the root postinstall (`patch-package --error-on-fail --error-on-warn`). Re-run `npm ci` at the repo root, and check that @agentvault/crypto resolved to exactly 0.4.1.");
1354
+ this.name = "CryptoPatchMissingError";
1355
+ }
1356
+ };
1357
+ function leafIndicesForKeyPackage(mgr, kp) {
1358
+ if (typeof MLSGroupManager.prototype.leafIndicesForKeyPackage !== "function") {
1359
+ throw new CryptoPatchMissingError();
1360
+ }
1361
+ return mgr.leafIndicesForKeyPackage(kp);
1362
+ }
1363
+
1327
1364
  // ../connect-sdk-ts/dist/groupkey/mls.js
1328
- import { MLSGroupManager, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1365
+ import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1329
1366
 
1330
1367
  // ../connect-sdk-ts/dist/groupkey/operation-queue.js
1331
1368
  var MlsOperationQueue = class {
@@ -1371,6 +1408,69 @@ var MlsOperationQueue = class {
1371
1408
  }
1372
1409
  };
1373
1410
 
1411
+ // ../connect-sdk-ts/dist/groupkey/receive-classifier.js
1412
+ function classifyFailedRow(i) {
1413
+ if (i.messageType === "welcome")
1414
+ return "defer";
1415
+ if (i.messageType !== "commit" && i.messageType !== "application")
1416
+ return "defer";
1417
+ if (i.heldGroupId !== null && i.rowGroupId === i.heldGroupId) {
1418
+ const epochForVerdict = i.messageType === "commit" ? i.liveCommitEpoch : i.snapshotEpoch;
1419
+ if (epochForVerdict === void 0 || typeof i.rowEpoch !== "number")
1420
+ return "defer";
1421
+ const dead = i.messageType === "commit" ? i.rowEpoch <= epochForVerdict : i.rowEpoch < epochForVerdict;
1422
+ return dead ? "permanentlySkipped" : "defer";
1423
+ }
1424
+ if (i.heldGroupId !== null && i.retiredGroupIds !== null && i.retiredGroupIds.includes(i.rowGroupId))
1425
+ return "retiredDrained";
1426
+ return "defer";
1427
+ }
1428
+
1429
+ // ../connect-sdk-ts/dist/groupkey/admission-rule.js
1430
+ function canAdmit(input) {
1431
+ if (input.targetDeviceId === input.ownDeviceId)
1432
+ return { decision: "refuse", reason: "self" };
1433
+ if (input.boundLeaves.some((b) => b.device === input.targetDeviceId)) {
1434
+ return { decision: "refuse", reason: "already-bound" };
1435
+ }
1436
+ if (input.targetLeaves.length > 0) {
1437
+ if (input.deltaVouches)
1438
+ return { decision: "refuse", reason: "identity-leaf-present" };
1439
+ if (input.targetLeaves.length > 1)
1440
+ return { decision: "refuse", reason: "identity-leaf-present" };
1441
+ const t = input.targetLeaves[0];
1442
+ if (!input.identityLeaves.some((l) => l.leafIndex === t))
1443
+ return { decision: "refuse", reason: "already-bound" };
1444
+ if (input.identityLeaves.some((l) => l.leafIndex !== t && l.leafIndex !== input.ownLeafIndex))
1445
+ return { decision: "refuse", reason: "identity-leaf-present" };
1446
+ return { decision: "already-present" };
1447
+ }
1448
+ if (input.targetIdentityId === null)
1449
+ return { decision: "refuse", reason: "unknown-identity" };
1450
+ if (input.identityLeaves.length === 0)
1451
+ return { decision: "admit" };
1452
+ const allUnbound = input.identityLeaves.every((l) => l.device === null);
1453
+ const ownLeafUnreadable = allUnbound && input.ownLeafIndex === null;
1454
+ const exemptionApplies = allUnbound && input.ownLeafIndex !== null && input.identityLeaves.every((l) => l.leafIndex === input.ownLeafIndex);
1455
+ if (exemptionApplies)
1456
+ return { decision: "admit" };
1457
+ if (input.deltaVouches)
1458
+ return { decision: "admit" };
1459
+ return {
1460
+ decision: "refuse",
1461
+ reason: ownLeafUnreadable ? "unknown-own-leaf" : "identity-leaf-present"
1462
+ };
1463
+ }
1464
+ function hasMalformedBinding(identityLeaves) {
1465
+ return identityLeaves.some((l) => l.malformed);
1466
+ }
1467
+
1468
+ // ../connect-sdk-ts/dist/groupkey/kp-expiry.js
1469
+ var SKEW_GUARD_MS = 5 * 60 * 1e3;
1470
+ function isServerExpired(exp, nowMs) {
1471
+ return nowMs - SKEW_GUARD_MS >= exp.getTime();
1472
+ }
1473
+
1374
1474
  // ../connect-sdk-ts/dist/groupkey/mls.js
1375
1475
  var LABEL = "agentvault-connect-v1";
1376
1476
  var DEFAULT_STUCK_THRESHOLD = 2;
@@ -1418,6 +1518,30 @@ var ForgedCredentialError = class extends Error {
1418
1518
  this.name = "ForgedCredentialError";
1419
1519
  }
1420
1520
  };
1521
+ var SelfAddRefusedError = class extends Error {
1522
+ deviceId;
1523
+ channelId;
1524
+ constructor(deviceId, channelId) {
1525
+ super(`addMemberToLiveGroup: refusing to Add this committer's OWN device ${deviceId} to channel ${channelId} \u2014 a self-Add mints a second, permanent leaf for a device that already holds one. Terminal; this is never a retryable condition.`);
1526
+ this.deviceId = deviceId;
1527
+ this.channelId = channelId;
1528
+ this.name = "SelfAddRefusedError";
1529
+ }
1530
+ };
1531
+ var AddRefusedError = class extends Error {
1532
+ reason;
1533
+ deviceId;
1534
+ identityId;
1535
+ identityLeafCount;
1536
+ constructor(reason, deviceId, identityId, identityLeafCount) {
1537
+ super(`addMemberToLiveGroup: refusing Add of device ${deviceId} (${reason}) \u2014 identity ${identityId ?? "<undecodable>"} holds ${identityLeafCount} leaf/leaves in this device's view of the tree. A plain Add here risks an irreversible duplicate leaf; the repair path is Remove+Add, never a plain Add.`);
1538
+ this.reason = reason;
1539
+ this.deviceId = deviceId;
1540
+ this.identityId = identityId;
1541
+ this.identityLeafCount = identityLeafCount;
1542
+ this.name = "AddRefusedError";
1543
+ }
1544
+ };
1421
1545
  var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1422
1546
  o;
1423
1547
  suite;
@@ -1487,6 +1611,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1487
1611
  * the past-epoch {@link permanentlySkipped} drain. Read via
1488
1612
  * {@link quarantineDrainedCount}. */
1489
1613
  quarantineDrained = 0;
1614
+ /** Cumulative count of rows ack-drained because their group_id is RETIRED for
1615
+ * the channel (superseded fork-loser traffic). Distinct from the same-group
1616
+ * past-epoch {@link permanentlySkipped} drain. Read via
1617
+ * {@link retiredDrainedCount}. */
1618
+ retiredDrained = 0;
1490
1619
  constructor(o) {
1491
1620
  this.o = o;
1492
1621
  this.suite = o.suite ?? "xchacha20poly1305";
@@ -1644,6 +1773,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1644
1773
  quarantineDrainedCount() {
1645
1774
  return this.quarantineDrained;
1646
1775
  }
1776
+ /** See {@link retiredDrained}. */
1777
+ retiredDrainedCount() {
1778
+ return this.retiredDrained;
1779
+ }
1647
1780
  /** Evict the live in-memory MLS group manager for a channel. Used when the
1648
1781
  * device leaves or deletes a channel: dropping the persisted store record is
1649
1782
  * not enough on its own, because a warm manager keeps decrypting until the
@@ -1680,6 +1813,24 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1680
1813
  * ONE active leaf. Used by the guest reconcile sweep when an approved guest
1681
1814
  * sits in `joining`.
1682
1815
  *
1816
+ * ⛔ AS OF SLICE B (ADR-0014 Decision B) THE WORKING DEVICE SWAP IS GONE.
1817
+ * When `priorDeviceId` names a device whose leaf still carries this guest
1818
+ * identity — the case the swap existed for — the Add chokepoint's admission
1819
+ * rule refuses (AddRefusedError, reason `identity-leaf-present`) and this
1820
+ * method THROWS before reaching the eviction below.
1821
+ *
1822
+ * ⚠️ That does NOT make the eviction block unreachable, and an earlier
1823
+ * revision of this note wrongly said it did. Pass a `priorDeviceId` that
1824
+ * differs from `newDeviceId` while the guest identity holds NO leaf and the
1825
+ * rule admits on clause 2's first arm, the Add commits, and the block DOES
1826
+ * run — then throws `LeafRosterMismatchError`, because the identity owns one
1827
+ * leaf where the default `orderedDeviceIds` names two. So the reachable
1828
+ * outcomes for a caller passing `priorDeviceId` are a refusal or a roster
1829
+ * mismatch, never a completed swap. The block is retained because it is still
1830
+ * correct and is what a Remove-then-Add re-enablement (or slice 5's
1831
+ * device-bound leaf) will build on. See the body comment for why the
1832
+ * count-only attestation that used to open that path was retired.
1833
+ *
1683
1834
  * Add then evict: we commit the Add first so the new leaf exists before the
1684
1835
  * old one is dropped (the guest is never momentarily leaf-less in the group).
1685
1836
  * The eviction uses the per-DEVICE removal path (removeDeviceFromLiveGroup),
@@ -1695,16 +1846,19 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1695
1846
  * equals newDeviceId (the same device re-admitted).
1696
1847
  */
1697
1848
  async admitGuestDevice(channelId, g) {
1698
- await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
1699
- knownLeafDevices: g.priorDeviceId ? [g.priorDeviceId] : []
1849
+ const outcome = await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
1850
+ expectedIdentityId: g.identityId
1700
1851
  });
1701
- if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1852
+ if (outcome === "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1702
1853
  const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
1703
1854
  await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
1704
1855
  identity: g.identityId,
1705
1856
  orderedDeviceIds
1706
1857
  });
1858
+ } else if (outcome !== "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1859
+ console.warn(`admitGuestDevice: channel ${channelId} \u2014 Add of ${g.newDeviceId} was a no-op (${outcome}), so the eviction of prior device ${g.priorDeviceId} was SKIPPED. The prior leaf is intact and no swap occurred; re-drive the swap only after the new device holds its own leaf.`);
1707
1860
  }
1861
+ return outcome;
1708
1862
  }
1709
1863
  /** Load (or reuse) the per-channel manager from the store. Throws if no state. */
1710
1864
  async manager(channelId) {
@@ -1712,9 +1866,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1712
1866
  if (cached)
1713
1867
  return cached;
1714
1868
  const saved = await this.o.store.load(channelId);
1869
+ const again = this.managers.get(channelId);
1870
+ if (again)
1871
+ return again;
1715
1872
  if (!saved)
1716
1873
  throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
1717
- const mgr = new MLSGroupManager(saved.ciphersuite);
1874
+ const mgr = new MLSGroupManager2(saved.ciphersuite);
1718
1875
  mgr.importState(saved);
1719
1876
  this.managers.set(channelId, mgr);
1720
1877
  return mgr;
@@ -1916,11 +2073,21 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1916
2073
  return w?.connect_channel_id ?? null;
1917
2074
  }
1918
2075
  /**
1919
- * Initialize the ts-mls libsodium (WASM) backend ONCE before any store
1920
- * operation that deserializes a persisted KeyPackage. generateKeyPackage
1921
- * awaits sodium.ready and is pure in-memory (it neither publishes to the
1922
- * backend nor writes the store), so the throwaway bundle is a side-effect-free
1923
- * warm-up. Without it, the FIRST loadPendingBundle at boot (the republish
2076
+ * Give the ts-mls libsodium (WASM) backend time to initialize before any
2077
+ * store operation that deserializes a persisted KeyPackage. generateKeyPackage
2078
+ * is pure in-memory (it neither publishes to the backend nor writes the
2079
+ * store), so the throwaway bundle is a side-effect-free warm-up.
2080
+ *
2081
+ * ⛔ IT IS A RACE, NOT A BARRIER, and do not read it as one. Nothing on this
2082
+ * path awaits sodium.ready: MLSGroupManager defaults _provider to
2083
+ * nobleCryptoProvider, and _ensureCiphersuite passes that provider to
2084
+ * getCiphersuiteImpl. What the warm-up actually does is BURN WALL CLOCK while
2085
+ * libsodium's WASM initializes in PARALLEL. The margin is roughly 1.7 ms and
2086
+ * it DRIFTS: libsodium-sumo is reached through a caret and its init timing
2087
+ * sets the margin, so an install can move it with no change in this repo.
2088
+ * See 02f47e15 (PR #87), whose commit body carries the correct diagnosis.
2089
+ *
2090
+ * Without it, the FIRST loadPendingBundle at boot (the republish
1924
2091
  * guard, and the join path) runs while WASM is still cold, throws deep in the
1925
2092
  * crypto ("Cannot read properties of undefined (reading '_malloc')"), is caught
1926
2093
  * and misread as "no held bundle", and the daemon mints + republishes a FRESH
@@ -1931,7 +2098,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1931
2098
  async ensureCryptoReady() {
1932
2099
  if (this.cryptoWarmed)
1933
2100
  return;
1934
- await new MLSGroupManager().generateKeyPackage(this.o.identity);
2101
+ await new MLSGroupManager2().generateKeyPackage(this.o.identity);
1935
2102
  this.cryptoWarmed = true;
1936
2103
  }
1937
2104
  async publishKeyPackage() {
@@ -1982,7 +2149,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1982
2149
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1983
2150
  if (!bundle)
1984
2151
  return false;
1985
- const heldHex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
2152
+ const heldHex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
1986
2153
  const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
1987
2154
  const pubHex = published[this.o.deviceId];
1988
2155
  return !!pubHex && pubHex === heldHex;
@@ -1992,13 +2159,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1992
2159
  }
1993
2160
  async publishKeyPackageUnlocked() {
1994
2161
  if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
1995
- console.warn("publishKeyPackage: pending bundle exists; skipping republish");
1996
- return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
2162
+ const kpId = await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
2163
+ const exp = kpId === "" ? null : await this.o.backend.fetchKeyPackageExpiry(kpId);
2164
+ const expired = exp !== null && isServerExpired(exp, this.o.now?.() ?? Date.now());
2165
+ if (!expired) {
2166
+ console.warn("publishKeyPackage: pending bundle exists; skipping republish");
2167
+ return kpId;
2168
+ }
2169
+ console.warn(`publishKeyPackage: server KeyPackage expired for device ${this.o.deviceId.slice(0, 8)}; rotating`);
2170
+ await this.o.store.deletePendingBundle(this.o.deviceId);
1997
2171
  }
1998
2172
  console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
1999
- const mgr = new MLSGroupManager();
2173
+ const mgr = new MLSGroupManager2();
2000
2174
  const bundle = await mgr.generateKeyPackage(this.o.identity);
2001
- const hex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
2175
+ const hex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
2002
2176
  const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
2003
2177
  await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
2004
2178
  return id;
@@ -2026,14 +2200,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2026
2200
  if (opts.forceReinit) {
2027
2201
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
2028
2202
  const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
2029
- const mgr = new MLSGroupManager();
2203
+ const mgr = new MLSGroupManager2();
2030
2204
  await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
2031
2205
  const kps = await this.o.backend.fetchKeyPackages(others);
2032
2206
  const pkgs = others.map((d) => {
2033
2207
  const hex = kps[d];
2034
2208
  if (!hex)
2035
2209
  throw new Error(`ensureSession(forceReinit): no published KeyPackage for device ${d}`);
2036
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2210
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2037
2211
  });
2038
2212
  const { commit, welcome } = await mgr.addMembers(pkgs);
2039
2213
  await this.o.backend.distribute(reinit.group_id, {
@@ -2052,7 +2226,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2052
2226
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
2053
2227
  if (init.created === false)
2054
2228
  throw new BootstrapRaceLostError();
2055
- const mgr = new MLSGroupManager();
2229
+ const mgr = new MLSGroupManager2();
2056
2230
  await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
2057
2231
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
2058
2232
  const kps = await this.o.backend.fetchKeyPackages(others);
@@ -2060,7 +2234,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2060
2234
  const hex = kps[d];
2061
2235
  if (!hex)
2062
2236
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
2063
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2237
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2064
2238
  });
2065
2239
  const { commit, welcome } = await mgr.addMembers(pkgs);
2066
2240
  await this.o.backend.distribute(init.group_id, {
@@ -2078,7 +2252,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2078
2252
  const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
2079
2253
  if (!w)
2080
2254
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
2081
- const mgr = new MLSGroupManager();
2255
+ const mgr = new MLSGroupManager2();
2082
2256
  try {
2083
2257
  await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
2084
2258
  } catch (err) {
@@ -2114,36 +2288,66 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2114
2288
  const hex = kps[newDeviceId];
2115
2289
  if (!hex)
2116
2290
  throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
2117
- const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2118
- if (_attempt === 0) {
2119
- let adds;
2120
- try {
2121
- adds = (await this.o.backend.getCommitterWork(channelId)).adds;
2122
- } catch (e) {
2123
- throw new Error(`addMemberToLiveGroup: committer-work unreachable for channel ${channelId} \u2014 refusing plain Add for device ${newDeviceId} (fail closed): ` + (e instanceof Error ? e.message : String(e)));
2291
+ const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2292
+ const cred = pkg.leafNode.credential;
2293
+ const claimedIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
2294
+ if (opts?.expectedIdentityId && claimedIdentity !== opts.expectedIdentityId) {
2295
+ throw new ForgedCredentialError(opts.expectedIdentityId, claimedIdentity ?? "");
2296
+ }
2297
+ const targetLeaves = leafIndicesForKeyPackage(mgr, pkg);
2298
+ let deltaVouches = false;
2299
+ try {
2300
+ deltaVouches = (await this.o.backend.getCommitterWork(channelId)).adds.includes(newDeviceId);
2301
+ } catch (e) {
2302
+ if (targetLeaves.length > 0) {
2303
+ throw new Error(`addMemberToLiveGroup: committer-work unreachable for channel ${channelId} while device ${newDeviceId}'s published KeyPackage already holds ${targetLeaves.length} leaf/leaves here (indices ${targetLeaves.join(",")}) \u2014 refusing to report a no-op success on a delta we could not read (fail closed): ` + (e instanceof Error ? e.message : String(e)));
2124
2304
  }
2125
- if (!adds.includes(newDeviceId)) {
2126
- const cred = pkg.leafNode.credential;
2127
- const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
2128
- const leaves = claimed === null ? null : mgr.leafIndicesForIdentity(claimed);
2129
- if (leaves === null || leaves.length > 0) {
2130
- const known = opts?.knownLeafDevices ? [...new Set(opts.knownLeafDevices)] : null;
2131
- const swapSafe = leaves !== null && known !== null && !known.includes(newDeviceId) && known.length === leaves.length;
2132
- if (!swapSafe) {
2133
- console.warn(`addMemberToLiveGroup: device ${newDeviceId.slice(0, 8)} is outside the committer-work add-delta and its identity already holds a leaf \u2014 terminal skip (no commit)`);
2134
- return;
2135
- }
2305
+ deltaVouches = false;
2306
+ }
2307
+ const identityLeaves = claimedIdentity === null ? [] : mgr.leafIndicesForIdentity(claimedIdentity).map((leafIndex) => ({
2308
+ leafIndex,
2309
+ device: null,
2310
+ // SLICE 5: read the LeafNode extension here.
2311
+ malformed: false
2312
+ // SLICE 5: set from a failed extension decode.
2313
+ }));
2314
+ const ownLeaf = this.ownLeafIndex(mgr);
2315
+ const verdict = canAdmit({
2316
+ targetDeviceId: newDeviceId,
2317
+ targetIdentityId: claimedIdentity,
2318
+ ownDeviceId: this.o.deviceId,
2319
+ ownLeafIndex: ownLeaf,
2320
+ boundLeaves: [],
2321
+ // SLICE 5: the whole-tree bound-leaf scan replaces this literal.
2322
+ identityLeaves,
2323
+ targetLeaves,
2324
+ deltaVouches
2325
+ });
2326
+ if (hasMalformedBinding(identityLeaves)) {
2327
+ console.warn(`addMemberToLiveGroup: channel ${channelId} has a leaf with an unreadable device binding for identity ${claimedIdentity ?? "?"} \u2014 treated as UNBOUND, never as absent`);
2328
+ }
2329
+ if (targetLeaves.length > 0 && deltaVouches) {
2330
+ console.warn(`addMemberToLiveGroup: channel ${channelId} \u2014 the server's add-delta vouches for device ${newDeviceId} while its published KeyPackage is ALREADY a leaf here (indices ${targetLeaves.join(",")}). An honest retry cannot produce this pair: attempt 0's init unions the device into the roster, so the delta can never list it again. Treat as a possible KeyPackage substitution, or as a lost race against a peer's Add that will self-heal on the next sweep pass.`);
2331
+ }
2332
+ if (targetLeaves.length > 1) {
2333
+ console.warn(`addMemberToLiveGroup: channel ${channelId} \u2014 device ${newDeviceId}'s published KeyPackage already occupies ${targetLeaves.length} leaves (indices ${targetLeaves.join(",")}). This is a pre-existing duplicate leaf; nothing on this path can repair it and heal refuses an identity holding more than one leaf.`);
2334
+ }
2335
+ switch (verdict.decision) {
2336
+ case "admit":
2337
+ break;
2338
+ case "already-present":
2339
+ return "already-present";
2340
+ case "refuse":
2341
+ if (verdict.reason === "self") {
2342
+ throw new SelfAddRefusedError(newDeviceId, channelId);
2136
2343
  }
2344
+ throw new AddRefusedError(verdict.reason, newDeviceId, claimedIdentity, identityLeaves.length);
2345
+ default: {
2346
+ const _never = verdict;
2347
+ throw new Error(`unhandled admission verdict: ${JSON.stringify(_never)}`);
2137
2348
  }
2138
2349
  }
2139
2350
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
2140
- if (opts?.expectedIdentityId) {
2141
- const cred = pkg.leafNode.credential;
2142
- const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
2143
- if (claimed !== opts.expectedIdentityId) {
2144
- throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
2145
- }
2146
- }
2147
2351
  const priorEpoch = Number(mgr.epoch);
2148
2352
  const { commit, welcome } = await mgr.addMembers([pkg]);
2149
2353
  const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
@@ -2165,15 +2369,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2165
2369
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
2166
2370
  } catch {
2167
2371
  }
2168
- try {
2169
- const work = await this.o.backend.getCommitterWork(channelId);
2170
- if (!work.adds.includes(newDeviceId))
2171
- return;
2172
- } catch {
2173
- }
2174
2372
  return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
2175
2373
  }
2176
2374
  await this.o.store.save(channelId, mgr.exportState());
2375
+ return "committed";
2177
2376
  }
2178
2377
  /**
2179
2378
  * Committer-side: REMOVE a member from a live group (Phase C eviction).
@@ -2271,9 +2470,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2271
2470
  * should delegate to it.
2272
2471
  *
2273
2472
  * Returns null rather than throwing so every caller must decide explicitly what
2274
- * an unknown own-leaf means. The one caller today FAILS CLOSED on null by
2275
- * falling back to the positional path only for FOREIGN identities — it never
2276
- * uses null to justify guessing among an own identity's leaves.
2473
+ * an unknown own-leaf means.
2474
+ *
2475
+ * ⚠️ The one caller today does NOT fail closed on null. In
2476
+ * {@link removeDeviceFromLiveGroupUnlocked} the branch is
2477
+ * `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
2478
+ * identities at all, so a null return sends an OWN-identity removal down the
2479
+ * positional `leaves[pos]` path, which is exactly the add-order guess the
2480
+ * self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
2481
+ * permutes that mapping after any Remove). Null is therefore currently
2482
+ * indistinguishable from "foreign identity", and only the cardinality guard
2483
+ * stands between it and evicting the wrong device of the same human.
2484
+ * Correcting that is Phase 1b, not this comment.
2277
2485
  */
2278
2486
  ownLeafIndex(mgr) {
2279
2487
  const idx = mgr.state?.privatePath?.leafIndex;
@@ -2382,6 +2590,37 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2382
2590
  * - MORE THAN ONE leaf → ambiguous (no add-order roster to disambiguate which
2383
2591
  * leaf is the stale one) → REFUSE rather than risk evicting a healthy device.
2384
2592
  *
2593
+ * ⛔⛔ LIMITATION — A MULTI-DEVICE MEMBER IS UNHEALABLE BY THIS METHOD, AND THE
2594
+ * FAILURE IS SILENT AND DESTRUCTIVE. READ THIS BEFORE CALLING IT.
2595
+ *
2596
+ * Leaf credentials encode the IDENTITY and not the device, so the leaf
2597
+ * resolution above is only exact when the identity's leaf set is unambiguous.
2598
+ * The `> 1` case refuses. The `=== 1` case DOES NOT, and it is the worse one:
2599
+ * heal device B of an identity whose device A holds the only leaf and A is
2600
+ * evicted while the call reports a clean heal. Every downstream check is
2601
+ * satisfied — the cardinality guard in `removeDeviceFromLiveGroupUnlocked`
2602
+ * compares this call site's single-element `orderedDeviceIds` against a
2603
+ * single-element leaf set, a coincidence of counts, and `leaves[0]` is A's.
2604
+ * `mls.heal-multidevice.test.ts` measures exactly that against real MLS.
2605
+ *
2606
+ * ⛔ NO GUARD IS AVAILABLE HERE, and this is a measured conclusion rather than
2607
+ * an unexplored one. The device-level predicate that would settle it (leaf
2608
+ * indices whose LeafNode signature key equals the fetched KeyPackage's — the
2609
+ * same evidence the Add chokepoint uses) was implemented and it refused EVERY
2610
+ * production heal: the repair contract requires the member to republish first,
2611
+ * `forceRepublishKeyPackage` mints a NEW SIGNING KEY, and a diverged device's
2612
+ * fresh KeyPackage therefore matches no leaf, including its own. A guard built
2613
+ * on it is a blanket disable, not a guard.
2614
+ *
2615
+ * ⚠️ CALLER CONTRACT UNTIL SLICE 5: do not drive this for a member you know to
2616
+ * hold more than one device. The caller is the only layer that has a roster,
2617
+ * and nothing in the tree binds a leaf to a device id, so the refusal has to
2618
+ * live above this function. connect-web's `foreign-identity-heal.ts` declines a
2619
+ * member whose identity holds two or more LEAVES; the residual it cannot see —
2620
+ * two DEVICES sharing one leaf — is the shape described above and is open.
2621
+ * It resolves when the leaf credential carries a device binding (slice 5),
2622
+ * which cannot be retrofitted without re-keying every live group.
2623
+ *
2385
2624
  * The Add fetches the device's freshly published KeyPackage from the backend,
2386
2625
  * exactly like a normal add — the CALLER is responsible for the member having
2387
2626
  * re-published one first (mirrors addMemberToLiveGroup's "no published
@@ -2398,7 +2637,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2398
2637
  const hex = kps[memberDeviceId];
2399
2638
  if (!hex)
2400
2639
  throw new Error(`healDivergedMember: no published KeyPackage for device ${memberDeviceId}`);
2401
- const cred = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex)).leafNode.credential;
2640
+ const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2641
+ const cred = pkg.leafNode.credential;
2402
2642
  if (cred.credentialType !== "basic") {
2403
2643
  throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
2404
2644
  }
@@ -2411,6 +2651,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2411
2651
  if (leaves.length > 1) {
2412
2652
  throw new Error(`healDivergedMember: identity owns ${leaves.length} leaves; ambiguous stale-leaf resolution (refusing to guess)`);
2413
2653
  }
2654
+ if (leaves.length === 1) {
2655
+ console.warn(`[heal] channel ${channelId}: about to Remove the single leaf held by identity ${identity} in order to re-Add device ${memberDeviceId}, WITHOUT being able to prove that leaf is that device's. Leaf credentials encode the identity, not the device. If this member holds more than one device, the leaf being evicted may belong to a SIBLING device that was never diverged, and this call will still report a clean heal. Multi-device members are unhealable until leaf\u2192device attribution exists.`);
2656
+ }
2414
2657
  let removeLanded = false;
2415
2658
  if (leaves.length === 1) {
2416
2659
  await this.removeDeviceFromLiveGroupUnlocked(channelId, memberDeviceId, { identity, orderedDeviceIds: [memberDeviceId] }, 0);
@@ -2421,13 +2664,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2421
2664
  throw new Error(`healDivergedMember: expected 0 leaves for the Add-only branch but found ${stillZero.length} (a leaf reappeared under the held lock \u2014 refusing to create a duplicate leaf)`);
2422
2665
  }
2423
2666
  }
2667
+ let outcome;
2424
2668
  try {
2425
- await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
2669
+ outcome = await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
2426
2670
  } catch (e) {
2427
2671
  if (removeLanded)
2428
2672
  throw new HealAddAfterRemoveError(e);
2429
2673
  throw e;
2430
2674
  }
2675
+ if (outcome !== "committed") {
2676
+ const err = new Error(`healDivergedMember: the re-Add of ${memberDeviceId} on channel ${channelId} committed nothing (outcome "${outcome}") \u2014 refusing to report a clean heal for a device that may hold no leaf at the live epoch.`);
2677
+ if (removeLanded)
2678
+ throw new HealAddAfterRemoveError(err);
2679
+ throw err;
2680
+ }
2431
2681
  }));
2432
2682
  }
2433
2683
  /** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
@@ -2534,14 +2784,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2534
2784
  throw new Error("resetSessionSupersede: call publishKeyPackage() first");
2535
2785
  const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
2536
2786
  const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
2537
- const mgr = new MLSGroupManager();
2787
+ const mgr = new MLSGroupManager2();
2538
2788
  await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
2539
2789
  const kps = await this.o.backend.fetchKeyPackages(others);
2540
2790
  const pkgs = others.map((d) => {
2541
2791
  const hex = kps[d];
2542
2792
  if (!hex)
2543
2793
  throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
2544
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2794
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2545
2795
  });
2546
2796
  const { commit, welcome } = await mgr.addMembers(pkgs);
2547
2797
  await this.o.backend.distribute(reinit.group_id, {
@@ -2590,18 +2840,55 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2590
2840
  }
2591
2841
  }
2592
2842
  /**
2593
- * Return the number of leaves the given identity holds in the live tree for
2594
- * `channelId`. Loads the manager from the store if not already cached.
2843
+ * Return the number of leaves the given identity holds in THIS DEVICE'S view
2844
+ * of the ratchet tree for `channelId`. Reads the cached manager, else loads
2845
+ * one from the store; it makes no backend call, so the answer is only as
2846
+ * current as the commits this device has already processed. A device that is
2847
+ * behind, forked, or holding a superseded group answers from that state, not
2848
+ * from the server's.
2849
+ *
2850
+ * ⚠️ THROWS (`no MLS state for channel …`) when there is no local state at
2851
+ * all — it does not report 0 for "we have not joined yet". Callers that may
2852
+ * run before {@link ensureSession} must handle the throw; see
2853
+ * {@link hasLeafForIdentity} for the non-throwing, cache-only variant.
2595
2854
  *
2596
2855
  * Primarily used by the gate (S20) to assert that a peer-committer 409-retry
2597
- * does NOT create a duplicate leaf for an already-added device. A return value
2598
- * of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
2599
- * duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
2856
+ * does NOT create a duplicate leaf for an already-added device. In THAT
2857
+ * scenario each identity owns exactly one device, so 1 is healthy and > 1
2858
+ * means the S3 guard failed to suppress the re-Add. That reading does NOT
2859
+ * generalise: leaf credentials encode the IDENTITY, not the device, so a
2860
+ * multi-device identity legitimately holds ONE LEAF PER DEVICE — the same
2861
+ * premise {@link removeDeviceFromLiveGroupUnlocked}'s
2862
+ * `leaves.length !== orderedDeviceIds.length` guard is built on. Read the
2863
+ * count against the identity's expected device count, not against 1.
2600
2864
  */
2601
2865
  async leafCountForIdentity(channelId, identityId) {
2602
2866
  const mgr = await this.manager(channelId);
2603
2867
  return mgr.leafIndicesForIdentity(identityId).length;
2604
2868
  }
2869
+ /**
2870
+ * Cache-only leaf count for `identityId` in `channelId`, or **null** when
2871
+ * there is no in-memory manager (a cold channel).
2872
+ *
2873
+ * The non-throwing sibling of {@link leafCountForIdentity}, with the same
2874
+ * cold-channel semantics as {@link hasLeafForIdentity}. It exists because the
2875
+ * join confirmer needs to know whether a leaf can be ATTRIBUTED to a device,
2876
+ * and a boolean cannot answer that: leaf credentials encode the identity, not
2877
+ * the device, so "the identity holds a leaf" is true off ANY of its devices'
2878
+ * leaves. `null` and any count other than 1 both mean "cannot attribute", and
2879
+ * a caller that clears state on attribution must treat them as such.
2880
+ *
2881
+ * ⚠️ This is still an IDENTITY-level read. It does not name which device
2882
+ * holds which leaf and it cannot until leaves carry a device binding
2883
+ * (ADR-0014 Decision B, slice 5). What it buys today is the ability to REFUSE
2884
+ * to attribute rather than to guess.
2885
+ */
2886
+ leafCountForIdentityCached(channelId, identityId) {
2887
+ const mgr = this.managers.get(channelId);
2888
+ if (!mgr)
2889
+ return null;
2890
+ return mgr.leafIndicesForIdentity(identityId).length;
2891
+ }
2605
2892
  /** Returns true when the live in-memory MLS tree for `channelId` contains at
2606
2893
  * least one leaf bearing `identityId` as its credential. Used by the agent
2607
2894
  * admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
@@ -2719,7 +3006,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2719
3006
  return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
2720
3007
  }
2721
3008
  return await this.locks.run(channelId, async () => {
2722
- const mgr = new MLSGroupManager();
3009
+ const mgr = new MLSGroupManager2();
2723
3010
  try {
2724
3011
  await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
2725
3012
  } catch (err) {
@@ -2757,16 +3044,45 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2757
3044
  }
2758
3045
  async receiveOnce(deviceId) {
2759
3046
  const rows = await this.o.backend.pullDelivery(deviceId);
2760
- if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
2761
- 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(", "));
2762
- }
2763
3047
  const appEntries = [];
2764
3048
  const handshakeAcked = [];
2765
3049
  const supersededChannelIds = [];
2766
- const order = { welcome: 0, commit: 1, application: 2 };
2767
- rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
2768
- for (const row of rows) {
3050
+ const welcomes = rows.filter((r) => r.message_type === "welcome");
3051
+ const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
3052
+ const ordered = [...welcomes, ...nonWelcomes];
3053
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
3054
+ 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(", "));
3055
+ }
3056
+ const classCtx = /* @__PURE__ */ new Map();
3057
+ const classCtxFor = async (cid) => {
3058
+ let ctx = classCtx.get(cid);
3059
+ if (!ctx) {
3060
+ let heldGroupId = null;
3061
+ let snapshotEpoch;
3062
+ let retired = null;
3063
+ try {
3064
+ const mgr = await this.manager(cid);
3065
+ heldGroupId = new TextDecoder().decode(mgr.groupId);
3066
+ snapshotEpoch = Number(mgr.epoch);
3067
+ } catch {
3068
+ heldGroupId = null;
3069
+ snapshotEpoch = void 0;
3070
+ }
3071
+ try {
3072
+ retired = await this.o.store.loadRetiredGroupIds(cid);
3073
+ } catch {
3074
+ retired = null;
3075
+ }
3076
+ ctx = { heldGroupId, retired, snapshotEpoch, liveCommitEpoch: snapshotEpoch };
3077
+ classCtx.set(cid, ctx);
3078
+ }
3079
+ return ctx;
3080
+ };
3081
+ for (const row of ordered) {
2769
3082
  try {
3083
+ if (row.message_type !== "welcome" && row.connect_channel_id) {
3084
+ await classCtxFor(row.connect_channel_id);
3085
+ }
2770
3086
  if (row.message_type === "welcome") {
2771
3087
  const wcid = row.connect_channel_id;
2772
3088
  if (wcid) {
@@ -2803,15 +3119,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2803
3119
  const cid = row.connect_channel_id;
2804
3120
  if (!cid)
2805
3121
  continue;
3122
+ let advancedEpoch;
2806
3123
  await this.locks.run(cid, async () => {
2807
3124
  const mgr = await this.manager(cid);
2808
3125
  await mgr.decrypt(hexToBytes(row.payload));
2809
3126
  await this.o.store.save(cid, mgr.exportState());
3127
+ advancedEpoch = Number(mgr.epoch);
2810
3128
  });
2811
3129
  this.recordDecryptSuccess(cid);
3130
+ if (advancedEpoch !== void 0) {
3131
+ const ctx = classCtx.get(cid);
3132
+ if (ctx)
3133
+ ctx.liveCommitEpoch = advancedEpoch;
3134
+ }
2812
3135
  handshakeAcked.push(row.queue_id);
2813
3136
  } else {
2814
3137
  const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
3138
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
3139
+ throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
3140
+ }
2815
3141
  const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
2816
3142
  const aad = await buildAssociatedData({
2817
3143
  message_id: envelope.message_id,
@@ -2839,18 +3165,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2839
3165
  this.recordDecryptError(row, err);
2840
3166
  const cid = row.connect_channel_id;
2841
3167
  let drained = false;
2842
- if (cid && typeof row.epoch === "number") {
2843
- let liveEpoch;
2844
- try {
2845
- liveEpoch = await this.currentEpoch(cid);
2846
- } catch {
2847
- liveEpoch = void 0;
2848
- }
2849
- const permanentlyDead = liveEpoch !== void 0 && (row.message_type === "commit" ? row.epoch <= liveEpoch : row.message_type === "application" ? row.epoch < liveEpoch : false);
2850
- if (permanentlyDead) {
3168
+ if (cid && row.message_type !== "welcome") {
3169
+ const ctx = classCtx.get(cid);
3170
+ const verdict = classifyFailedRow({
3171
+ messageType: row.message_type,
3172
+ rowGroupId: row.group_id,
3173
+ rowEpoch: typeof row.epoch === "number" ? row.epoch : void 0,
3174
+ heldGroupId: ctx?.heldGroupId ?? null,
3175
+ retiredGroupIds: ctx?.retired ?? null,
3176
+ snapshotEpoch: ctx?.snapshotEpoch,
3177
+ liveCommitEpoch: ctx?.liveCommitEpoch
3178
+ });
3179
+ if (verdict === "permanentlySkipped") {
2851
3180
  handshakeAcked.push(row.queue_id);
2852
3181
  this.permanentlySkipped++;
2853
3182
  drained = true;
3183
+ } else if (verdict === "retiredDrained") {
3184
+ handshakeAcked.push(row.queue_id);
3185
+ this.retiredDrained++;
3186
+ drained = true;
2854
3187
  }
2855
3188
  }
2856
3189
  if (!drained && cid) {
@@ -2858,8 +3191,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2858
3191
  if (e) {
2859
3192
  e.wedgedRun += 1;
2860
3193
  if (e.wedgedRun >= this.quarantineThreshold) {
2861
- handshakeAcked.push(row.queue_id);
2862
- this.quarantineDrained++;
3194
+ if (row.message_type !== "welcome") {
3195
+ handshakeAcked.push(row.queue_id);
3196
+ this.quarantineDrained++;
3197
+ }
2863
3198
  this.quarantineChannel(cid);
2864
3199
  }
2865
3200
  }
@@ -3415,6 +3750,24 @@ var RestTransport = class {
3415
3750
  };
3416
3751
 
3417
3752
  // ../connect-sdk-ts/dist/mls/rest-backend.js
3753
+ function describeType(v) {
3754
+ if (v === void 0)
3755
+ return "missing";
3756
+ if (v === null)
3757
+ return "null";
3758
+ if (Array.isArray(v))
3759
+ return "array";
3760
+ return typeof v;
3761
+ }
3762
+ function parseGroupRoster(endpoint, channelId, value) {
3763
+ if (!Array.isArray(value)) {
3764
+ throw new MalformedGroupRosterError(endpoint, channelId, describeType(value));
3765
+ }
3766
+ if (value.length === 0) {
3767
+ throw new MalformedGroupRosterError(endpoint, channelId, "empty array");
3768
+ }
3769
+ return value.map(String);
3770
+ }
3418
3771
  var RestMlsBackend = class {
3419
3772
  opts;
3420
3773
  f;
@@ -3490,7 +3843,10 @@ var RestMlsBackend = class {
3490
3843
  return {
3491
3844
  group_id: String(j.group_id),
3492
3845
  current_epoch: Number(j.current_epoch),
3493
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3846
+ // #230: fail loud on an absent / non-array / empty roster rather than
3847
+ // coerce to `[]` (which pre-empts the consumers' `?? [self]` fallback and
3848
+ // gets POSTed back as a wipe on distribute).
3849
+ member_device_ids: parseGroupRoster("init", channelId, j.member_device_ids),
3494
3850
  // Back-compat: servers that predate the created field return nothing;
3495
3851
  // treat absence as true (they always created a fresh group).
3496
3852
  created: j.created === void 0 ? true : Boolean(j.created)
@@ -3515,10 +3871,14 @@ var RestMlsBackend = class {
3515
3871
  return {
3516
3872
  group_id: String(j.group_id),
3517
3873
  current_epoch: Number(j.current_epoch),
3518
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3874
+ // #230: same guard as init. This field has ZERO consumers today
3875
+ // (resetSessionSupersede reads only stale_member_device_ids), so this is
3876
+ // defense in depth: a future consumer cannot inherit the silent-`[]` trap.
3877
+ member_device_ids: parseGroupRoster("reinit", channelId, j.member_device_ids),
3519
3878
  // Absent on a backend that predates the dropped-member report; an empty
3520
3879
  // list is the correct read of "the server told us nothing", since the old
3521
- // server also never dropped anyone for an expired KeyPackage.
3880
+ // server also never dropped anyone for an expired KeyPackage. UNCHANGED:
3881
+ // empty here is legitimate, the opposite of member_device_ids (§3.3).
3522
3882
  stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
3523
3883
  };
3524
3884
  }
@@ -3534,6 +3894,22 @@ var RestMlsBackend = class {
3534
3894
  return null;
3535
3895
  }
3536
3896
  }
3897
+ async fetchKeyPackageExpiry(kpId) {
3898
+ try {
3899
+ const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}/expiry`);
3900
+ const raw = j.expires_at;
3901
+ if (typeof raw !== "string")
3902
+ return null;
3903
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/.test(raw))
3904
+ return null;
3905
+ const ms = Date.parse(raw);
3906
+ if (Number.isNaN(ms))
3907
+ return null;
3908
+ return new Date(ms);
3909
+ } catch {
3910
+ return null;
3911
+ }
3912
+ }
3537
3913
  async distribute(groupId, req) {
3538
3914
  await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
3539
3915
  ...req,
@@ -3558,10 +3934,14 @@ var RestMlsBackend = class {
3558
3934
  }
3559
3935
  async getCommitterWork(channelId) {
3560
3936
  const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
3561
- return {
3562
- adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
3563
- removes: Array.isArray(j.removes) ? j.removes.map(String) : []
3937
+ const requireList = (field) => {
3938
+ const v = j?.[field];
3939
+ if (!Array.isArray(v)) {
3940
+ throw new MalformedCommitterWorkError(channelId, field, describeType(v));
3941
+ }
3942
+ return v.map(String);
3564
3943
  };
3944
+ return { adds: requireList("adds"), removes: requireList("removes") };
3565
3945
  }
3566
3946
  async acquireCommitLease(channelId) {
3567
3947
  const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
@@ -3592,6 +3972,390 @@ var RestMlsBackend = class {
3592
3972
 
3593
3973
  // ../connect-sdk-ts/dist/mls/fake-backend.js
3594
3974
  import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
3975
+ var FakeConnectBackend = class _FakeConnectBackend {
3976
+ // Key-package store: deviceId → hex-encoded KeyPackage
3977
+ kps = /* @__PURE__ */ new Map();
3978
+ // Server-assigned KeyPackage ids: deviceId → id (last publish wins, mirroring
3979
+ // the real backend's single-row-per-device semantics).
3980
+ kpIds = /* @__PURE__ */ new Map();
3981
+ _kpSeq = 0;
3982
+ /** Server TTL stamps: kp_id → expiry epoch-ms. Keyed by ID, not by device, so a
3983
+ * SUPERSEDED id has no entry at all — mirroring `publish_key_package`, whose
3984
+ * first statement DELETEs every prior row for the device
3985
+ * (`mls_key_package_service.py:26-28`), which makes a superseded id 404. */
3986
+ kpExpiry = /* @__PURE__ */ new Map();
3987
+ /** Injectable clock, so a test can place a row inside or just outside the guard's
3988
+ * skew grace. The fake's stamp, its `fetchKeyPackages` filter, and the provider's
3989
+ * comparison must all read ONE clock or the grace is not expressible. */
3990
+ now = () => Date.now();
3991
+ /** The real server's default KeyPackage TTL. */
3992
+ static TTL_MS = 7 * 24 * 60 * 60 * 1e3;
3993
+ // Per-device delivery queues
3994
+ queues = /* @__PURE__ */ new Map();
3995
+ // Per-channel state (the real backend scopes membership + epoch per group;
3996
+ // keying these by channel keeps a multi-channel test from cross-contaminating).
3997
+ membersByChannel = /* @__PURE__ */ new Map();
3998
+ epochByChannel = /* @__PURE__ */ new Map();
3999
+ groupIdByChannel = /* @__PURE__ */ new Map();
4000
+ channelByGroup = /* @__PURE__ */ new Map();
4001
+ // identity_id → device_id, recorded at registerDevice so ingestApplication can
4002
+ // exclude the sender's own device on fanout (mirrors sendApplication's
4003
+ // senderDeviceId exclusion when only the envelope's sender_id is known).
4004
+ deviceByIdentity = /* @__PURE__ */ new Map();
4005
+ // Owner-attested reset: settable attestation map keyed by NEW groupId, read by
4006
+ // fetchResetAttestation (agent verify path) + written by resetReinitConnectChannelGroup.
4007
+ resetAttestations = /* @__PURE__ */ new Map();
4008
+ // Optional signer id the owner-side resetReinit records on the attestation it
4009
+ // stores (Task 9). Undefined in Task-6 tests, which set resetAttestations directly.
4010
+ ownerIdentityId;
4011
+ // Monotonic counter for generating unique IDs
4012
+ /** Ported-from-gen2 replay support: every distributed commit, in order, so
4013
+ * syncGroup() can serve a real catch-up instead of a stub. */
4014
+ commitLog = /* @__PURE__ */ new Map();
4015
+ /** Gap beyond which syncGroup answers "rejoin" (mirrors REJOIN_THRESHOLD). */
4016
+ rejoinThreshold = 100;
4017
+ _seq = 0;
4018
+ // Dedicated monotonic counter for reinit group ids (see reinitConnectChannelGroup).
4019
+ _reinitSeq = 0;
4020
+ /** Returns (and lazily creates) the delivery queue for a device. */
4021
+ q(deviceId) {
4022
+ let queue = this.queues.get(deviceId);
4023
+ if (!queue) {
4024
+ queue = [];
4025
+ this.queues.set(deviceId, queue);
4026
+ }
4027
+ return queue;
4028
+ }
4029
+ // ── MlsBackend implementation ──────────────────────────────────────────────
4030
+ /** Test stub: a FIXED 32-byte challenge hex. The real backend mints a fresh
4031
+ * single-use nonce; tests that exercise the PoP path sign over this constant
4032
+ * and re-derive the same message to verify. Deterministic so a register spy
4033
+ * can reconstruct and check the proof. */
4034
+ async getRegisterChallenge() {
4035
+ return "ab".repeat(32);
4036
+ }
4037
+ async registerDevice(req) {
4038
+ const deviceId = "fake-device-" + this._seq++;
4039
+ if (req.identity_id)
4040
+ this.deviceByIdentity.set(req.identity_id, deviceId);
4041
+ return { device_id: deviceId };
4042
+ }
4043
+ /** Test seam: record an identity→device link for a provider constructed with a
4044
+ * fixed deviceId (the SDK e2e wires providers directly, bypassing registerDevice).
4045
+ * The real backend resolves removed_identity_id → device(s) from the devices
4046
+ * table FK; this mirrors that resolution so op==="remove" can subtract the
4047
+ * removed identity's device(s) from the roster + delivery, exactly like the
4048
+ * server's purge_device_queue. */
4049
+ linkIdentityDevice(identityId, deviceId) {
4050
+ this.deviceByIdentity.set(identityId, deviceId);
4051
+ }
4052
+ async publishKeyPackage(deviceId, keyPackageHex) {
4053
+ this.kps.set(deviceId, keyPackageHex);
4054
+ const prior = this.kpIds.get(deviceId);
4055
+ if (prior)
4056
+ this.kpExpiry.delete(prior);
4057
+ const id = `kp_${deviceId}_${this._kpSeq = (this._kpSeq ?? 0) + 1}`;
4058
+ this.kpIds.set(deviceId, id);
4059
+ this.kpExpiry.set(id, this.now() + _FakeConnectBackend.TTL_MS);
4060
+ return id;
4061
+ }
4062
+ /** Test seam: move a recorded expiry to an absolute epoch-ms instant. Named
4063
+ * rather than letting a test write `kpExpiry` directly, so the id-keyed shape
4064
+ * stays an implementation detail. A hard-coded past value would make T2
4065
+ * (the fresh-KeyPackage negative control) unbuildable. */
4066
+ setKeyPackageExpiry(kpId, expiresAtMs) {
4067
+ this.kpExpiry.set(kpId, expiresAtMs);
4068
+ }
4069
+ async fetchKeyPackageExpiry(kpId) {
4070
+ const ms = this.kpExpiry.get(kpId);
4071
+ return ms === void 0 ? null : new Date(ms);
4072
+ }
4073
+ async getConnectChannelGroup(channelId) {
4074
+ const groupId = this.groupIdByChannel.get(channelId) ?? null;
4075
+ if (!groupId)
4076
+ return { exists: false, group_id: null, current_epoch: null };
4077
+ return {
4078
+ exists: true,
4079
+ group_id: groupId,
4080
+ current_epoch: this.epochByChannel.get(channelId) ?? 0
4081
+ };
4082
+ }
4083
+ async initConnectChannelGroup(channelId, memberDeviceIds) {
4084
+ const alreadyExists = this.groupIdByChannel.has(channelId);
4085
+ let groupId = this.groupIdByChannel.get(channelId);
4086
+ if (!groupId) {
4087
+ groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
4088
+ this.groupIdByChannel.set(channelId, groupId);
4089
+ this.channelByGroup.set(groupId, channelId);
4090
+ this.epochByChannel.set(channelId, 0);
4091
+ }
4092
+ const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
4093
+ this.membersByChannel.set(channelId, members);
4094
+ return {
4095
+ group_id: groupId,
4096
+ current_epoch: this.epochByChannel.get(channelId) ?? 0,
4097
+ member_device_ids: members,
4098
+ // Real semantics: created=true on first call (group was just created),
4099
+ // created=false on idempotent return (group already existed). Without
4100
+ // this, race tests silently pass wrong (fake is laxer than the server).
4101
+ created: !alreadyExists
4102
+ };
4103
+ }
4104
+ async reinitConnectChannelGroup(channelId, memberDeviceIds) {
4105
+ const groupId = `00000000-0000-0000-0000-${String(1e3 + (this._reinitSeq = (this._reinitSeq ?? 0) + 1)).padStart(12, "0")}`;
4106
+ this.groupIdByChannel.set(channelId, groupId);
4107
+ this.channelByGroup.set(groupId, channelId);
4108
+ this.epochByChannel.set(channelId, 0);
4109
+ const members = [...new Set(memberDeviceIds)];
4110
+ this.membersByChannel.set(channelId, members);
4111
+ return { group_id: groupId, current_epoch: 0, member_device_ids: members };
4112
+ }
4113
+ async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
4114
+ this.groupIdByChannel.set(channelId, att.newGroupId);
4115
+ this.channelByGroup.set(att.newGroupId, channelId);
4116
+ this.epochByChannel.set(channelId, 0);
4117
+ const members = [...new Set(memberDeviceIds)];
4118
+ this.membersByChannel.set(channelId, members);
4119
+ this.resetAttestations.set(att.newGroupId, {
4120
+ signedContent: new TextDecoder().decode(hexToBytes2(att.signedContentHex)),
4121
+ signatureHex: att.signatureHex,
4122
+ signerIdentityId: this.ownerIdentityId ?? "owner"
4123
+ });
4124
+ return {
4125
+ group_id: att.newGroupId,
4126
+ current_epoch: 0,
4127
+ member_device_ids: members,
4128
+ // The fake honours the roster verbatim, so nothing is ever dropped here.
4129
+ // Tests that need the dropped-member path stub this method directly.
4130
+ stale_member_device_ids: []
4131
+ };
4132
+ }
4133
+ async fetchKeyPackages(deviceIds) {
4134
+ const out = {};
4135
+ for (const id of deviceIds) {
4136
+ const kpId = this.kpIds.get(id);
4137
+ const exp = kpId === void 0 ? void 0 : this.kpExpiry.get(kpId);
4138
+ if (exp !== void 0 && exp <= this.now())
4139
+ continue;
4140
+ out[id] = this.kps.get(id);
4141
+ }
4142
+ return out;
4143
+ }
4144
+ async fetchKeyPackageById(kpId) {
4145
+ for (const [deviceId, id] of this.kpIds) {
4146
+ if (id === kpId) {
4147
+ return this.kps.get(deviceId) ?? null;
4148
+ }
4149
+ }
4150
+ return null;
4151
+ }
4152
+ async syncGroup(groupId, lastEpoch, _deviceId) {
4153
+ const channelId = this.channelByGroup.get(groupId);
4154
+ const current = channelId ? this.epochByChannel.get(channelId) ?? 0 : 0;
4155
+ if (current - lastEpoch > this.rejoinThreshold)
4156
+ return { action: "rejoin", messages: [] };
4157
+ const log = this.commitLog.get(groupId) ?? [];
4158
+ return {
4159
+ action: "replay",
4160
+ messages: log.filter((c) => c.epoch > lastEpoch).sort((a, b) => a.epoch - b.epoch).map((c) => ({
4161
+ id: c.id,
4162
+ epoch: c.epoch,
4163
+ message_type: "commit",
4164
+ sender_device_id: c.sender_device_id,
4165
+ payload: c.payload
4166
+ }))
4167
+ };
4168
+ }
4169
+ async distribute(groupId, req) {
4170
+ const channelId = this.channelByGroup.get(groupId);
4171
+ if (!channelId)
4172
+ throw new Error(`FakeConnectBackend.distribute: unknown group ${groupId}`);
4173
+ if (req.commit && req.expected_epoch != null) {
4174
+ const current = this.epochByChannel.get(channelId) ?? 0;
4175
+ if (req.expected_epoch !== current) {
4176
+ const e = new Error(`connect backend post failed: 409 EPOCH_CONFLICT (expected ${req.expected_epoch}, current ${current})`);
4177
+ e.status = 409;
4178
+ throw e;
4179
+ }
4180
+ }
4181
+ if (req.commit) {
4182
+ const log = this.commitLog.get(groupId) ?? [];
4183
+ log.push({
4184
+ id: `c${++this._seq}`,
4185
+ epoch: Number(req.commit_epoch ?? 0),
4186
+ payload: String(req.commit),
4187
+ sender_device_id: String(req.device_id ?? "")
4188
+ });
4189
+ this.commitLog.set(groupId, log);
4190
+ }
4191
+ const priorMembers = this.membersByChannel.get(channelId) ?? [];
4192
+ this.epochByChannel.set(channelId, req.commit_epoch ?? this.epochByChannel.get(channelId) ?? 0);
4193
+ let evictedDevice;
4194
+ if (req.op === "remove") {
4195
+ if (!req.removed_identity_id) {
4196
+ throw new Error("removed_identity_id required");
4197
+ }
4198
+ evictedDevice = req.removed_device_id ?? (req.removed_identity_id ? this.deviceByIdentity.get(req.removed_identity_id) : void 0);
4199
+ const remaining = (this.membersByChannel.get(channelId) ?? []).filter((d) => d !== evictedDevice);
4200
+ this.membersByChannel.set(channelId, remaining);
4201
+ if (evictedDevice)
4202
+ this.queues.delete(evictedDevice);
4203
+ } else {
4204
+ const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
4205
+ this.membersByChannel.set(channelId, members);
4206
+ }
4207
+ if (req.commit) {
4208
+ for (const dev of priorMembers) {
4209
+ if (dev === req.device_id)
4210
+ continue;
4211
+ if (dev === evictedDevice)
4212
+ continue;
4213
+ this.q(dev).push({
4214
+ queue_id: "q-" + this._seq++,
4215
+ message_id: "m-commit-" + this._seq++,
4216
+ group_id: groupId,
4217
+ message_type: "commit",
4218
+ sender_device_id: req.device_id,
4219
+ epoch: this.epochByChannel.get(channelId) ?? 0,
4220
+ payload: req.commit,
4221
+ connect_channel_id: channelId,
4222
+ created_at: ""
4223
+ });
4224
+ }
4225
+ }
4226
+ for (const w of req.welcomes ?? []) {
4227
+ this.q(w.target_device_id).push({
4228
+ queue_id: "q-" + this._seq++,
4229
+ message_id: "m-welcome-" + this._seq++,
4230
+ group_id: groupId,
4231
+ message_type: "welcome",
4232
+ sender_device_id: req.device_id,
4233
+ epoch: this.epochByChannel.get(channelId) ?? 0,
4234
+ payload: w.payload,
4235
+ connect_channel_id: channelId,
4236
+ created_at: ""
4237
+ });
4238
+ }
4239
+ }
4240
+ async pullDelivery(deviceId) {
4241
+ return this.queues.get(deviceId) ?? [];
4242
+ }
4243
+ async ackDelivery(deviceId, queueIds) {
4244
+ const queue = this.queues.get(deviceId);
4245
+ if (queue) {
4246
+ this.queues.set(deviceId, queue.filter((r) => !queueIds.includes(r.queue_id)));
4247
+ }
4248
+ }
4249
+ requestWelcomeCalls = [];
4250
+ async requestWelcome(groupId, deviceId, freshKpId) {
4251
+ this.requestWelcomeCalls.push({ groupId, deviceId, freshKpId });
4252
+ }
4253
+ // ADR 0004 C1: committer-work + lease stubs (in-memory for unit tests;
4254
+ // no sweep driver runs against the fake backend, so these are no-ops).
4255
+ async getCommitterWork(_channelId) {
4256
+ return { adds: [], removes: [] };
4257
+ }
4258
+ async acquireCommitLease(_channelId) {
4259
+ return "fake-lease-token";
4260
+ }
4261
+ async releaseCommitLease(_channelId, _token) {
4262
+ }
4263
+ async fetchResetAttestation(groupId) {
4264
+ return this.resetAttestations.get(groupId) ?? null;
4265
+ }
4266
+ // ── E2E helper: seal + fanout (mirrors the Python edge's Task-7 path) ──────
4267
+ async sendApplication(p) {
4268
+ const key = await p.sender.exporterKey(p.enclaveId, p.channelId, p.epoch);
4269
+ const messageId = "m-" + this._seq++;
4270
+ const replayNonce = bytesToBase64(crypto.getRandomValues(new Uint8Array(24)));
4271
+ const policy_binding = {
4272
+ binding_id: "b",
4273
+ version: "1",
4274
+ agent_skill_id: null,
4275
+ delegation_id: null,
4276
+ client_stage5_outcome: "allow",
4277
+ client_stage5_evaluators: []
4278
+ };
4279
+ const aad = await buildAssociatedData({
4280
+ message_id: messageId,
4281
+ channel_id: p.channelId,
4282
+ sender_id: p.senderId,
4283
+ mls_group_epoch: p.epoch,
4284
+ replay_nonce: replayNonce,
4285
+ policy_binding
4286
+ });
4287
+ const sealed = sealPayload({
4288
+ plaintext: new TextEncoder().encode(p.plaintext),
4289
+ key,
4290
+ associatedData: aad,
4291
+ suite: "xchacha20poly1305"
4292
+ });
4293
+ const envelope = {
4294
+ av_version: "1.0",
4295
+ message_id: messageId,
4296
+ channel_id: p.channelId,
4297
+ enclave_id: p.enclaveId,
4298
+ message_type: "text",
4299
+ sender_id: p.senderId,
4300
+ sender_type: "agent",
4301
+ identity_proof: { signature: "", algo: "ed25519", attested: false, canonicalization: "RFC8785" },
4302
+ trace_context: { traceparent: "" },
4303
+ policy_binding,
4304
+ mls_group_epoch: p.epoch,
4305
+ replay_nonce: replayNonce,
4306
+ payload_encrypted: bytesToBase64(sealed),
4307
+ references: []
4308
+ };
4309
+ const canonical = await canonicalEnvelopeBytes(envelope);
4310
+ const members = this.membersByChannel.get(p.channelId) ?? [];
4311
+ const groupId = this.groupIdByChannel.get(p.channelId) ?? "";
4312
+ for (const dev of members) {
4313
+ if (dev === p.senderDeviceId)
4314
+ continue;
4315
+ this.q(dev).push({
4316
+ queue_id: "q-" + this._seq++,
4317
+ message_id: messageId,
4318
+ group_id: groupId,
4319
+ message_type: "application",
4320
+ sender_device_id: p.senderDeviceId,
4321
+ epoch: p.epoch,
4322
+ payload: bytesToHex3(canonical),
4323
+ connect_channel_id: p.channelId,
4324
+ created_at: ""
4325
+ });
4326
+ }
4327
+ }
4328
+ // ── E2E helper: ingest an already-built envelope (client-side build/seal) ──
4329
+ //
4330
+ // Mirrors sendApplication's storage + fanout, but takes an AgentVaultEnvelope
4331
+ // the *client* already built, sealed, and signed (via buildOutboundEnvelope) —
4332
+ // exactly what the owner client hands to its injected `publish`. The server
4333
+ // edge stores the canonical envelope bytes (hex) and fans out one delivery row
4334
+ // per active member device except the sender's. We resolve the sender's device
4335
+ // from sender_id (recorded at registerDevice); if unknown, every member gets it.
4336
+ async ingestApplication(envelope) {
4337
+ const channelId = envelope.channel_id;
4338
+ const canonical = await canonicalEnvelopeBytes(envelope);
4339
+ const members = this.membersByChannel.get(channelId) ?? [];
4340
+ const groupId = this.groupIdByChannel.get(channelId) ?? "";
4341
+ const senderDeviceId = this.deviceByIdentity.get(envelope.sender_id);
4342
+ for (const dev of members) {
4343
+ if (dev === senderDeviceId)
4344
+ continue;
4345
+ this.q(dev).push({
4346
+ queue_id: "q-" + this._seq++,
4347
+ message_id: envelope.message_id,
4348
+ group_id: groupId,
4349
+ message_type: "application",
4350
+ sender_device_id: senderDeviceId ?? "",
4351
+ epoch: envelope.mls_group_epoch,
4352
+ payload: bytesToHex3(canonical),
4353
+ connect_channel_id: channelId,
4354
+ created_at: ""
4355
+ });
4356
+ }
4357
+ }
4358
+ };
3595
4359
 
3596
4360
  // ../connect-sdk-ts/dist/receive/inbox-cipher.js
3597
4361
  import { hkdf } from "@noble/hashes/hkdf";
@@ -3641,7 +4405,7 @@ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
3641
4405
 
3642
4406
  // ../connect-mls-store/dist/sqlite-store.js
3643
4407
  import { DatabaseSync } from "node:sqlite";
3644
- import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
4408
+ import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
3645
4409
 
3646
4410
  // ../connect-mls-store/dist/schema.js
3647
4411
  var SCHEMA_VERSION = 6;
@@ -3757,7 +4521,7 @@ var MlsStoreSealError = class extends Error {
3757
4521
  function encodeBundle(bundle) {
3758
4522
  const pp = bundle.privatePackage;
3759
4523
  const obj = {
3760
- pub: bytesToHex4(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
4524
+ pub: bytesToHex4(MLSGroupManager3.serializeKeyPackage(bundle.publicPackage)),
3761
4525
  initPriv: bytesToHex4(pp.initPrivateKey),
3762
4526
  hpkePriv: bytesToHex4(pp.hpkePrivateKey),
3763
4527
  sigPriv: bytesToHex4(pp.signaturePrivateKey)
@@ -3767,7 +4531,7 @@ function encodeBundle(bundle) {
3767
4531
  function decodeBundle(bytes) {
3768
4532
  const obj = JSON.parse(new TextDecoder().decode(bytes));
3769
4533
  return {
3770
- publicPackage: MLSGroupManager2.deserializeKeyPackage(hexToBytes3(obj.pub)),
4534
+ publicPackage: MLSGroupManager3.deserializeKeyPackage(hexToBytes3(obj.pub)),
3771
4535
  privatePackage: {
3772
4536
  initPrivateKey: hexToBytes3(obj.initPriv),
3773
4537
  hpkePrivateKey: hexToBytes3(obj.hpkePriv),
@@ -4312,9 +5076,10 @@ function mimeForName(name) {
4312
5076
  return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
4313
5077
  }
4314
5078
  async function prepareAttachment(filePath, caption, channelId, deps) {
5079
+ await deps.ready();
4315
5080
  const bytes = await deps.readFile(filePath);
4316
5081
  const name = basename(filePath);
4317
- const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
5082
+ const { encryptedData, fileKey, fileNonce, digest } = await deps.encryptFile(bytes);
4318
5083
  const blobId = await deps.uploadBlob(encryptedData, channelId);
4319
5084
  const body = {
4320
5085
  v: 1,
@@ -4331,12 +5096,13 @@ async function prepareAttachment(filePath, caption, channelId, deps) {
4331
5096
  return body;
4332
5097
  }
4333
5098
  async function materializeAttachment(body, ctx, deps) {
5099
+ await deps.ready();
4334
5100
  const ciphertext = await deps.downloadBlob(body.blobId);
4335
- const sha2566 = deps.computeFileDigest(ciphertext);
5101
+ const sha2566 = await deps.computeFileDigest(ciphertext);
4336
5102
  if (sha2566 !== body.digest) {
4337
5103
  throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
4338
5104
  }
4339
- const plain = deps.decryptFile(
5105
+ const plain = await deps.decryptFile(
4340
5106
  ciphertext,
4341
5107
  deps.base64ToBytes(body.fileKey),
4342
5108
  deps.base64ToBytes(body.fileNonce)
@@ -4349,6 +5115,16 @@ async function materializeAttachment(body, ctx, deps) {
4349
5115
  return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2566 };
4350
5116
  }
4351
5117
 
5118
+ // src/sodium-ready.ts
5119
+ import { generateEphemeralKeypair } from "@agentvault/crypto";
5120
+ var warming = null;
5121
+ function ensureSodiumReady() {
5122
+ return warming ??= generateEphemeralKeypair().then(() => void 0).catch((e) => {
5123
+ warming = null;
5124
+ throw e;
5125
+ });
5126
+ }
5127
+
4352
5128
  // src/memory/hermes-session-map.ts
4353
5129
  import { readFileSync as readFileSync2 } from "node:fs";
4354
5130
  import { homedir } from "node:os";
@@ -5246,6 +6022,10 @@ var BridgeAgent = class _BridgeAgent {
5246
6022
  }
5247
6023
  async start() {
5248
6024
  this.startedAt = Date.now();
6025
+ try {
6026
+ await this.ingestOnce();
6027
+ } catch {
6028
+ }
5249
6029
  await this.provider.publishKeyPackage();
5250
6030
  this.startReceivePump();
5251
6031
  if (this.selfHealSweepEnabled) {
@@ -5449,6 +6229,50 @@ var BridgeAgent = class _BridgeAgent {
5449
6229
  * DEVICE never held a leaf can 403 on the signal call. That's harmless and
5450
6230
  * must not abort the sweep for every other channel.
5451
6231
  */
6232
+ /**
6233
+ * One best-effort device-level KeyPackage rotation attempt.
6234
+ *
6235
+ * `publishKeyPackage` is a cheap no-op skip unless the SERVER says the held
6236
+ * KeyPackage is expired, so calling it every cycle costs one read and rotates at
6237
+ * most once per TTL. The catch logs once and continues: it MUST NOT rethrow, MUST
6238
+ * NOT abort the sweep, and MUST NOT retry inside the cycle. A KeyPackage that
6239
+ * cannot be rotated this cycle is retried on the next interval, and the sweep's
6240
+ * per-channel repair work is independent of it.
6241
+ */
6242
+ /**
6243
+ * Tri-state: "is this device STILL serving a server-expired KeyPackage at the end
6244
+ * of the cycle that just tried to fix it?"
6245
+ *
6246
+ * `false` is the only healthy value, and it requires a POSITIVE, current proof of
6247
+ * non-expiry. `null` is unknown and reads exactly like `true` in `degraded`, so a
6248
+ * daemon that cannot prove itself un-expired stops reporting healthy. That is the
6249
+ * OPPOSITE direction to the rotation guard, which never rotates on unknown.
6250
+ * Conflating the two directions recreates the lie this change removes.
6251
+ *
6252
+ * Initialised `null` and written only by `selfHealSweep`, so a sweep-off daemon
6253
+ * reads `null` and therefore degraded.
6254
+ */
6255
+ keypackageExpired = null;
6256
+ /** Resolve the flag from the SAME two inputs the guard uses, with every unknown
6257
+ * mapped to `null`. Never throws: a failure to measure is itself unknown. */
6258
+ async resolveKeypackageExpired() {
6259
+ try {
6260
+ const kpId = await this.provider.getLastPublishedKpId();
6261
+ if (kpId === null || kpId === "") return null;
6262
+ const exp = await this.backend.fetchKeyPackageExpiry(kpId);
6263
+ if (exp === null) return null;
6264
+ return isServerExpired(exp, Date.now());
6265
+ } catch {
6266
+ return null;
6267
+ }
6268
+ }
6269
+ async rotateDeviceKeyPackage() {
6270
+ try {
6271
+ await this.provider.publishKeyPackage();
6272
+ } catch (e) {
6273
+ console.error("[bridge] selfHealSweep: device KeyPackage rotation failed (continuing):", e);
6274
+ }
6275
+ }
5452
6276
  async selfHealSweep() {
5453
6277
  if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
5454
6278
  this.providerCapabilityMissing = true;
@@ -5457,7 +6281,16 @@ var BridgeAgent = class _BridgeAgent {
5457
6281
  );
5458
6282
  return;
5459
6283
  }
6284
+ if (typeof this.backend?.fetchKeyPackageExpiry !== "function") {
6285
+ this.providerCapabilityMissing = true;
6286
+ console.error(
6287
+ "[bridge] selfHealSweep ABORTED: backend is missing fetchKeyPackageExpiry \u2014 the SDK dist is STALE (it predates KeyPackage expiry rotation). Rebuild the SDK dist THEN the bridge dist. KeyPackage rotation and healing are NOT running; observations are reported STALE."
6288
+ );
6289
+ return;
6290
+ }
5460
6291
  this.providerCapabilityMissing = false;
6292
+ await this.rotateDeviceKeyPackage();
6293
+ this.keypackageExpired = await this.resolveKeypackageExpired();
5461
6294
  const enclaves = await this.enclavesToCover();
5462
6295
  this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
5463
6296
  const seen = /* @__PURE__ */ new Set();
@@ -5705,12 +6538,24 @@ var BridgeAgent = class _BridgeAgent {
5705
6538
  }
5706
6539
  sendDeps() {
5707
6540
  return {
6541
+ ready: () => ensureSodiumReady(),
5708
6542
  uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
5709
6543
  encryptFile,
5710
6544
  bytesToBase64: bytesToBase642,
5711
6545
  readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
5712
6546
  };
5713
6547
  }
6548
+ receiveDeps() {
6549
+ return {
6550
+ ready: () => ensureSodiumReady(),
6551
+ downloadBlob: this.downloadBlobImpl,
6552
+ decryptFile,
6553
+ computeFileDigest,
6554
+ base64ToBytes: base64ToBytes2,
6555
+ mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
6556
+ writeFile: (p, data) => fsp.writeFile(p, data)
6557
+ };
6558
+ }
5714
6559
  async sendAttachment(channelId, filePath, caption) {
5715
6560
  this.enforceRateFloor("attachment", channelId);
5716
6561
  const joined = await this.reconcileJoin(channelId);
@@ -5766,8 +6611,11 @@ var BridgeAgent = class _BridgeAgent {
5766
6611
  /**
5767
6612
  * List channels in one enclave (default: the daemon's own home enclave).
5768
6613
  * Task 3: called per-*known* enclave by refreshChannelMeta so a
5769
- * cross-enclave room's is_dm gets resolved — see the scope caveat there
5770
- * (same-tenant only; a cross-tenant enclave 404s under the agent's bearer).
6614
+ * cross-enclave room's is_dm gets resolved. ⚠️ The old "same-tenant only; a
6615
+ * cross-tenant enclave 404s under the agent's bearer" caveat here is refuted
6616
+ * — see the CROSS-TENANT LISTING note on enclavesToCover. This route admits
6617
+ * any enclave the caller holds a live membership row in, partner tenants
6618
+ * included.
5771
6619
  */
5772
6620
  async listChannels(enclaveId = this.cfg.enclaveId) {
5773
6621
  const url = `${this.cfg.apiBase}/api/v1/enclaves/${enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
@@ -5822,12 +6670,16 @@ var BridgeAgent = class _BridgeAgent {
5822
6670
  * room is never listed under the home-enclave-only call, so its is_dm never
5823
6671
  * resolves and the @mention filter fails open for that room.
5824
6672
  *
5825
- * Each enclave is wrapped in its own try/catch (scope caveat — audit
5826
- * H2/tenant): list_channels_in_enclave filters on tenant_id, so a
5827
- * same-tenant enclave the agent has learned about resolves fine, but a
5828
- * cross-TENANT federated-partner enclave 404s under this agent's bearer;
5829
- * that enclave's channels are left unresolved (fail open) without aborting
5830
- * the other enclaves in the set.
6673
+ * Each enclave is wrapped in its own try/catch so one enclave's listing
6674
+ * failure leaves only that enclave's channels unresolved (fail open) without
6675
+ * aborting the other enclaves in the set.
6676
+ *
6677
+ * ⚠️ The old scope caveat here (audit H2/tenant) said
6678
+ * `list_channels_in_enclave` filters on tenant_id and 404s a cross-TENANT
6679
+ * federated-partner enclave. That is refuted — see the CROSS-TENANT LISTING
6680
+ * note on enclavesToCover. A partner enclave with a live membership row
6681
+ * resolves like any other, so the try/catch now covers transport errors,
6682
+ * 5xx, and genuine non-member 404s only.
5831
6683
  */
5832
6684
  async refreshChannelMeta() {
5833
6685
  const enclaves = await this.enclavesToCover();
@@ -5855,12 +6707,20 @@ var BridgeAgent = class _BridgeAgent {
5855
6707
  * failure right after a restart would read as "never succeeded" and slip
5856
6708
  * through the expected-gap branch — weaker than the old abort-on-failure).
5857
6709
  * A failure of a member of this set is a REGRESSION and degrades health; a
5858
- * failure of a never-succeeded enclave (partner: tenant-scoped 404, permanent
5859
- * pending backend authz) is an EXPECTED gap — reported, not degrading, or
5860
- * every federated agent would peg degraded forever. */
6710
+ * failure of a never-succeeded enclave is reported but does not degrade.
6711
+ *
6712
+ * ⚠️ The old justification for that asymmetry named a partner enclave's
6713
+ * "tenant-scoped 404, permanent pending backend authz" as the expected gap.
6714
+ * That premise is refuted — see the CROSS-TENANT LISTING note on
6715
+ * enclavesToCover; a partner enclave with a live membership row lists fine.
6716
+ * The asymmetry is kept on its remaining merit: an enclave this process has
6717
+ * never listed may simply be one this agent holds no live membership in, and
6718
+ * pegging health degraded forever on that is noise, not signal. */
5861
6719
  enclaveListEverSucceeded = /* @__PURE__ */ new Set();
5862
6720
  /** Warn-once-per-failure-streak, keyed by enclave id (cleared on success):
5863
- * a permanently-404ing partner enclave must not log 1440 lines a day. */
6721
+ * a persistently-failing enclave must not log 1440 lines a day. (The old
6722
+ * wording named "a permanently-404ing partner enclave" — refuted, see the
6723
+ * CROSS-TENANT LISTING note on enclavesToCover.) */
5864
6724
  enclaveListWarned = /* @__PURE__ */ new Set();
5865
6725
  static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
5866
6726
  enclaveEnumWarned = false;
@@ -5888,11 +6748,35 @@ var BridgeAgent = class _BridgeAgent {
5888
6748
  * membership changes are picked up within one TTL, which matches the sweep
5889
6749
  * cadence this exists to serve.
5890
6750
  *
5891
- * ⚠️ Residual gap, stated: the enumeration RETURNS cross-tenant partner
5892
- * enclaves, but `list_channels_in_enclave` is tenant-scoped and 404s them
5893
- * under this agent's bearer — so a partner enclave in this set still yields
5894
- * no channels. Callers must treat a per-enclave listing failure as skip-one,
5895
- * never abort-all. Closing that needs backend authz work, not daemon work.
6751
+ * ── CROSS-TENANT LISTING: the canonical statement for this file ──────────
6752
+ *
6753
+ * ⚠️ CORRECTED 2026-08-21 (#280 spec F6). This block, and five other comments
6754
+ * in this file, used to state that `list_channels_in_enclave` is tenant-
6755
+ * scoped and 404s a cross-tenant partner enclave under this agent's bearer,
6756
+ * leaving a partner enclave in this set with no channels. That has been FALSE
6757
+ * since #187 (`d7cc685a`, 2026-07-31). Verified by opening the code:
6758
+ *
6759
+ * - `list_channels_in_enclave` calls `_resolve_enclave_membership(...,
6760
+ * host_only=False)` (backend/app/api/v1/connect_channels.py:897-900).
6761
+ * - `host_only=False` OMITS the `ConnectEnclave.tenant_id == auth.tenant_id`
6762
+ * predicate and runs the membership query under `bypass_rls`, so the
6763
+ * host-owned enclave row is visible to a partner session (same file
6764
+ * :519-520, :531-543). The gate is membership, not tenancy.
6765
+ * - The channel query it then runs (same file :902-919) reads
6766
+ * `connect_channels` joined to `connect_channel_members`, and BOTH tables
6767
+ * are among the eight the schema deliberately leaves without RLS for
6768
+ * having no `tenant_id` column (alembic 0001_connect_initial_schema.py:
6769
+ * 750-755, :778-780). No policy filters those rows by tenant.
6770
+ *
6771
+ * So a partner-tenant agent holding a live `ConnectEnclaveMember` row
6772
+ * (`removed_at IS NULL`) gets 200 with that enclave's real channels. There is
6773
+ * no partner-enclave listing gap left to close, and no backend authz work
6774
+ * pending for it.
6775
+ *
6776
+ * What DOES still reach a per-enclave listing failure: a transport error, a
6777
+ * 5xx, or a genuine 404 for an enclave this agent holds no live membership
6778
+ * in. Those are real outages, so the skip-one, never-abort-all rule stands on
6779
+ * its own merit — it just no longer has a standing partner-enclave case.
5896
6780
  */
5897
6781
  async enclavesToCover() {
5898
6782
  const now = Date.now();
@@ -6348,14 +7232,7 @@ var BridgeAgent = class _BridgeAgent {
6348
7232
  const meta = await materializeAttachment(
6349
7233
  parsed,
6350
7234
  { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
6351
- {
6352
- downloadBlob: this.downloadBlobImpl,
6353
- decryptFile,
6354
- computeFileDigest,
6355
- base64ToBytes: base64ToBytes2,
6356
- mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
6357
- writeFile: (p, data) => fsp.writeFile(p, data)
6358
- }
7235
+ this.receiveDeps()
6359
7236
  );
6360
7237
  this.buffer.push({
6361
7238
  channel_id: m.channelId,
@@ -6538,8 +7415,10 @@ var BridgeAgent = class _BridgeAgent {
6538
7415
  sweep_enabled: this.selfHealSweepEnabled,
6539
7416
  observations_stale: observationsStale,
6540
7417
  diverged_channels: divergedChannels,
7418
+ keypackage_expired: this.keypackageExpired,
6541
7419
  // A2: the truthful roll-up gates read instead of `status` (D-3).
6542
- degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost,
7420
+ // `!== false` and not `=== true`: unknown must never read healthy.
7421
+ degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost || this.keypackageExpired !== false,
6543
7422
  channels: liveChannels,
6544
7423
  sweep_enclaves_failed: [...this.sweepEnclavesFailed],
6545
7424
  identity_id: this.cfg.identityId,
@@ -6549,6 +7428,8 @@ var BridgeAgent = class _BridgeAgent {
6549
7428
  filtered_room: this.filteredRoomMessages,
6550
7429
  rate_limited: this.rateLimitedSends,
6551
7430
  permanently_skipped: this.provider.permanentlySkippedCount(),
7431
+ quarantine_drained: this.provider.quarantineDrainedCount(),
7432
+ retired_drained: this.provider.retiredDrainedCount(),
6552
7433
  failed_attachments: this.failedAttachments,
6553
7434
  delivery_pings: this.pingsReceived,
6554
7435
  agent_sessions: this.authSessions,
@@ -6594,8 +7475,11 @@ var BridgeAgent = class _BridgeAgent {
6594
7475
  filteredRoomMessages: this.filteredRoomMessages,
6595
7476
  rateLimitedSends: this.rateLimitedSends,
6596
7477
  permanentlySkipped: this.provider.permanentlySkippedCount(),
7478
+ quarantineDrained: this.provider.quarantineDrainedCount(),
7479
+ retiredDrained: this.provider.retiredDrainedCount(),
6597
7480
  welcomeRequestsSent: this.welcomeRequestsSent,
6598
- failedAttachments: this.failedAttachments
7481
+ failedAttachments: this.failedAttachments,
7482
+ build: BRIDGE_VERSION
6599
7483
  };
6600
7484
  }
6601
7485
  async close() {