@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/index.js CHANGED
@@ -1113,6 +1113,28 @@ async function agentPausedErrorFromResponse(res) {
1113
1113
  const err = detail?.error;
1114
1114
  return err === "agent_paused" ? new AgentPausedError() : null;
1115
1115
  }
1116
+ var MalformedCommitterWorkError = class extends ConnectSdkError {
1117
+ channelId;
1118
+ field;
1119
+ receivedType;
1120
+ constructor(channelId, field, receivedType) {
1121
+ super(`committer-work for channel ${channelId} returned a malformed body: \`${field}\` is ${receivedType}, expected array`);
1122
+ this.channelId = channelId;
1123
+ this.field = field;
1124
+ this.receivedType = receivedType;
1125
+ }
1126
+ };
1127
+ var MalformedGroupRosterError = class extends ConnectSdkError {
1128
+ endpoint;
1129
+ channelId;
1130
+ receivedType;
1131
+ constructor(endpoint, channelId, receivedType) {
1132
+ super(`${endpoint} for channel ${channelId} returned a malformed group roster: \`member_device_ids\` is ${receivedType}, expected a non-empty array`);
1133
+ this.endpoint = endpoint;
1134
+ this.channelId = channelId;
1135
+ this.receivedType = receivedType;
1136
+ }
1137
+ };
1116
1138
 
1117
1139
  // ../connect-sdk-ts/dist/envelope-builder.js
1118
1140
  async function buildOutboundEnvelope(p) {
@@ -1221,8 +1243,23 @@ import { hmac } from "@noble/hashes/hmac";
1221
1243
  import { sha256 as sha2562 } from "@noble/hashes/sha256";
1222
1244
  import { bytesToHex } from "@agentvault/crypto";
1223
1245
 
1246
+ // ../connect-sdk-ts/dist/groupkey/crypto-patch-guard.js
1247
+ import { MLSGroupManager } from "@agentvault/crypto";
1248
+ var CryptoPatchMissingError = class extends Error {
1249
+ constructor() {
1250
+ 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.");
1251
+ this.name = "CryptoPatchMissingError";
1252
+ }
1253
+ };
1254
+ function leafIndicesForKeyPackage(mgr, kp) {
1255
+ if (typeof MLSGroupManager.prototype.leafIndicesForKeyPackage !== "function") {
1256
+ throw new CryptoPatchMissingError();
1257
+ }
1258
+ return mgr.leafIndicesForKeyPackage(kp);
1259
+ }
1260
+
1224
1261
  // ../connect-sdk-ts/dist/groupkey/mls.js
1225
- import { MLSGroupManager, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1262
+ import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1226
1263
 
1227
1264
  // ../connect-sdk-ts/dist/groupkey/operation-queue.js
1228
1265
  var MlsOperationQueue = class {
@@ -1268,6 +1305,69 @@ var MlsOperationQueue = class {
1268
1305
  }
1269
1306
  };
1270
1307
 
1308
+ // ../connect-sdk-ts/dist/groupkey/receive-classifier.js
1309
+ function classifyFailedRow(i) {
1310
+ if (i.messageType === "welcome")
1311
+ return "defer";
1312
+ if (i.messageType !== "commit" && i.messageType !== "application")
1313
+ return "defer";
1314
+ if (i.heldGroupId !== null && i.rowGroupId === i.heldGroupId) {
1315
+ const epochForVerdict = i.messageType === "commit" ? i.liveCommitEpoch : i.snapshotEpoch;
1316
+ if (epochForVerdict === void 0 || typeof i.rowEpoch !== "number")
1317
+ return "defer";
1318
+ const dead = i.messageType === "commit" ? i.rowEpoch <= epochForVerdict : i.rowEpoch < epochForVerdict;
1319
+ return dead ? "permanentlySkipped" : "defer";
1320
+ }
1321
+ if (i.heldGroupId !== null && i.retiredGroupIds !== null && i.retiredGroupIds.includes(i.rowGroupId))
1322
+ return "retiredDrained";
1323
+ return "defer";
1324
+ }
1325
+
1326
+ // ../connect-sdk-ts/dist/groupkey/admission-rule.js
1327
+ function canAdmit(input) {
1328
+ if (input.targetDeviceId === input.ownDeviceId)
1329
+ return { decision: "refuse", reason: "self" };
1330
+ if (input.boundLeaves.some((b) => b.device === input.targetDeviceId)) {
1331
+ return { decision: "refuse", reason: "already-bound" };
1332
+ }
1333
+ if (input.targetLeaves.length > 0) {
1334
+ if (input.deltaVouches)
1335
+ return { decision: "refuse", reason: "identity-leaf-present" };
1336
+ if (input.targetLeaves.length > 1)
1337
+ return { decision: "refuse", reason: "identity-leaf-present" };
1338
+ const t = input.targetLeaves[0];
1339
+ if (!input.identityLeaves.some((l) => l.leafIndex === t))
1340
+ return { decision: "refuse", reason: "already-bound" };
1341
+ if (input.identityLeaves.some((l) => l.leafIndex !== t && l.leafIndex !== input.ownLeafIndex))
1342
+ return { decision: "refuse", reason: "identity-leaf-present" };
1343
+ return { decision: "already-present" };
1344
+ }
1345
+ if (input.targetIdentityId === null)
1346
+ return { decision: "refuse", reason: "unknown-identity" };
1347
+ if (input.identityLeaves.length === 0)
1348
+ return { decision: "admit" };
1349
+ const allUnbound = input.identityLeaves.every((l) => l.device === null);
1350
+ const ownLeafUnreadable = allUnbound && input.ownLeafIndex === null;
1351
+ const exemptionApplies = allUnbound && input.ownLeafIndex !== null && input.identityLeaves.every((l) => l.leafIndex === input.ownLeafIndex);
1352
+ if (exemptionApplies)
1353
+ return { decision: "admit" };
1354
+ if (input.deltaVouches)
1355
+ return { decision: "admit" };
1356
+ return {
1357
+ decision: "refuse",
1358
+ reason: ownLeafUnreadable ? "unknown-own-leaf" : "identity-leaf-present"
1359
+ };
1360
+ }
1361
+ function hasMalformedBinding(identityLeaves) {
1362
+ return identityLeaves.some((l) => l.malformed);
1363
+ }
1364
+
1365
+ // ../connect-sdk-ts/dist/groupkey/kp-expiry.js
1366
+ var SKEW_GUARD_MS = 5 * 60 * 1e3;
1367
+ function isServerExpired(exp, nowMs) {
1368
+ return nowMs - SKEW_GUARD_MS >= exp.getTime();
1369
+ }
1370
+
1271
1371
  // ../connect-sdk-ts/dist/groupkey/mls.js
1272
1372
  var LABEL = "agentvault-connect-v1";
1273
1373
  var DEFAULT_STUCK_THRESHOLD = 2;
@@ -1315,6 +1415,30 @@ var ForgedCredentialError = class extends Error {
1315
1415
  this.name = "ForgedCredentialError";
1316
1416
  }
1317
1417
  };
1418
+ var SelfAddRefusedError = class extends Error {
1419
+ deviceId;
1420
+ channelId;
1421
+ constructor(deviceId, channelId) {
1422
+ 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.`);
1423
+ this.deviceId = deviceId;
1424
+ this.channelId = channelId;
1425
+ this.name = "SelfAddRefusedError";
1426
+ }
1427
+ };
1428
+ var AddRefusedError = class extends Error {
1429
+ reason;
1430
+ deviceId;
1431
+ identityId;
1432
+ identityLeafCount;
1433
+ constructor(reason, deviceId, identityId, identityLeafCount) {
1434
+ 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.`);
1435
+ this.reason = reason;
1436
+ this.deviceId = deviceId;
1437
+ this.identityId = identityId;
1438
+ this.identityLeafCount = identityLeafCount;
1439
+ this.name = "AddRefusedError";
1440
+ }
1441
+ };
1318
1442
  var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1319
1443
  o;
1320
1444
  suite;
@@ -1384,6 +1508,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1384
1508
  * the past-epoch {@link permanentlySkipped} drain. Read via
1385
1509
  * {@link quarantineDrainedCount}. */
1386
1510
  quarantineDrained = 0;
1511
+ /** Cumulative count of rows ack-drained because their group_id is RETIRED for
1512
+ * the channel (superseded fork-loser traffic). Distinct from the same-group
1513
+ * past-epoch {@link permanentlySkipped} drain. Read via
1514
+ * {@link retiredDrainedCount}. */
1515
+ retiredDrained = 0;
1387
1516
  constructor(o) {
1388
1517
  this.o = o;
1389
1518
  this.suite = o.suite ?? "xchacha20poly1305";
@@ -1541,6 +1670,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1541
1670
  quarantineDrainedCount() {
1542
1671
  return this.quarantineDrained;
1543
1672
  }
1673
+ /** See {@link retiredDrained}. */
1674
+ retiredDrainedCount() {
1675
+ return this.retiredDrained;
1676
+ }
1544
1677
  /** Evict the live in-memory MLS group manager for a channel. Used when the
1545
1678
  * device leaves or deletes a channel: dropping the persisted store record is
1546
1679
  * not enough on its own, because a warm manager keeps decrypting until the
@@ -1577,6 +1710,24 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1577
1710
  * ONE active leaf. Used by the guest reconcile sweep when an approved guest
1578
1711
  * sits in `joining`.
1579
1712
  *
1713
+ * ⛔ AS OF SLICE B (ADR-0014 Decision B) THE WORKING DEVICE SWAP IS GONE.
1714
+ * When `priorDeviceId` names a device whose leaf still carries this guest
1715
+ * identity — the case the swap existed for — the Add chokepoint's admission
1716
+ * rule refuses (AddRefusedError, reason `identity-leaf-present`) and this
1717
+ * method THROWS before reaching the eviction below.
1718
+ *
1719
+ * ⚠️ That does NOT make the eviction block unreachable, and an earlier
1720
+ * revision of this note wrongly said it did. Pass a `priorDeviceId` that
1721
+ * differs from `newDeviceId` while the guest identity holds NO leaf and the
1722
+ * rule admits on clause 2's first arm, the Add commits, and the block DOES
1723
+ * run — then throws `LeafRosterMismatchError`, because the identity owns one
1724
+ * leaf where the default `orderedDeviceIds` names two. So the reachable
1725
+ * outcomes for a caller passing `priorDeviceId` are a refusal or a roster
1726
+ * mismatch, never a completed swap. The block is retained because it is still
1727
+ * correct and is what a Remove-then-Add re-enablement (or slice 5's
1728
+ * device-bound leaf) will build on. See the body comment for why the
1729
+ * count-only attestation that used to open that path was retired.
1730
+ *
1580
1731
  * Add then evict: we commit the Add first so the new leaf exists before the
1581
1732
  * old one is dropped (the guest is never momentarily leaf-less in the group).
1582
1733
  * The eviction uses the per-DEVICE removal path (removeDeviceFromLiveGroup),
@@ -1592,16 +1743,19 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1592
1743
  * equals newDeviceId (the same device re-admitted).
1593
1744
  */
1594
1745
  async admitGuestDevice(channelId, g) {
1595
- await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
1596
- knownLeafDevices: g.priorDeviceId ? [g.priorDeviceId] : []
1746
+ const outcome = await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
1747
+ expectedIdentityId: g.identityId
1597
1748
  });
1598
- if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1749
+ if (outcome === "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1599
1750
  const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
1600
1751
  await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
1601
1752
  identity: g.identityId,
1602
1753
  orderedDeviceIds
1603
1754
  });
1755
+ } else if (outcome !== "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1756
+ 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.`);
1604
1757
  }
1758
+ return outcome;
1605
1759
  }
1606
1760
  /** Load (or reuse) the per-channel manager from the store. Throws if no state. */
1607
1761
  async manager(channelId) {
@@ -1609,9 +1763,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1609
1763
  if (cached)
1610
1764
  return cached;
1611
1765
  const saved = await this.o.store.load(channelId);
1766
+ const again = this.managers.get(channelId);
1767
+ if (again)
1768
+ return again;
1612
1769
  if (!saved)
1613
1770
  throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
1614
- const mgr = new MLSGroupManager(saved.ciphersuite);
1771
+ const mgr = new MLSGroupManager2(saved.ciphersuite);
1615
1772
  mgr.importState(saved);
1616
1773
  this.managers.set(channelId, mgr);
1617
1774
  return mgr;
@@ -1813,11 +1970,21 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1813
1970
  return w?.connect_channel_id ?? null;
1814
1971
  }
1815
1972
  /**
1816
- * Initialize the ts-mls libsodium (WASM) backend ONCE before any store
1817
- * operation that deserializes a persisted KeyPackage. generateKeyPackage
1818
- * awaits sodium.ready and is pure in-memory (it neither publishes to the
1819
- * backend nor writes the store), so the throwaway bundle is a side-effect-free
1820
- * warm-up. Without it, the FIRST loadPendingBundle at boot (the republish
1973
+ * Give the ts-mls libsodium (WASM) backend time to initialize before any
1974
+ * store operation that deserializes a persisted KeyPackage. generateKeyPackage
1975
+ * is pure in-memory (it neither publishes to the backend nor writes the
1976
+ * store), so the throwaway bundle is a side-effect-free warm-up.
1977
+ *
1978
+ * ⛔ IT IS A RACE, NOT A BARRIER, and do not read it as one. Nothing on this
1979
+ * path awaits sodium.ready: MLSGroupManager defaults _provider to
1980
+ * nobleCryptoProvider, and _ensureCiphersuite passes that provider to
1981
+ * getCiphersuiteImpl. What the warm-up actually does is BURN WALL CLOCK while
1982
+ * libsodium's WASM initializes in PARALLEL. The margin is roughly 1.7 ms and
1983
+ * it DRIFTS: libsodium-sumo is reached through a caret and its init timing
1984
+ * sets the margin, so an install can move it with no change in this repo.
1985
+ * See 02f47e15 (PR #87), whose commit body carries the correct diagnosis.
1986
+ *
1987
+ * Without it, the FIRST loadPendingBundle at boot (the republish
1821
1988
  * guard, and the join path) runs while WASM is still cold, throws deep in the
1822
1989
  * crypto ("Cannot read properties of undefined (reading '_malloc')"), is caught
1823
1990
  * and misread as "no held bundle", and the daemon mints + republishes a FRESH
@@ -1828,7 +1995,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1828
1995
  async ensureCryptoReady() {
1829
1996
  if (this.cryptoWarmed)
1830
1997
  return;
1831
- await new MLSGroupManager().generateKeyPackage(this.o.identity);
1998
+ await new MLSGroupManager2().generateKeyPackage(this.o.identity);
1832
1999
  this.cryptoWarmed = true;
1833
2000
  }
1834
2001
  async publishKeyPackage() {
@@ -1879,7 +2046,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1879
2046
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1880
2047
  if (!bundle)
1881
2048
  return false;
1882
- const heldHex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
2049
+ const heldHex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
1883
2050
  const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
1884
2051
  const pubHex = published[this.o.deviceId];
1885
2052
  return !!pubHex && pubHex === heldHex;
@@ -1889,13 +2056,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1889
2056
  }
1890
2057
  async publishKeyPackageUnlocked() {
1891
2058
  if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
1892
- console.warn("publishKeyPackage: pending bundle exists; skipping republish");
1893
- return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
2059
+ const kpId = await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
2060
+ const exp = kpId === "" ? null : await this.o.backend.fetchKeyPackageExpiry(kpId);
2061
+ const expired = exp !== null && isServerExpired(exp, this.o.now?.() ?? Date.now());
2062
+ if (!expired) {
2063
+ console.warn("publishKeyPackage: pending bundle exists; skipping republish");
2064
+ return kpId;
2065
+ }
2066
+ console.warn(`publishKeyPackage: server KeyPackage expired for device ${this.o.deviceId.slice(0, 8)}; rotating`);
2067
+ await this.o.store.deletePendingBundle(this.o.deviceId);
1894
2068
  }
1895
2069
  console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
1896
- const mgr = new MLSGroupManager();
2070
+ const mgr = new MLSGroupManager2();
1897
2071
  const bundle = await mgr.generateKeyPackage(this.o.identity);
1898
- const hex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
2072
+ const hex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
1899
2073
  const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
1900
2074
  await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
1901
2075
  return id;
@@ -1923,14 +2097,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1923
2097
  if (opts.forceReinit) {
1924
2098
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
1925
2099
  const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
1926
- const mgr = new MLSGroupManager();
2100
+ const mgr = new MLSGroupManager2();
1927
2101
  await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
1928
2102
  const kps = await this.o.backend.fetchKeyPackages(others);
1929
2103
  const pkgs = others.map((d) => {
1930
2104
  const hex = kps[d];
1931
2105
  if (!hex)
1932
2106
  throw new Error(`ensureSession(forceReinit): no published KeyPackage for device ${d}`);
1933
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2107
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
1934
2108
  });
1935
2109
  const { commit, welcome } = await mgr.addMembers(pkgs);
1936
2110
  await this.o.backend.distribute(reinit.group_id, {
@@ -1949,7 +2123,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1949
2123
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
1950
2124
  if (init.created === false)
1951
2125
  throw new BootstrapRaceLostError();
1952
- const mgr = new MLSGroupManager();
2126
+ const mgr = new MLSGroupManager2();
1953
2127
  await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
1954
2128
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
1955
2129
  const kps = await this.o.backend.fetchKeyPackages(others);
@@ -1957,7 +2131,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1957
2131
  const hex = kps[d];
1958
2132
  if (!hex)
1959
2133
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
1960
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2134
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
1961
2135
  });
1962
2136
  const { commit, welcome } = await mgr.addMembers(pkgs);
1963
2137
  await this.o.backend.distribute(init.group_id, {
@@ -1975,7 +2149,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1975
2149
  const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
1976
2150
  if (!w)
1977
2151
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
1978
- const mgr = new MLSGroupManager();
2152
+ const mgr = new MLSGroupManager2();
1979
2153
  try {
1980
2154
  await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
1981
2155
  } catch (err) {
@@ -2011,36 +2185,66 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2011
2185
  const hex = kps[newDeviceId];
2012
2186
  if (!hex)
2013
2187
  throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
2014
- const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2015
- if (_attempt === 0) {
2016
- let adds;
2017
- try {
2018
- adds = (await this.o.backend.getCommitterWork(channelId)).adds;
2019
- } catch (e) {
2020
- 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)));
2188
+ const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2189
+ const cred = pkg.leafNode.credential;
2190
+ const claimedIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
2191
+ if (opts?.expectedIdentityId && claimedIdentity !== opts.expectedIdentityId) {
2192
+ throw new ForgedCredentialError(opts.expectedIdentityId, claimedIdentity ?? "");
2193
+ }
2194
+ const targetLeaves = leafIndicesForKeyPackage(mgr, pkg);
2195
+ let deltaVouches = false;
2196
+ try {
2197
+ deltaVouches = (await this.o.backend.getCommitterWork(channelId)).adds.includes(newDeviceId);
2198
+ } catch (e) {
2199
+ if (targetLeaves.length > 0) {
2200
+ 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)));
2021
2201
  }
2022
- if (!adds.includes(newDeviceId)) {
2023
- const cred = pkg.leafNode.credential;
2024
- const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
2025
- const leaves = claimed === null ? null : mgr.leafIndicesForIdentity(claimed);
2026
- if (leaves === null || leaves.length > 0) {
2027
- const known = opts?.knownLeafDevices ? [...new Set(opts.knownLeafDevices)] : null;
2028
- const swapSafe = leaves !== null && known !== null && !known.includes(newDeviceId) && known.length === leaves.length;
2029
- if (!swapSafe) {
2030
- 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)`);
2031
- return;
2032
- }
2202
+ deltaVouches = false;
2203
+ }
2204
+ const identityLeaves = claimedIdentity === null ? [] : mgr.leafIndicesForIdentity(claimedIdentity).map((leafIndex) => ({
2205
+ leafIndex,
2206
+ device: null,
2207
+ // SLICE 5: read the LeafNode extension here.
2208
+ malformed: false
2209
+ // SLICE 5: set from a failed extension decode.
2210
+ }));
2211
+ const ownLeaf = this.ownLeafIndex(mgr);
2212
+ const verdict = canAdmit({
2213
+ targetDeviceId: newDeviceId,
2214
+ targetIdentityId: claimedIdentity,
2215
+ ownDeviceId: this.o.deviceId,
2216
+ ownLeafIndex: ownLeaf,
2217
+ boundLeaves: [],
2218
+ // SLICE 5: the whole-tree bound-leaf scan replaces this literal.
2219
+ identityLeaves,
2220
+ targetLeaves,
2221
+ deltaVouches
2222
+ });
2223
+ if (hasMalformedBinding(identityLeaves)) {
2224
+ console.warn(`addMemberToLiveGroup: channel ${channelId} has a leaf with an unreadable device binding for identity ${claimedIdentity ?? "?"} \u2014 treated as UNBOUND, never as absent`);
2225
+ }
2226
+ if (targetLeaves.length > 0 && deltaVouches) {
2227
+ 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.`);
2228
+ }
2229
+ if (targetLeaves.length > 1) {
2230
+ 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.`);
2231
+ }
2232
+ switch (verdict.decision) {
2233
+ case "admit":
2234
+ break;
2235
+ case "already-present":
2236
+ return "already-present";
2237
+ case "refuse":
2238
+ if (verdict.reason === "self") {
2239
+ throw new SelfAddRefusedError(newDeviceId, channelId);
2033
2240
  }
2241
+ throw new AddRefusedError(verdict.reason, newDeviceId, claimedIdentity, identityLeaves.length);
2242
+ default: {
2243
+ const _never = verdict;
2244
+ throw new Error(`unhandled admission verdict: ${JSON.stringify(_never)}`);
2034
2245
  }
2035
2246
  }
2036
2247
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
2037
- if (opts?.expectedIdentityId) {
2038
- const cred = pkg.leafNode.credential;
2039
- const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
2040
- if (claimed !== opts.expectedIdentityId) {
2041
- throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
2042
- }
2043
- }
2044
2248
  const priorEpoch = Number(mgr.epoch);
2045
2249
  const { commit, welcome } = await mgr.addMembers([pkg]);
2046
2250
  const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
@@ -2062,15 +2266,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2062
2266
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
2063
2267
  } catch {
2064
2268
  }
2065
- try {
2066
- const work = await this.o.backend.getCommitterWork(channelId);
2067
- if (!work.adds.includes(newDeviceId))
2068
- return;
2069
- } catch {
2070
- }
2071
2269
  return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
2072
2270
  }
2073
2271
  await this.o.store.save(channelId, mgr.exportState());
2272
+ return "committed";
2074
2273
  }
2075
2274
  /**
2076
2275
  * Committer-side: REMOVE a member from a live group (Phase C eviction).
@@ -2168,9 +2367,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2168
2367
  * should delegate to it.
2169
2368
  *
2170
2369
  * Returns null rather than throwing so every caller must decide explicitly what
2171
- * an unknown own-leaf means. The one caller today FAILS CLOSED on null by
2172
- * falling back to the positional path only for FOREIGN identities — it never
2173
- * uses null to justify guessing among an own identity's leaves.
2370
+ * an unknown own-leaf means.
2371
+ *
2372
+ * ⚠️ The one caller today does NOT fail closed on null. In
2373
+ * {@link removeDeviceFromLiveGroupUnlocked} the branch is
2374
+ * `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
2375
+ * identities at all, so a null return sends an OWN-identity removal down the
2376
+ * positional `leaves[pos]` path, which is exactly the add-order guess the
2377
+ * self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
2378
+ * permutes that mapping after any Remove). Null is therefore currently
2379
+ * indistinguishable from "foreign identity", and only the cardinality guard
2380
+ * stands between it and evicting the wrong device of the same human.
2381
+ * Correcting that is Phase 1b, not this comment.
2174
2382
  */
2175
2383
  ownLeafIndex(mgr) {
2176
2384
  const idx = mgr.state?.privatePath?.leafIndex;
@@ -2279,6 +2487,37 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2279
2487
  * - MORE THAN ONE leaf → ambiguous (no add-order roster to disambiguate which
2280
2488
  * leaf is the stale one) → REFUSE rather than risk evicting a healthy device.
2281
2489
  *
2490
+ * ⛔⛔ LIMITATION — A MULTI-DEVICE MEMBER IS UNHEALABLE BY THIS METHOD, AND THE
2491
+ * FAILURE IS SILENT AND DESTRUCTIVE. READ THIS BEFORE CALLING IT.
2492
+ *
2493
+ * Leaf credentials encode the IDENTITY and not the device, so the leaf
2494
+ * resolution above is only exact when the identity's leaf set is unambiguous.
2495
+ * The `> 1` case refuses. The `=== 1` case DOES NOT, and it is the worse one:
2496
+ * heal device B of an identity whose device A holds the only leaf and A is
2497
+ * evicted while the call reports a clean heal. Every downstream check is
2498
+ * satisfied — the cardinality guard in `removeDeviceFromLiveGroupUnlocked`
2499
+ * compares this call site's single-element `orderedDeviceIds` against a
2500
+ * single-element leaf set, a coincidence of counts, and `leaves[0]` is A's.
2501
+ * `mls.heal-multidevice.test.ts` measures exactly that against real MLS.
2502
+ *
2503
+ * ⛔ NO GUARD IS AVAILABLE HERE, and this is a measured conclusion rather than
2504
+ * an unexplored one. The device-level predicate that would settle it (leaf
2505
+ * indices whose LeafNode signature key equals the fetched KeyPackage's — the
2506
+ * same evidence the Add chokepoint uses) was implemented and it refused EVERY
2507
+ * production heal: the repair contract requires the member to republish first,
2508
+ * `forceRepublishKeyPackage` mints a NEW SIGNING KEY, and a diverged device's
2509
+ * fresh KeyPackage therefore matches no leaf, including its own. A guard built
2510
+ * on it is a blanket disable, not a guard.
2511
+ *
2512
+ * ⚠️ CALLER CONTRACT UNTIL SLICE 5: do not drive this for a member you know to
2513
+ * hold more than one device. The caller is the only layer that has a roster,
2514
+ * and nothing in the tree binds a leaf to a device id, so the refusal has to
2515
+ * live above this function. connect-web's `foreign-identity-heal.ts` declines a
2516
+ * member whose identity holds two or more LEAVES; the residual it cannot see —
2517
+ * two DEVICES sharing one leaf — is the shape described above and is open.
2518
+ * It resolves when the leaf credential carries a device binding (slice 5),
2519
+ * which cannot be retrofitted without re-keying every live group.
2520
+ *
2282
2521
  * The Add fetches the device's freshly published KeyPackage from the backend,
2283
2522
  * exactly like a normal add — the CALLER is responsible for the member having
2284
2523
  * re-published one first (mirrors addMemberToLiveGroup's "no published
@@ -2295,7 +2534,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2295
2534
  const hex = kps[memberDeviceId];
2296
2535
  if (!hex)
2297
2536
  throw new Error(`healDivergedMember: no published KeyPackage for device ${memberDeviceId}`);
2298
- const cred = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex)).leafNode.credential;
2537
+ const pkg = MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2538
+ const cred = pkg.leafNode.credential;
2299
2539
  if (cred.credentialType !== "basic") {
2300
2540
  throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
2301
2541
  }
@@ -2308,6 +2548,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2308
2548
  if (leaves.length > 1) {
2309
2549
  throw new Error(`healDivergedMember: identity owns ${leaves.length} leaves; ambiguous stale-leaf resolution (refusing to guess)`);
2310
2550
  }
2551
+ if (leaves.length === 1) {
2552
+ 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.`);
2553
+ }
2311
2554
  let removeLanded = false;
2312
2555
  if (leaves.length === 1) {
2313
2556
  await this.removeDeviceFromLiveGroupUnlocked(channelId, memberDeviceId, { identity, orderedDeviceIds: [memberDeviceId] }, 0);
@@ -2318,13 +2561,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2318
2561
  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)`);
2319
2562
  }
2320
2563
  }
2564
+ let outcome;
2321
2565
  try {
2322
- await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
2566
+ outcome = await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
2323
2567
  } catch (e) {
2324
2568
  if (removeLanded)
2325
2569
  throw new HealAddAfterRemoveError(e);
2326
2570
  throw e;
2327
2571
  }
2572
+ if (outcome !== "committed") {
2573
+ 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.`);
2574
+ if (removeLanded)
2575
+ throw new HealAddAfterRemoveError(err);
2576
+ throw err;
2577
+ }
2328
2578
  }));
2329
2579
  }
2330
2580
  /** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
@@ -2431,14 +2681,14 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2431
2681
  throw new Error("resetSessionSupersede: call publishKeyPackage() first");
2432
2682
  const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
2433
2683
  const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
2434
- const mgr = new MLSGroupManager();
2684
+ const mgr = new MLSGroupManager2();
2435
2685
  await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
2436
2686
  const kps = await this.o.backend.fetchKeyPackages(others);
2437
2687
  const pkgs = others.map((d) => {
2438
2688
  const hex = kps[d];
2439
2689
  if (!hex)
2440
2690
  throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
2441
- return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2691
+ return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
2442
2692
  });
2443
2693
  const { commit, welcome } = await mgr.addMembers(pkgs);
2444
2694
  await this.o.backend.distribute(reinit.group_id, {
@@ -2487,18 +2737,55 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2487
2737
  }
2488
2738
  }
2489
2739
  /**
2490
- * Return the number of leaves the given identity holds in the live tree for
2491
- * `channelId`. Loads the manager from the store if not already cached.
2740
+ * Return the number of leaves the given identity holds in THIS DEVICE'S view
2741
+ * of the ratchet tree for `channelId`. Reads the cached manager, else loads
2742
+ * one from the store; it makes no backend call, so the answer is only as
2743
+ * current as the commits this device has already processed. A device that is
2744
+ * behind, forked, or holding a superseded group answers from that state, not
2745
+ * from the server's.
2746
+ *
2747
+ * ⚠️ THROWS (`no MLS state for channel …`) when there is no local state at
2748
+ * all — it does not report 0 for "we have not joined yet". Callers that may
2749
+ * run before {@link ensureSession} must handle the throw; see
2750
+ * {@link hasLeafForIdentity} for the non-throwing, cache-only variant.
2492
2751
  *
2493
2752
  * Primarily used by the gate (S20) to assert that a peer-committer 409-retry
2494
- * does NOT create a duplicate leaf for an already-added device. A return value
2495
- * of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
2496
- * duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
2753
+ * does NOT create a duplicate leaf for an already-added device. In THAT
2754
+ * scenario each identity owns exactly one device, so 1 is healthy and > 1
2755
+ * means the S3 guard failed to suppress the re-Add. That reading does NOT
2756
+ * generalise: leaf credentials encode the IDENTITY, not the device, so a
2757
+ * multi-device identity legitimately holds ONE LEAF PER DEVICE — the same
2758
+ * premise {@link removeDeviceFromLiveGroupUnlocked}'s
2759
+ * `leaves.length !== orderedDeviceIds.length` guard is built on. Read the
2760
+ * count against the identity's expected device count, not against 1.
2497
2761
  */
2498
2762
  async leafCountForIdentity(channelId, identityId) {
2499
2763
  const mgr = await this.manager(channelId);
2500
2764
  return mgr.leafIndicesForIdentity(identityId).length;
2501
2765
  }
2766
+ /**
2767
+ * Cache-only leaf count for `identityId` in `channelId`, or **null** when
2768
+ * there is no in-memory manager (a cold channel).
2769
+ *
2770
+ * The non-throwing sibling of {@link leafCountForIdentity}, with the same
2771
+ * cold-channel semantics as {@link hasLeafForIdentity}. It exists because the
2772
+ * join confirmer needs to know whether a leaf can be ATTRIBUTED to a device,
2773
+ * and a boolean cannot answer that: leaf credentials encode the identity, not
2774
+ * the device, so "the identity holds a leaf" is true off ANY of its devices'
2775
+ * leaves. `null` and any count other than 1 both mean "cannot attribute", and
2776
+ * a caller that clears state on attribution must treat them as such.
2777
+ *
2778
+ * ⚠️ This is still an IDENTITY-level read. It does not name which device
2779
+ * holds which leaf and it cannot until leaves carry a device binding
2780
+ * (ADR-0014 Decision B, slice 5). What it buys today is the ability to REFUSE
2781
+ * to attribute rather than to guess.
2782
+ */
2783
+ leafCountForIdentityCached(channelId, identityId) {
2784
+ const mgr = this.managers.get(channelId);
2785
+ if (!mgr)
2786
+ return null;
2787
+ return mgr.leafIndicesForIdentity(identityId).length;
2788
+ }
2502
2789
  /** Returns true when the live in-memory MLS tree for `channelId` contains at
2503
2790
  * least one leaf bearing `identityId` as its credential. Used by the agent
2504
2791
  * admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
@@ -2616,7 +2903,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2616
2903
  return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
2617
2904
  }
2618
2905
  return await this.locks.run(channelId, async () => {
2619
- const mgr = new MLSGroupManager();
2906
+ const mgr = new MLSGroupManager2();
2620
2907
  try {
2621
2908
  await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
2622
2909
  } catch (err) {
@@ -2654,16 +2941,45 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2654
2941
  }
2655
2942
  async receiveOnce(deviceId) {
2656
2943
  const rows = await this.o.backend.pullDelivery(deviceId);
2657
- if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
2658
- 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(", "));
2659
- }
2660
2944
  const appEntries = [];
2661
2945
  const handshakeAcked = [];
2662
2946
  const supersededChannelIds = [];
2663
- const order = { welcome: 0, commit: 1, application: 2 };
2664
- rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
2665
- for (const row of rows) {
2947
+ const welcomes = rows.filter((r) => r.message_type === "welcome");
2948
+ const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
2949
+ const ordered = [...welcomes, ...nonWelcomes];
2950
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
2951
+ 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(", "));
2952
+ }
2953
+ const classCtx = /* @__PURE__ */ new Map();
2954
+ const classCtxFor = async (cid) => {
2955
+ let ctx = classCtx.get(cid);
2956
+ if (!ctx) {
2957
+ let heldGroupId = null;
2958
+ let snapshotEpoch;
2959
+ let retired = null;
2960
+ try {
2961
+ const mgr = await this.manager(cid);
2962
+ heldGroupId = new TextDecoder().decode(mgr.groupId);
2963
+ snapshotEpoch = Number(mgr.epoch);
2964
+ } catch {
2965
+ heldGroupId = null;
2966
+ snapshotEpoch = void 0;
2967
+ }
2968
+ try {
2969
+ retired = await this.o.store.loadRetiredGroupIds(cid);
2970
+ } catch {
2971
+ retired = null;
2972
+ }
2973
+ ctx = { heldGroupId, retired, snapshotEpoch, liveCommitEpoch: snapshotEpoch };
2974
+ classCtx.set(cid, ctx);
2975
+ }
2976
+ return ctx;
2977
+ };
2978
+ for (const row of ordered) {
2666
2979
  try {
2980
+ if (row.message_type !== "welcome" && row.connect_channel_id) {
2981
+ await classCtxFor(row.connect_channel_id);
2982
+ }
2667
2983
  if (row.message_type === "welcome") {
2668
2984
  const wcid = row.connect_channel_id;
2669
2985
  if (wcid) {
@@ -2700,15 +3016,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2700
3016
  const cid = row.connect_channel_id;
2701
3017
  if (!cid)
2702
3018
  continue;
3019
+ let advancedEpoch;
2703
3020
  await this.locks.run(cid, async () => {
2704
3021
  const mgr = await this.manager(cid);
2705
3022
  await mgr.decrypt(hexToBytes(row.payload));
2706
3023
  await this.o.store.save(cid, mgr.exportState());
3024
+ advancedEpoch = Number(mgr.epoch);
2707
3025
  });
2708
3026
  this.recordDecryptSuccess(cid);
3027
+ if (advancedEpoch !== void 0) {
3028
+ const ctx = classCtx.get(cid);
3029
+ if (ctx)
3030
+ ctx.liveCommitEpoch = advancedEpoch;
3031
+ }
2709
3032
  handshakeAcked.push(row.queue_id);
2710
3033
  } else {
2711
3034
  const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
3035
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
3036
+ throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
3037
+ }
2712
3038
  const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
2713
3039
  const aad = await buildAssociatedData({
2714
3040
  message_id: envelope.message_id,
@@ -2736,18 +3062,25 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2736
3062
  this.recordDecryptError(row, err);
2737
3063
  const cid = row.connect_channel_id;
2738
3064
  let drained = false;
2739
- if (cid && typeof row.epoch === "number") {
2740
- let liveEpoch;
2741
- try {
2742
- liveEpoch = await this.currentEpoch(cid);
2743
- } catch {
2744
- liveEpoch = void 0;
2745
- }
2746
- const permanentlyDead = liveEpoch !== void 0 && (row.message_type === "commit" ? row.epoch <= liveEpoch : row.message_type === "application" ? row.epoch < liveEpoch : false);
2747
- if (permanentlyDead) {
3065
+ if (cid && row.message_type !== "welcome") {
3066
+ const ctx = classCtx.get(cid);
3067
+ const verdict = classifyFailedRow({
3068
+ messageType: row.message_type,
3069
+ rowGroupId: row.group_id,
3070
+ rowEpoch: typeof row.epoch === "number" ? row.epoch : void 0,
3071
+ heldGroupId: ctx?.heldGroupId ?? null,
3072
+ retiredGroupIds: ctx?.retired ?? null,
3073
+ snapshotEpoch: ctx?.snapshotEpoch,
3074
+ liveCommitEpoch: ctx?.liveCommitEpoch
3075
+ });
3076
+ if (verdict === "permanentlySkipped") {
2748
3077
  handshakeAcked.push(row.queue_id);
2749
3078
  this.permanentlySkipped++;
2750
3079
  drained = true;
3080
+ } else if (verdict === "retiredDrained") {
3081
+ handshakeAcked.push(row.queue_id);
3082
+ this.retiredDrained++;
3083
+ drained = true;
2751
3084
  }
2752
3085
  }
2753
3086
  if (!drained && cid) {
@@ -2755,8 +3088,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2755
3088
  if (e) {
2756
3089
  e.wedgedRun += 1;
2757
3090
  if (e.wedgedRun >= this.quarantineThreshold) {
2758
- handshakeAcked.push(row.queue_id);
2759
- this.quarantineDrained++;
3091
+ if (row.message_type !== "welcome") {
3092
+ handshakeAcked.push(row.queue_id);
3093
+ this.quarantineDrained++;
3094
+ }
2760
3095
  this.quarantineChannel(cid);
2761
3096
  }
2762
3097
  }
@@ -3312,6 +3647,24 @@ var RestTransport = class {
3312
3647
  };
3313
3648
 
3314
3649
  // ../connect-sdk-ts/dist/mls/rest-backend.js
3650
+ function describeType(v) {
3651
+ if (v === void 0)
3652
+ return "missing";
3653
+ if (v === null)
3654
+ return "null";
3655
+ if (Array.isArray(v))
3656
+ return "array";
3657
+ return typeof v;
3658
+ }
3659
+ function parseGroupRoster(endpoint, channelId, value) {
3660
+ if (!Array.isArray(value)) {
3661
+ throw new MalformedGroupRosterError(endpoint, channelId, describeType(value));
3662
+ }
3663
+ if (value.length === 0) {
3664
+ throw new MalformedGroupRosterError(endpoint, channelId, "empty array");
3665
+ }
3666
+ return value.map(String);
3667
+ }
3315
3668
  var RestMlsBackend = class {
3316
3669
  opts;
3317
3670
  f;
@@ -3387,7 +3740,10 @@ var RestMlsBackend = class {
3387
3740
  return {
3388
3741
  group_id: String(j.group_id),
3389
3742
  current_epoch: Number(j.current_epoch),
3390
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3743
+ // #230: fail loud on an absent / non-array / empty roster rather than
3744
+ // coerce to `[]` (which pre-empts the consumers' `?? [self]` fallback and
3745
+ // gets POSTed back as a wipe on distribute).
3746
+ member_device_ids: parseGroupRoster("init", channelId, j.member_device_ids),
3391
3747
  // Back-compat: servers that predate the created field return nothing;
3392
3748
  // treat absence as true (they always created a fresh group).
3393
3749
  created: j.created === void 0 ? true : Boolean(j.created)
@@ -3412,10 +3768,14 @@ var RestMlsBackend = class {
3412
3768
  return {
3413
3769
  group_id: String(j.group_id),
3414
3770
  current_epoch: Number(j.current_epoch),
3415
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3771
+ // #230: same guard as init. This field has ZERO consumers today
3772
+ // (resetSessionSupersede reads only stale_member_device_ids), so this is
3773
+ // defense in depth: a future consumer cannot inherit the silent-`[]` trap.
3774
+ member_device_ids: parseGroupRoster("reinit", channelId, j.member_device_ids),
3416
3775
  // Absent on a backend that predates the dropped-member report; an empty
3417
3776
  // list is the correct read of "the server told us nothing", since the old
3418
- // server also never dropped anyone for an expired KeyPackage.
3777
+ // server also never dropped anyone for an expired KeyPackage. UNCHANGED:
3778
+ // empty here is legitimate, the opposite of member_device_ids (§3.3).
3419
3779
  stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
3420
3780
  };
3421
3781
  }
@@ -3431,6 +3791,22 @@ var RestMlsBackend = class {
3431
3791
  return null;
3432
3792
  }
3433
3793
  }
3794
+ async fetchKeyPackageExpiry(kpId) {
3795
+ try {
3796
+ const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}/expiry`);
3797
+ const raw = j.expires_at;
3798
+ if (typeof raw !== "string")
3799
+ return null;
3800
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/.test(raw))
3801
+ return null;
3802
+ const ms = Date.parse(raw);
3803
+ if (Number.isNaN(ms))
3804
+ return null;
3805
+ return new Date(ms);
3806
+ } catch {
3807
+ return null;
3808
+ }
3809
+ }
3434
3810
  async distribute(groupId, req) {
3435
3811
  await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
3436
3812
  ...req,
@@ -3455,10 +3831,14 @@ var RestMlsBackend = class {
3455
3831
  }
3456
3832
  async getCommitterWork(channelId) {
3457
3833
  const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
3458
- return {
3459
- adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
3460
- removes: Array.isArray(j.removes) ? j.removes.map(String) : []
3834
+ const requireList = (field) => {
3835
+ const v = j?.[field];
3836
+ if (!Array.isArray(v)) {
3837
+ throw new MalformedCommitterWorkError(channelId, field, describeType(v));
3838
+ }
3839
+ return v.map(String);
3461
3840
  };
3841
+ return { adds: requireList("adds"), removes: requireList("removes") };
3462
3842
  }
3463
3843
  async acquireCommitLease(channelId) {
3464
3844
  const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
@@ -3489,6 +3869,390 @@ var RestMlsBackend = class {
3489
3869
 
3490
3870
  // ../connect-sdk-ts/dist/mls/fake-backend.js
3491
3871
  import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
3872
+ var FakeConnectBackend = class _FakeConnectBackend {
3873
+ // Key-package store: deviceId → hex-encoded KeyPackage
3874
+ kps = /* @__PURE__ */ new Map();
3875
+ // Server-assigned KeyPackage ids: deviceId → id (last publish wins, mirroring
3876
+ // the real backend's single-row-per-device semantics).
3877
+ kpIds = /* @__PURE__ */ new Map();
3878
+ _kpSeq = 0;
3879
+ /** Server TTL stamps: kp_id → expiry epoch-ms. Keyed by ID, not by device, so a
3880
+ * SUPERSEDED id has no entry at all — mirroring `publish_key_package`, whose
3881
+ * first statement DELETEs every prior row for the device
3882
+ * (`mls_key_package_service.py:26-28`), which makes a superseded id 404. */
3883
+ kpExpiry = /* @__PURE__ */ new Map();
3884
+ /** Injectable clock, so a test can place a row inside or just outside the guard's
3885
+ * skew grace. The fake's stamp, its `fetchKeyPackages` filter, and the provider's
3886
+ * comparison must all read ONE clock or the grace is not expressible. */
3887
+ now = () => Date.now();
3888
+ /** The real server's default KeyPackage TTL. */
3889
+ static TTL_MS = 7 * 24 * 60 * 60 * 1e3;
3890
+ // Per-device delivery queues
3891
+ queues = /* @__PURE__ */ new Map();
3892
+ // Per-channel state (the real backend scopes membership + epoch per group;
3893
+ // keying these by channel keeps a multi-channel test from cross-contaminating).
3894
+ membersByChannel = /* @__PURE__ */ new Map();
3895
+ epochByChannel = /* @__PURE__ */ new Map();
3896
+ groupIdByChannel = /* @__PURE__ */ new Map();
3897
+ channelByGroup = /* @__PURE__ */ new Map();
3898
+ // identity_id → device_id, recorded at registerDevice so ingestApplication can
3899
+ // exclude the sender's own device on fanout (mirrors sendApplication's
3900
+ // senderDeviceId exclusion when only the envelope's sender_id is known).
3901
+ deviceByIdentity = /* @__PURE__ */ new Map();
3902
+ // Owner-attested reset: settable attestation map keyed by NEW groupId, read by
3903
+ // fetchResetAttestation (agent verify path) + written by resetReinitConnectChannelGroup.
3904
+ resetAttestations = /* @__PURE__ */ new Map();
3905
+ // Optional signer id the owner-side resetReinit records on the attestation it
3906
+ // stores (Task 9). Undefined in Task-6 tests, which set resetAttestations directly.
3907
+ ownerIdentityId;
3908
+ // Monotonic counter for generating unique IDs
3909
+ /** Ported-from-gen2 replay support: every distributed commit, in order, so
3910
+ * syncGroup() can serve a real catch-up instead of a stub. */
3911
+ commitLog = /* @__PURE__ */ new Map();
3912
+ /** Gap beyond which syncGroup answers "rejoin" (mirrors REJOIN_THRESHOLD). */
3913
+ rejoinThreshold = 100;
3914
+ _seq = 0;
3915
+ // Dedicated monotonic counter for reinit group ids (see reinitConnectChannelGroup).
3916
+ _reinitSeq = 0;
3917
+ /** Returns (and lazily creates) the delivery queue for a device. */
3918
+ q(deviceId) {
3919
+ let queue = this.queues.get(deviceId);
3920
+ if (!queue) {
3921
+ queue = [];
3922
+ this.queues.set(deviceId, queue);
3923
+ }
3924
+ return queue;
3925
+ }
3926
+ // ── MlsBackend implementation ──────────────────────────────────────────────
3927
+ /** Test stub: a FIXED 32-byte challenge hex. The real backend mints a fresh
3928
+ * single-use nonce; tests that exercise the PoP path sign over this constant
3929
+ * and re-derive the same message to verify. Deterministic so a register spy
3930
+ * can reconstruct and check the proof. */
3931
+ async getRegisterChallenge() {
3932
+ return "ab".repeat(32);
3933
+ }
3934
+ async registerDevice(req) {
3935
+ const deviceId = "fake-device-" + this._seq++;
3936
+ if (req.identity_id)
3937
+ this.deviceByIdentity.set(req.identity_id, deviceId);
3938
+ return { device_id: deviceId };
3939
+ }
3940
+ /** Test seam: record an identity→device link for a provider constructed with a
3941
+ * fixed deviceId (the SDK e2e wires providers directly, bypassing registerDevice).
3942
+ * The real backend resolves removed_identity_id → device(s) from the devices
3943
+ * table FK; this mirrors that resolution so op==="remove" can subtract the
3944
+ * removed identity's device(s) from the roster + delivery, exactly like the
3945
+ * server's purge_device_queue. */
3946
+ linkIdentityDevice(identityId, deviceId) {
3947
+ this.deviceByIdentity.set(identityId, deviceId);
3948
+ }
3949
+ async publishKeyPackage(deviceId, keyPackageHex) {
3950
+ this.kps.set(deviceId, keyPackageHex);
3951
+ const prior = this.kpIds.get(deviceId);
3952
+ if (prior)
3953
+ this.kpExpiry.delete(prior);
3954
+ const id = `kp_${deviceId}_${this._kpSeq = (this._kpSeq ?? 0) + 1}`;
3955
+ this.kpIds.set(deviceId, id);
3956
+ this.kpExpiry.set(id, this.now() + _FakeConnectBackend.TTL_MS);
3957
+ return id;
3958
+ }
3959
+ /** Test seam: move a recorded expiry to an absolute epoch-ms instant. Named
3960
+ * rather than letting a test write `kpExpiry` directly, so the id-keyed shape
3961
+ * stays an implementation detail. A hard-coded past value would make T2
3962
+ * (the fresh-KeyPackage negative control) unbuildable. */
3963
+ setKeyPackageExpiry(kpId, expiresAtMs) {
3964
+ this.kpExpiry.set(kpId, expiresAtMs);
3965
+ }
3966
+ async fetchKeyPackageExpiry(kpId) {
3967
+ const ms = this.kpExpiry.get(kpId);
3968
+ return ms === void 0 ? null : new Date(ms);
3969
+ }
3970
+ async getConnectChannelGroup(channelId) {
3971
+ const groupId = this.groupIdByChannel.get(channelId) ?? null;
3972
+ if (!groupId)
3973
+ return { exists: false, group_id: null, current_epoch: null };
3974
+ return {
3975
+ exists: true,
3976
+ group_id: groupId,
3977
+ current_epoch: this.epochByChannel.get(channelId) ?? 0
3978
+ };
3979
+ }
3980
+ async initConnectChannelGroup(channelId, memberDeviceIds) {
3981
+ const alreadyExists = this.groupIdByChannel.has(channelId);
3982
+ let groupId = this.groupIdByChannel.get(channelId);
3983
+ if (!groupId) {
3984
+ groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
3985
+ this.groupIdByChannel.set(channelId, groupId);
3986
+ this.channelByGroup.set(groupId, channelId);
3987
+ this.epochByChannel.set(channelId, 0);
3988
+ }
3989
+ const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
3990
+ this.membersByChannel.set(channelId, members);
3991
+ return {
3992
+ group_id: groupId,
3993
+ current_epoch: this.epochByChannel.get(channelId) ?? 0,
3994
+ member_device_ids: members,
3995
+ // Real semantics: created=true on first call (group was just created),
3996
+ // created=false on idempotent return (group already existed). Without
3997
+ // this, race tests silently pass wrong (fake is laxer than the server).
3998
+ created: !alreadyExists
3999
+ };
4000
+ }
4001
+ async reinitConnectChannelGroup(channelId, memberDeviceIds) {
4002
+ const groupId = `00000000-0000-0000-0000-${String(1e3 + (this._reinitSeq = (this._reinitSeq ?? 0) + 1)).padStart(12, "0")}`;
4003
+ this.groupIdByChannel.set(channelId, groupId);
4004
+ this.channelByGroup.set(groupId, channelId);
4005
+ this.epochByChannel.set(channelId, 0);
4006
+ const members = [...new Set(memberDeviceIds)];
4007
+ this.membersByChannel.set(channelId, members);
4008
+ return { group_id: groupId, current_epoch: 0, member_device_ids: members };
4009
+ }
4010
+ async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
4011
+ this.groupIdByChannel.set(channelId, att.newGroupId);
4012
+ this.channelByGroup.set(att.newGroupId, channelId);
4013
+ this.epochByChannel.set(channelId, 0);
4014
+ const members = [...new Set(memberDeviceIds)];
4015
+ this.membersByChannel.set(channelId, members);
4016
+ this.resetAttestations.set(att.newGroupId, {
4017
+ signedContent: new TextDecoder().decode(hexToBytes2(att.signedContentHex)),
4018
+ signatureHex: att.signatureHex,
4019
+ signerIdentityId: this.ownerIdentityId ?? "owner"
4020
+ });
4021
+ return {
4022
+ group_id: att.newGroupId,
4023
+ current_epoch: 0,
4024
+ member_device_ids: members,
4025
+ // The fake honours the roster verbatim, so nothing is ever dropped here.
4026
+ // Tests that need the dropped-member path stub this method directly.
4027
+ stale_member_device_ids: []
4028
+ };
4029
+ }
4030
+ async fetchKeyPackages(deviceIds) {
4031
+ const out = {};
4032
+ for (const id of deviceIds) {
4033
+ const kpId = this.kpIds.get(id);
4034
+ const exp = kpId === void 0 ? void 0 : this.kpExpiry.get(kpId);
4035
+ if (exp !== void 0 && exp <= this.now())
4036
+ continue;
4037
+ out[id] = this.kps.get(id);
4038
+ }
4039
+ return out;
4040
+ }
4041
+ async fetchKeyPackageById(kpId) {
4042
+ for (const [deviceId, id] of this.kpIds) {
4043
+ if (id === kpId) {
4044
+ return this.kps.get(deviceId) ?? null;
4045
+ }
4046
+ }
4047
+ return null;
4048
+ }
4049
+ async syncGroup(groupId, lastEpoch, _deviceId) {
4050
+ const channelId = this.channelByGroup.get(groupId);
4051
+ const current = channelId ? this.epochByChannel.get(channelId) ?? 0 : 0;
4052
+ if (current - lastEpoch > this.rejoinThreshold)
4053
+ return { action: "rejoin", messages: [] };
4054
+ const log = this.commitLog.get(groupId) ?? [];
4055
+ return {
4056
+ action: "replay",
4057
+ messages: log.filter((c) => c.epoch > lastEpoch).sort((a, b) => a.epoch - b.epoch).map((c) => ({
4058
+ id: c.id,
4059
+ epoch: c.epoch,
4060
+ message_type: "commit",
4061
+ sender_device_id: c.sender_device_id,
4062
+ payload: c.payload
4063
+ }))
4064
+ };
4065
+ }
4066
+ async distribute(groupId, req) {
4067
+ const channelId = this.channelByGroup.get(groupId);
4068
+ if (!channelId)
4069
+ throw new Error(`FakeConnectBackend.distribute: unknown group ${groupId}`);
4070
+ if (req.commit && req.expected_epoch != null) {
4071
+ const current = this.epochByChannel.get(channelId) ?? 0;
4072
+ if (req.expected_epoch !== current) {
4073
+ const e = new Error(`connect backend post failed: 409 EPOCH_CONFLICT (expected ${req.expected_epoch}, current ${current})`);
4074
+ e.status = 409;
4075
+ throw e;
4076
+ }
4077
+ }
4078
+ if (req.commit) {
4079
+ const log = this.commitLog.get(groupId) ?? [];
4080
+ log.push({
4081
+ id: `c${++this._seq}`,
4082
+ epoch: Number(req.commit_epoch ?? 0),
4083
+ payload: String(req.commit),
4084
+ sender_device_id: String(req.device_id ?? "")
4085
+ });
4086
+ this.commitLog.set(groupId, log);
4087
+ }
4088
+ const priorMembers = this.membersByChannel.get(channelId) ?? [];
4089
+ this.epochByChannel.set(channelId, req.commit_epoch ?? this.epochByChannel.get(channelId) ?? 0);
4090
+ let evictedDevice;
4091
+ if (req.op === "remove") {
4092
+ if (!req.removed_identity_id) {
4093
+ throw new Error("removed_identity_id required");
4094
+ }
4095
+ evictedDevice = req.removed_device_id ?? (req.removed_identity_id ? this.deviceByIdentity.get(req.removed_identity_id) : void 0);
4096
+ const remaining = (this.membersByChannel.get(channelId) ?? []).filter((d) => d !== evictedDevice);
4097
+ this.membersByChannel.set(channelId, remaining);
4098
+ if (evictedDevice)
4099
+ this.queues.delete(evictedDevice);
4100
+ } else {
4101
+ const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
4102
+ this.membersByChannel.set(channelId, members);
4103
+ }
4104
+ if (req.commit) {
4105
+ for (const dev of priorMembers) {
4106
+ if (dev === req.device_id)
4107
+ continue;
4108
+ if (dev === evictedDevice)
4109
+ continue;
4110
+ this.q(dev).push({
4111
+ queue_id: "q-" + this._seq++,
4112
+ message_id: "m-commit-" + this._seq++,
4113
+ group_id: groupId,
4114
+ message_type: "commit",
4115
+ sender_device_id: req.device_id,
4116
+ epoch: this.epochByChannel.get(channelId) ?? 0,
4117
+ payload: req.commit,
4118
+ connect_channel_id: channelId,
4119
+ created_at: ""
4120
+ });
4121
+ }
4122
+ }
4123
+ for (const w of req.welcomes ?? []) {
4124
+ this.q(w.target_device_id).push({
4125
+ queue_id: "q-" + this._seq++,
4126
+ message_id: "m-welcome-" + this._seq++,
4127
+ group_id: groupId,
4128
+ message_type: "welcome",
4129
+ sender_device_id: req.device_id,
4130
+ epoch: this.epochByChannel.get(channelId) ?? 0,
4131
+ payload: w.payload,
4132
+ connect_channel_id: channelId,
4133
+ created_at: ""
4134
+ });
4135
+ }
4136
+ }
4137
+ async pullDelivery(deviceId) {
4138
+ return this.queues.get(deviceId) ?? [];
4139
+ }
4140
+ async ackDelivery(deviceId, queueIds) {
4141
+ const queue = this.queues.get(deviceId);
4142
+ if (queue) {
4143
+ this.queues.set(deviceId, queue.filter((r) => !queueIds.includes(r.queue_id)));
4144
+ }
4145
+ }
4146
+ requestWelcomeCalls = [];
4147
+ async requestWelcome(groupId, deviceId, freshKpId) {
4148
+ this.requestWelcomeCalls.push({ groupId, deviceId, freshKpId });
4149
+ }
4150
+ // ADR 0004 C1: committer-work + lease stubs (in-memory for unit tests;
4151
+ // no sweep driver runs against the fake backend, so these are no-ops).
4152
+ async getCommitterWork(_channelId) {
4153
+ return { adds: [], removes: [] };
4154
+ }
4155
+ async acquireCommitLease(_channelId) {
4156
+ return "fake-lease-token";
4157
+ }
4158
+ async releaseCommitLease(_channelId, _token) {
4159
+ }
4160
+ async fetchResetAttestation(groupId) {
4161
+ return this.resetAttestations.get(groupId) ?? null;
4162
+ }
4163
+ // ── E2E helper: seal + fanout (mirrors the Python edge's Task-7 path) ──────
4164
+ async sendApplication(p) {
4165
+ const key = await p.sender.exporterKey(p.enclaveId, p.channelId, p.epoch);
4166
+ const messageId = "m-" + this._seq++;
4167
+ const replayNonce = bytesToBase64(crypto.getRandomValues(new Uint8Array(24)));
4168
+ const policy_binding = {
4169
+ binding_id: "b",
4170
+ version: "1",
4171
+ agent_skill_id: null,
4172
+ delegation_id: null,
4173
+ client_stage5_outcome: "allow",
4174
+ client_stage5_evaluators: []
4175
+ };
4176
+ const aad = await buildAssociatedData({
4177
+ message_id: messageId,
4178
+ channel_id: p.channelId,
4179
+ sender_id: p.senderId,
4180
+ mls_group_epoch: p.epoch,
4181
+ replay_nonce: replayNonce,
4182
+ policy_binding
4183
+ });
4184
+ const sealed = sealPayload({
4185
+ plaintext: new TextEncoder().encode(p.plaintext),
4186
+ key,
4187
+ associatedData: aad,
4188
+ suite: "xchacha20poly1305"
4189
+ });
4190
+ const envelope = {
4191
+ av_version: "1.0",
4192
+ message_id: messageId,
4193
+ channel_id: p.channelId,
4194
+ enclave_id: p.enclaveId,
4195
+ message_type: "text",
4196
+ sender_id: p.senderId,
4197
+ sender_type: "agent",
4198
+ identity_proof: { signature: "", algo: "ed25519", attested: false, canonicalization: "RFC8785" },
4199
+ trace_context: { traceparent: "" },
4200
+ policy_binding,
4201
+ mls_group_epoch: p.epoch,
4202
+ replay_nonce: replayNonce,
4203
+ payload_encrypted: bytesToBase64(sealed),
4204
+ references: []
4205
+ };
4206
+ const canonical = await canonicalEnvelopeBytes(envelope);
4207
+ const members = this.membersByChannel.get(p.channelId) ?? [];
4208
+ const groupId = this.groupIdByChannel.get(p.channelId) ?? "";
4209
+ for (const dev of members) {
4210
+ if (dev === p.senderDeviceId)
4211
+ continue;
4212
+ this.q(dev).push({
4213
+ queue_id: "q-" + this._seq++,
4214
+ message_id: messageId,
4215
+ group_id: groupId,
4216
+ message_type: "application",
4217
+ sender_device_id: p.senderDeviceId,
4218
+ epoch: p.epoch,
4219
+ payload: bytesToHex3(canonical),
4220
+ connect_channel_id: p.channelId,
4221
+ created_at: ""
4222
+ });
4223
+ }
4224
+ }
4225
+ // ── E2E helper: ingest an already-built envelope (client-side build/seal) ──
4226
+ //
4227
+ // Mirrors sendApplication's storage + fanout, but takes an AgentVaultEnvelope
4228
+ // the *client* already built, sealed, and signed (via buildOutboundEnvelope) —
4229
+ // exactly what the owner client hands to its injected `publish`. The server
4230
+ // edge stores the canonical envelope bytes (hex) and fans out one delivery row
4231
+ // per active member device except the sender's. We resolve the sender's device
4232
+ // from sender_id (recorded at registerDevice); if unknown, every member gets it.
4233
+ async ingestApplication(envelope) {
4234
+ const channelId = envelope.channel_id;
4235
+ const canonical = await canonicalEnvelopeBytes(envelope);
4236
+ const members = this.membersByChannel.get(channelId) ?? [];
4237
+ const groupId = this.groupIdByChannel.get(channelId) ?? "";
4238
+ const senderDeviceId = this.deviceByIdentity.get(envelope.sender_id);
4239
+ for (const dev of members) {
4240
+ if (dev === senderDeviceId)
4241
+ continue;
4242
+ this.q(dev).push({
4243
+ queue_id: "q-" + this._seq++,
4244
+ message_id: envelope.message_id,
4245
+ group_id: groupId,
4246
+ message_type: "application",
4247
+ sender_device_id: senderDeviceId ?? "",
4248
+ epoch: envelope.mls_group_epoch,
4249
+ payload: bytesToHex3(canonical),
4250
+ connect_channel_id: channelId,
4251
+ created_at: ""
4252
+ });
4253
+ }
4254
+ }
4255
+ };
3492
4256
 
3493
4257
  // ../connect-sdk-ts/dist/receive/inbox-cipher.js
3494
4258
  import { hkdf } from "@noble/hashes/hkdf";
@@ -3538,7 +4302,7 @@ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
3538
4302
 
3539
4303
  // ../connect-mls-store/dist/sqlite-store.js
3540
4304
  import { DatabaseSync } from "node:sqlite";
3541
- import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
4305
+ import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
3542
4306
 
3543
4307
  // ../connect-mls-store/dist/schema.js
3544
4308
  var SCHEMA_VERSION = 6;
@@ -3654,7 +4418,7 @@ var MlsStoreSealError = class extends Error {
3654
4418
  function encodeBundle(bundle) {
3655
4419
  const pp = bundle.privatePackage;
3656
4420
  const obj = {
3657
- pub: bytesToHex4(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
4421
+ pub: bytesToHex4(MLSGroupManager3.serializeKeyPackage(bundle.publicPackage)),
3658
4422
  initPriv: bytesToHex4(pp.initPrivateKey),
3659
4423
  hpkePriv: bytesToHex4(pp.hpkePrivateKey),
3660
4424
  sigPriv: bytesToHex4(pp.signaturePrivateKey)
@@ -3664,7 +4428,7 @@ function encodeBundle(bundle) {
3664
4428
  function decodeBundle(bytes) {
3665
4429
  const obj = JSON.parse(new TextDecoder().decode(bytes));
3666
4430
  return {
3667
- publicPackage: MLSGroupManager2.deserializeKeyPackage(hexToBytes3(obj.pub)),
4431
+ publicPackage: MLSGroupManager3.deserializeKeyPackage(hexToBytes3(obj.pub)),
3668
4432
  privatePackage: {
3669
4433
  initPrivateKey: hexToBytes3(obj.initPriv),
3670
4434
  hpkePrivateKey: hexToBytes3(obj.hpkePriv),
@@ -4168,9 +4932,10 @@ function mimeForName(name) {
4168
4932
  return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
4169
4933
  }
4170
4934
  async function prepareAttachment(filePath, caption, channelId, deps) {
4935
+ await deps.ready();
4171
4936
  const bytes = await deps.readFile(filePath);
4172
4937
  const name = basename(filePath);
4173
- const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
4938
+ const { encryptedData, fileKey, fileNonce, digest } = await deps.encryptFile(bytes);
4174
4939
  const blobId = await deps.uploadBlob(encryptedData, channelId);
4175
4940
  const body = {
4176
4941
  v: 1,
@@ -4187,12 +4952,13 @@ async function prepareAttachment(filePath, caption, channelId, deps) {
4187
4952
  return body;
4188
4953
  }
4189
4954
  async function materializeAttachment(body, ctx, deps) {
4955
+ await deps.ready();
4190
4956
  const ciphertext = await deps.downloadBlob(body.blobId);
4191
- const sha2566 = deps.computeFileDigest(ciphertext);
4957
+ const sha2566 = await deps.computeFileDigest(ciphertext);
4192
4958
  if (sha2566 !== body.digest) {
4193
4959
  throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
4194
4960
  }
4195
- const plain = deps.decryptFile(
4961
+ const plain = await deps.decryptFile(
4196
4962
  ciphertext,
4197
4963
  deps.base64ToBytes(body.fileKey),
4198
4964
  deps.base64ToBytes(body.fileNonce)
@@ -4205,6 +4971,16 @@ async function materializeAttachment(body, ctx, deps) {
4205
4971
  return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2566 };
4206
4972
  }
4207
4973
 
4974
+ // src/sodium-ready.ts
4975
+ import { generateEphemeralKeypair } from "@agentvault/crypto";
4976
+ var warming = null;
4977
+ function ensureSodiumReady() {
4978
+ return warming ??= generateEphemeralKeypair().then(() => void 0).catch((e) => {
4979
+ warming = null;
4980
+ throw e;
4981
+ });
4982
+ }
4983
+
4208
4984
  // src/memory/hermes-session-map.ts
4209
4985
  import { readFileSync } from "node:fs";
4210
4986
  import { homedir } from "node:os";
@@ -5102,6 +5878,10 @@ var BridgeAgent = class _BridgeAgent {
5102
5878
  }
5103
5879
  async start() {
5104
5880
  this.startedAt = Date.now();
5881
+ try {
5882
+ await this.ingestOnce();
5883
+ } catch {
5884
+ }
5105
5885
  await this.provider.publishKeyPackage();
5106
5886
  this.startReceivePump();
5107
5887
  if (this.selfHealSweepEnabled) {
@@ -5305,6 +6085,50 @@ var BridgeAgent = class _BridgeAgent {
5305
6085
  * DEVICE never held a leaf can 403 on the signal call. That's harmless and
5306
6086
  * must not abort the sweep for every other channel.
5307
6087
  */
6088
+ /**
6089
+ * One best-effort device-level KeyPackage rotation attempt.
6090
+ *
6091
+ * `publishKeyPackage` is a cheap no-op skip unless the SERVER says the held
6092
+ * KeyPackage is expired, so calling it every cycle costs one read and rotates at
6093
+ * most once per TTL. The catch logs once and continues: it MUST NOT rethrow, MUST
6094
+ * NOT abort the sweep, and MUST NOT retry inside the cycle. A KeyPackage that
6095
+ * cannot be rotated this cycle is retried on the next interval, and the sweep's
6096
+ * per-channel repair work is independent of it.
6097
+ */
6098
+ /**
6099
+ * Tri-state: "is this device STILL serving a server-expired KeyPackage at the end
6100
+ * of the cycle that just tried to fix it?"
6101
+ *
6102
+ * `false` is the only healthy value, and it requires a POSITIVE, current proof of
6103
+ * non-expiry. `null` is unknown and reads exactly like `true` in `degraded`, so a
6104
+ * daemon that cannot prove itself un-expired stops reporting healthy. That is the
6105
+ * OPPOSITE direction to the rotation guard, which never rotates on unknown.
6106
+ * Conflating the two directions recreates the lie this change removes.
6107
+ *
6108
+ * Initialised `null` and written only by `selfHealSweep`, so a sweep-off daemon
6109
+ * reads `null` and therefore degraded.
6110
+ */
6111
+ keypackageExpired = null;
6112
+ /** Resolve the flag from the SAME two inputs the guard uses, with every unknown
6113
+ * mapped to `null`. Never throws: a failure to measure is itself unknown. */
6114
+ async resolveKeypackageExpired() {
6115
+ try {
6116
+ const kpId = await this.provider.getLastPublishedKpId();
6117
+ if (kpId === null || kpId === "") return null;
6118
+ const exp = await this.backend.fetchKeyPackageExpiry(kpId);
6119
+ if (exp === null) return null;
6120
+ return isServerExpired(exp, Date.now());
6121
+ } catch {
6122
+ return null;
6123
+ }
6124
+ }
6125
+ async rotateDeviceKeyPackage() {
6126
+ try {
6127
+ await this.provider.publishKeyPackage();
6128
+ } catch (e) {
6129
+ console.error("[bridge] selfHealSweep: device KeyPackage rotation failed (continuing):", e);
6130
+ }
6131
+ }
5308
6132
  async selfHealSweep() {
5309
6133
  if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
5310
6134
  this.providerCapabilityMissing = true;
@@ -5313,7 +6137,16 @@ var BridgeAgent = class _BridgeAgent {
5313
6137
  );
5314
6138
  return;
5315
6139
  }
6140
+ if (typeof this.backend?.fetchKeyPackageExpiry !== "function") {
6141
+ this.providerCapabilityMissing = true;
6142
+ console.error(
6143
+ "[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."
6144
+ );
6145
+ return;
6146
+ }
5316
6147
  this.providerCapabilityMissing = false;
6148
+ await this.rotateDeviceKeyPackage();
6149
+ this.keypackageExpired = await this.resolveKeypackageExpired();
5317
6150
  const enclaves = await this.enclavesToCover();
5318
6151
  this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
5319
6152
  const seen = /* @__PURE__ */ new Set();
@@ -5561,12 +6394,24 @@ var BridgeAgent = class _BridgeAgent {
5561
6394
  }
5562
6395
  sendDeps() {
5563
6396
  return {
6397
+ ready: () => ensureSodiumReady(),
5564
6398
  uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
5565
6399
  encryptFile,
5566
6400
  bytesToBase64: bytesToBase642,
5567
6401
  readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
5568
6402
  };
5569
6403
  }
6404
+ receiveDeps() {
6405
+ return {
6406
+ ready: () => ensureSodiumReady(),
6407
+ downloadBlob: this.downloadBlobImpl,
6408
+ decryptFile,
6409
+ computeFileDigest,
6410
+ base64ToBytes: base64ToBytes2,
6411
+ mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
6412
+ writeFile: (p, data) => fsp.writeFile(p, data)
6413
+ };
6414
+ }
5570
6415
  async sendAttachment(channelId, filePath, caption) {
5571
6416
  this.enforceRateFloor("attachment", channelId);
5572
6417
  const joined = await this.reconcileJoin(channelId);
@@ -5622,8 +6467,11 @@ var BridgeAgent = class _BridgeAgent {
5622
6467
  /**
5623
6468
  * List channels in one enclave (default: the daemon's own home enclave).
5624
6469
  * Task 3: called per-*known* enclave by refreshChannelMeta so a
5625
- * cross-enclave room's is_dm gets resolved — see the scope caveat there
5626
- * (same-tenant only; a cross-tenant enclave 404s under the agent's bearer).
6470
+ * cross-enclave room's is_dm gets resolved. ⚠️ The old "same-tenant only; a
6471
+ * cross-tenant enclave 404s under the agent's bearer" caveat here is refuted
6472
+ * — see the CROSS-TENANT LISTING note on enclavesToCover. This route admits
6473
+ * any enclave the caller holds a live membership row in, partner tenants
6474
+ * included.
5627
6475
  */
5628
6476
  async listChannels(enclaveId = this.cfg.enclaveId) {
5629
6477
  const url = `${this.cfg.apiBase}/api/v1/enclaves/${enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
@@ -5678,12 +6526,16 @@ var BridgeAgent = class _BridgeAgent {
5678
6526
  * room is never listed under the home-enclave-only call, so its is_dm never
5679
6527
  * resolves and the @mention filter fails open for that room.
5680
6528
  *
5681
- * Each enclave is wrapped in its own try/catch (scope caveat — audit
5682
- * H2/tenant): list_channels_in_enclave filters on tenant_id, so a
5683
- * same-tenant enclave the agent has learned about resolves fine, but a
5684
- * cross-TENANT federated-partner enclave 404s under this agent's bearer;
5685
- * that enclave's channels are left unresolved (fail open) without aborting
5686
- * the other enclaves in the set.
6529
+ * Each enclave is wrapped in its own try/catch so one enclave's listing
6530
+ * failure leaves only that enclave's channels unresolved (fail open) without
6531
+ * aborting the other enclaves in the set.
6532
+ *
6533
+ * ⚠️ The old scope caveat here (audit H2/tenant) said
6534
+ * `list_channels_in_enclave` filters on tenant_id and 404s a cross-TENANT
6535
+ * federated-partner enclave. That is refuted — see the CROSS-TENANT LISTING
6536
+ * note on enclavesToCover. A partner enclave with a live membership row
6537
+ * resolves like any other, so the try/catch now covers transport errors,
6538
+ * 5xx, and genuine non-member 404s only.
5687
6539
  */
5688
6540
  async refreshChannelMeta() {
5689
6541
  const enclaves = await this.enclavesToCover();
@@ -5711,12 +6563,20 @@ var BridgeAgent = class _BridgeAgent {
5711
6563
  * failure right after a restart would read as "never succeeded" and slip
5712
6564
  * through the expected-gap branch — weaker than the old abort-on-failure).
5713
6565
  * A failure of a member of this set is a REGRESSION and degrades health; a
5714
- * failure of a never-succeeded enclave (partner: tenant-scoped 404, permanent
5715
- * pending backend authz) is an EXPECTED gap — reported, not degrading, or
5716
- * every federated agent would peg degraded forever. */
6566
+ * failure of a never-succeeded enclave is reported but does not degrade.
6567
+ *
6568
+ * ⚠️ The old justification for that asymmetry named a partner enclave's
6569
+ * "tenant-scoped 404, permanent pending backend authz" as the expected gap.
6570
+ * That premise is refuted — see the CROSS-TENANT LISTING note on
6571
+ * enclavesToCover; a partner enclave with a live membership row lists fine.
6572
+ * The asymmetry is kept on its remaining merit: an enclave this process has
6573
+ * never listed may simply be one this agent holds no live membership in, and
6574
+ * pegging health degraded forever on that is noise, not signal. */
5717
6575
  enclaveListEverSucceeded = /* @__PURE__ */ new Set();
5718
6576
  /** Warn-once-per-failure-streak, keyed by enclave id (cleared on success):
5719
- * a permanently-404ing partner enclave must not log 1440 lines a day. */
6577
+ * a persistently-failing enclave must not log 1440 lines a day. (The old
6578
+ * wording named "a permanently-404ing partner enclave" — refuted, see the
6579
+ * CROSS-TENANT LISTING note on enclavesToCover.) */
5720
6580
  enclaveListWarned = /* @__PURE__ */ new Set();
5721
6581
  static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
5722
6582
  enclaveEnumWarned = false;
@@ -5744,11 +6604,35 @@ var BridgeAgent = class _BridgeAgent {
5744
6604
  * membership changes are picked up within one TTL, which matches the sweep
5745
6605
  * cadence this exists to serve.
5746
6606
  *
5747
- * ⚠️ Residual gap, stated: the enumeration RETURNS cross-tenant partner
5748
- * enclaves, but `list_channels_in_enclave` is tenant-scoped and 404s them
5749
- * under this agent's bearer — so a partner enclave in this set still yields
5750
- * no channels. Callers must treat a per-enclave listing failure as skip-one,
5751
- * never abort-all. Closing that needs backend authz work, not daemon work.
6607
+ * ── CROSS-TENANT LISTING: the canonical statement for this file ──────────
6608
+ *
6609
+ * ⚠️ CORRECTED 2026-08-21 (#280 spec F6). This block, and five other comments
6610
+ * in this file, used to state that `list_channels_in_enclave` is tenant-
6611
+ * scoped and 404s a cross-tenant partner enclave under this agent's bearer,
6612
+ * leaving a partner enclave in this set with no channels. That has been FALSE
6613
+ * since #187 (`d7cc685a`, 2026-07-31). Verified by opening the code:
6614
+ *
6615
+ * - `list_channels_in_enclave` calls `_resolve_enclave_membership(...,
6616
+ * host_only=False)` (backend/app/api/v1/connect_channels.py:897-900).
6617
+ * - `host_only=False` OMITS the `ConnectEnclave.tenant_id == auth.tenant_id`
6618
+ * predicate and runs the membership query under `bypass_rls`, so the
6619
+ * host-owned enclave row is visible to a partner session (same file
6620
+ * :519-520, :531-543). The gate is membership, not tenancy.
6621
+ * - The channel query it then runs (same file :902-919) reads
6622
+ * `connect_channels` joined to `connect_channel_members`, and BOTH tables
6623
+ * are among the eight the schema deliberately leaves without RLS for
6624
+ * having no `tenant_id` column (alembic 0001_connect_initial_schema.py:
6625
+ * 750-755, :778-780). No policy filters those rows by tenant.
6626
+ *
6627
+ * So a partner-tenant agent holding a live `ConnectEnclaveMember` row
6628
+ * (`removed_at IS NULL`) gets 200 with that enclave's real channels. There is
6629
+ * no partner-enclave listing gap left to close, and no backend authz work
6630
+ * pending for it.
6631
+ *
6632
+ * What DOES still reach a per-enclave listing failure: a transport error, a
6633
+ * 5xx, or a genuine 404 for an enclave this agent holds no live membership
6634
+ * in. Those are real outages, so the skip-one, never-abort-all rule stands on
6635
+ * its own merit — it just no longer has a standing partner-enclave case.
5752
6636
  */
5753
6637
  async enclavesToCover() {
5754
6638
  const now = Date.now();
@@ -6204,14 +7088,7 @@ var BridgeAgent = class _BridgeAgent {
6204
7088
  const meta = await materializeAttachment(
6205
7089
  parsed,
6206
7090
  { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
6207
- {
6208
- downloadBlob: this.downloadBlobImpl,
6209
- decryptFile,
6210
- computeFileDigest,
6211
- base64ToBytes: base64ToBytes2,
6212
- mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
6213
- writeFile: (p, data) => fsp.writeFile(p, data)
6214
- }
7091
+ this.receiveDeps()
6215
7092
  );
6216
7093
  this.buffer.push({
6217
7094
  channel_id: m.channelId,
@@ -6394,8 +7271,10 @@ var BridgeAgent = class _BridgeAgent {
6394
7271
  sweep_enabled: this.selfHealSweepEnabled,
6395
7272
  observations_stale: observationsStale,
6396
7273
  diverged_channels: divergedChannels,
7274
+ keypackage_expired: this.keypackageExpired,
6397
7275
  // A2: the truthful roll-up gates read instead of `status` (D-3).
6398
- degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost,
7276
+ // `!== false` and not `=== true`: unknown must never read healthy.
7277
+ degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost || this.keypackageExpired !== false,
6399
7278
  channels: liveChannels,
6400
7279
  sweep_enclaves_failed: [...this.sweepEnclavesFailed],
6401
7280
  identity_id: this.cfg.identityId,
@@ -6405,6 +7284,8 @@ var BridgeAgent = class _BridgeAgent {
6405
7284
  filtered_room: this.filteredRoomMessages,
6406
7285
  rate_limited: this.rateLimitedSends,
6407
7286
  permanently_skipped: this.provider.permanentlySkippedCount(),
7287
+ quarantine_drained: this.provider.quarantineDrainedCount(),
7288
+ retired_drained: this.provider.retiredDrainedCount(),
6408
7289
  failed_attachments: this.failedAttachments,
6409
7290
  delivery_pings: this.pingsReceived,
6410
7291
  agent_sessions: this.authSessions,
@@ -6450,8 +7331,11 @@ var BridgeAgent = class _BridgeAgent {
6450
7331
  filteredRoomMessages: this.filteredRoomMessages,
6451
7332
  rateLimitedSends: this.rateLimitedSends,
6452
7333
  permanentlySkipped: this.provider.permanentlySkippedCount(),
7334
+ quarantineDrained: this.provider.quarantineDrainedCount(),
7335
+ retiredDrained: this.provider.retiredDrainedCount(),
6453
7336
  welcomeRequestsSent: this.welcomeRequestsSent,
6454
- failedAttachments: this.failedAttachments
7337
+ failedAttachments: this.failedAttachments,
7338
+ build: BRIDGE_VERSION
6455
7339
  };
6456
7340
  }
6457
7341
  async close() {
@@ -7150,6 +8034,7 @@ export {
7150
8034
  attachAgentWebSocket,
7151
8035
  capabilities,
7152
8036
  createHttpServer,
8037
+ ensureSodiumReady,
7153
8038
  errorBody,
7154
8039
  httpStatus,
7155
8040
  isLoopback,