@securecoms/agent-bridge 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,52 @@
1
+ // ../connect-sdk-ts/dist/service-log.js
2
+ var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
3
+ var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
4
+ function errorClass(error) {
5
+ if (error === null)
6
+ return "NullThrown";
7
+ if (typeof error !== "object" && typeof error !== "function") {
8
+ const primitive = {
9
+ string: "StringThrown",
10
+ number: "NumberThrown",
11
+ boolean: "BooleanThrown",
12
+ bigint: "BigIntThrown",
13
+ symbol: "SymbolThrown",
14
+ undefined: "UndefinedThrown"
15
+ };
16
+ return primitive[typeof error] ?? "UnknownThrown";
17
+ }
18
+ try {
19
+ const name = Object.getPrototypeOf(error)?.constructor?.name;
20
+ return typeof name === "string" && CLASS.test(name) ? name : "Error";
21
+ } catch {
22
+ return "Error";
23
+ }
24
+ }
25
+ function formatServiceLog(level, event, fields = {}, error) {
26
+ const safeEvent = TOKEN.test(event) ? event : "invalid_event";
27
+ const parts = [`[sdk] ${level} ${safeEvent}`];
28
+ for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
29
+ if (!TOKEN.test(key))
30
+ continue;
31
+ if (typeof value === "boolean")
32
+ parts.push(`${key}=${value}`);
33
+ else if (typeof value === "number" && Number.isFinite(value))
34
+ parts.push(`${key}=${value}`);
35
+ }
36
+ if (error !== void 0)
37
+ parts.push(`error_class=${errorClass(error)}`);
38
+ return parts.join(" ");
39
+ }
40
+ function writeServiceLog(level, event, fields, error) {
41
+ const line = formatServiceLog(level, event, fields, error);
42
+ if (level === "error")
43
+ console.error(line);
44
+ else if (level === "warn")
45
+ console.warn(line);
46
+ else
47
+ console.log(line);
48
+ }
49
+
1
50
  // ../connect-core/dist/cadence.js
2
51
  var SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
3
52
  var SELF_HEAL_SWEEP_JITTER_MS = 3e4;
@@ -1180,17 +1229,29 @@ var enc2 = new TextEncoder();
1180
1229
  var dec2 = new TextDecoder();
1181
1230
 
1182
1231
  // ../connect-clients-core/dist/filesafe/owner-pin.js
1232
+ function rejectionReason(e) {
1233
+ const msg = e instanceof Error ? e.message : "";
1234
+ if (msg.includes("does not chain"))
1235
+ return "not_chaining";
1236
+ if (msg.includes("bad handoff signature"))
1237
+ return "bad_signature";
1238
+ return "other";
1239
+ }
1183
1240
  async function resolvePinnedOwner(args2) {
1184
1241
  let pin = await args2.store.load(args2.enclaveId);
1185
1242
  if (pin === null) {
1186
1243
  pin = { g0Cred: args2.observedG0Cred, chain: [] };
1187
1244
  }
1188
1245
  let chain = pin.chain;
1189
- for (const link of args2.relayedLinks) {
1246
+ for (let i = 0; i < args2.relayedLinks.length; i++) {
1190
1247
  const tail = currentOwnerCred(pin.g0Cred, chain);
1191
1248
  try {
1192
- chain = await extendOwnerChain(chain, link, tail);
1193
- } catch {
1249
+ chain = await extendOwnerChain(chain, args2.relayedLinks[i], tail);
1250
+ } catch (e) {
1251
+ try {
1252
+ args2.onRejectedLink?.({ index: i, reason: rejectionReason(e) });
1253
+ } catch {
1254
+ }
1194
1255
  }
1195
1256
  }
1196
1257
  const next = { g0Cred: pin.g0Cred, chain };
@@ -1211,7 +1272,7 @@ var SoftwareKeyCustody = class _SoftwareKeyCustody {
1211
1272
  this.priv = priv;
1212
1273
  this.pub = pub;
1213
1274
  if (!quiet) {
1214
- console.warn("[AgentVault] using a software key (dev/CI only) -- envelopes are not attested. Use kms-hosted or byok custody in production.");
1275
+ writeServiceLog("warn", "custody.software_key_unattested");
1215
1276
  }
1216
1277
  }
1217
1278
  static generate(algo) {
@@ -1321,6 +1382,56 @@ var MalformedGroupRosterError = class extends ConnectSdkError {
1321
1382
  this.receivedType = receivedType;
1322
1383
  }
1323
1384
  };
1385
+ var MlsBackendHttpError = class extends ConnectSdkError {
1386
+ status;
1387
+ code;
1388
+ reason;
1389
+ /**
1390
+ * `code` (C2 T0): the backend's error code parsed from the response body
1391
+ * (`{"detail": "x"}`, `{"detail": {"error": "x"}}` or `{"error": "x"}`),
1392
+ * when there is one. The distribute outcome classifier (C2 T3) reads it
1393
+ * against `DISTRIBUTE_DEFINITE_CODES`; absent means "no code", which is
1394
+ * ambiguous by construction. It never enters the message, so the message
1395
+ * stays byte-identical to the pre-C2 one.
1396
+ *
1397
+ * `reason` (C2 T3): the 409 `EPOCH_CONFLICT` body's `reason`
1398
+ * (`stale_epoch`, `epoch_not_monotonic`, `superseded`) when present. The
1399
+ * add and remove sites read it to route a `superseded` refusal away from
1400
+ * the drain-and-retry path (LOW-2). Never in the message either.
1401
+ */
1402
+ constructor(path, status, code, reason) {
1403
+ super(`${path} failed: ${status}`);
1404
+ this.status = status;
1405
+ this.code = code;
1406
+ this.reason = reason;
1407
+ }
1408
+ };
1409
+ var PendingCommitUnresolvedError = class extends ConnectSdkError {
1410
+ channelId;
1411
+ constructor(channelId) {
1412
+ super("mls.pending_commit.unresolved: a previous commit on this conversation is still being confirmed; retry later");
1413
+ this.channelId = channelId;
1414
+ this.name = "PendingCommitUnresolvedError";
1415
+ }
1416
+ };
1417
+ var GroupSupersededError = class extends ConnectSdkError {
1418
+ channelId;
1419
+ groupId;
1420
+ constructor(channelId, groupId) {
1421
+ super("mls.group_superseded: the conversation's group was replaced by an owner reset; the change was not applied");
1422
+ this.channelId = channelId;
1423
+ this.groupId = groupId;
1424
+ this.name = "GroupSupersededError";
1425
+ }
1426
+ };
1427
+ var AttestedOrphanRefusedError = class extends ConnectSdkError {
1428
+ channelId;
1429
+ constructor(channelId) {
1430
+ super("mls.attested_orphan_refused: the conversation's reset did not complete; it needs the owner to reset again");
1431
+ this.channelId = channelId;
1432
+ this.name = "AttestedOrphanRefusedError";
1433
+ }
1434
+ };
1324
1435
 
1325
1436
  // ../connect-sdk-ts/dist/envelope-builder.js
1326
1437
  async function buildOutboundEnvelope(p) {
@@ -1447,6 +1558,94 @@ function leafIndicesForKeyPackage(mgr, kp) {
1447
1558
  // ../connect-sdk-ts/dist/groupkey/mls.js
1448
1559
  import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1449
1560
 
1561
+ // ../connect-sdk-ts/dist/mls/store.js
1562
+ function assertPendingCommitShape(rec) {
1563
+ if (rec.commitEpoch !== rec.request.commit_epoch) {
1564
+ throw new Error("pending commit: commitEpoch does not equal request.commit_epoch");
1565
+ }
1566
+ if (rec.op === "bootstrap" && rec.prior !== null) {
1567
+ throw new Error("pending commit: a bootstrap record must have prior === null");
1568
+ }
1569
+ if (rec.op === "add" || rec.op === "remove") {
1570
+ if (rec.prior === null) {
1571
+ throw new Error(`pending commit: an ${rec.op} record must have a prior MAIN`);
1572
+ }
1573
+ if (rec.groupId !== rec.prior.groupId) {
1574
+ throw new Error(`pending commit: an ${rec.op} record's groupId must equal prior.groupId`);
1575
+ }
1576
+ }
1577
+ }
1578
+ function isPendingReservation(rec) {
1579
+ return rec.request.commit === void 0;
1580
+ }
1581
+ function pendingReservation(p) {
1582
+ return {
1583
+ groupId: p.groupId,
1584
+ op: "supersede",
1585
+ request: { device_id: p.deviceId, commit_epoch: 0 },
1586
+ state: p.placeholder,
1587
+ commitEpoch: 0,
1588
+ prior: p.prior,
1589
+ createdAt: p.createdAt
1590
+ };
1591
+ }
1592
+ function pendingCommitKey(rec) {
1593
+ return { groupId: rec.groupId, commitEpoch: rec.commitEpoch, commit: rec.request.commit ?? "" };
1594
+ }
1595
+ function matchesPendingCommitKey(rec, k) {
1596
+ return rec.groupId === k.groupId && rec.commitEpoch === k.commitEpoch && (rec.request.commit ?? "") === k.commit;
1597
+ }
1598
+ function sameMainState(a, b) {
1599
+ if (!a || !b)
1600
+ return !a && !b;
1601
+ return a.groupState === b.groupState && a.ciphersuite === b.ciphersuite;
1602
+ }
1603
+
1604
+ // ../connect-sdk-ts/dist/mls/distribute-outcome.js
1605
+ var CODES = [
1606
+ { status: 409, code: "EPOCH_CONFLICT" },
1607
+ { status: 409, code: "welcome_target_not_approved_member" },
1608
+ { status: 403, code: "not_group_committer" },
1609
+ { status: 403, code: "not_authorized_to_remove" },
1610
+ { status: 403, code: "removal_would_empty_group" },
1611
+ { status: 403, code: "removal_would_drop_last_committer" },
1612
+ { status: 422, code: "device_identity_mismatch" },
1613
+ { status: 422, code: "no_target_devices" },
1614
+ { status: 400, code: "invalid_commit_hex" },
1615
+ { status: 400, code: "expected_epoch_required" },
1616
+ { status: 400, code: "removed_identity_id required" },
1617
+ { status: 400, code: "commit required" },
1618
+ { status: 404, code: "not_connect_group" },
1619
+ { status: 404, code: "group_missing" },
1620
+ { status: 403, code: "agent_paused" },
1621
+ { status: 403, code: "forbidden" },
1622
+ { status: 400, code: "mls.stale_epoch" }
1623
+ ];
1624
+ var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
1625
+ var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
1626
+ function parseErrorCode(body) {
1627
+ if (body === null || typeof body !== "object" || Array.isArray(body))
1628
+ return void 0;
1629
+ const b = body;
1630
+ if (typeof b.detail === "string")
1631
+ return b.detail;
1632
+ if (b.detail !== null && typeof b.detail === "object" && !Array.isArray(b.detail)) {
1633
+ const inner = b.detail.error;
1634
+ if (typeof inner === "string")
1635
+ return inner;
1636
+ }
1637
+ if (typeof b.error === "string")
1638
+ return b.error;
1639
+ return void 0;
1640
+ }
1641
+ function classifyDistributeError(err) {
1642
+ if (!(err instanceof MlsBackendHttpError))
1643
+ return "ambiguous";
1644
+ if (typeof err.code !== "string")
1645
+ return "ambiguous";
1646
+ return DEFINITE_KEYS.has(`${err.status} ${err.code}`) ? "definite" : "ambiguous";
1647
+ }
1648
+
1450
1649
  // ../connect-sdk-ts/dist/groupkey/operation-queue.js
1451
1650
  var MlsOperationQueue = class {
1452
1651
  chains = /* @__PURE__ */ new Map();
@@ -2269,9 +2468,52 @@ function getSignaturePublicKeyFromLeafIndex(ratchetTree, leafIndex) {
2269
2468
  }
2270
2469
 
2271
2470
  // ../connect-sdk-ts/dist/groupkey/mls.js
2471
+ var PENDING_INLINE_RESENDS = 2;
2472
+ var DEFAULT_PENDING_RESEND_DELAYS_MS = [250, 1e3];
2473
+ var PENDING_UNRESOLVED_LOG_INTERVAL_MS = 6e4;
2474
+ var PENDING_AUTO_RESOLVE_INTERVAL_MS = 6e4;
2475
+ var PENDING_RECEIVE_FAIL_LOG_INTERVAL_MS = 6e4;
2476
+ var PENDING_LEFT_ESCALATION_PULLS = 20;
2477
+ var PENDING_LEFT_ESCALATION_MS = 10 * 6e4;
2478
+ var PendingRowEscalatedError = class extends Error {
2479
+ constructor() {
2480
+ super("mls.pending_commit.left_row_escalated");
2481
+ this.name = "PendingRowEscalatedError";
2482
+ }
2483
+ };
2484
+ function sleepMs(ms) {
2485
+ return ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
2486
+ }
2487
+ function tagOf(mgr) {
2488
+ return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
2489
+ }
2490
+ function tagOfState(state) {
2491
+ const m = new MLSGroupManager2(state.ciphersuite);
2492
+ m.importState(state);
2493
+ return tagOf(m);
2494
+ }
2495
+ function sameMainTag(a, b) {
2496
+ if (a === null || b === null)
2497
+ return a === b;
2498
+ return a.groupId === b.groupId && a.epoch === b.epoch;
2499
+ }
2500
+ var RESERVATION_PLACEHOLDER_STATE = {
2501
+ groupState: "",
2502
+ ciphersuite: "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519"
2503
+ };
2504
+ function isDefiniteReinitRefusal(e) {
2505
+ return e instanceof MlsBackendHttpError && e.status >= 400 && e.status < 500;
2506
+ }
2507
+ function isSupersededRefusal(e) {
2508
+ return e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT" && e.reason === "superseded";
2509
+ }
2510
+ function isEpochConflict(e) {
2511
+ return e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT" && !isSupersededRefusal(e);
2512
+ }
2272
2513
  var LABEL = "agentvault-connect-v1";
2273
2514
  var DEFAULT_STUCK_THRESHOLD = 2;
2274
2515
  var DEFAULT_QUARANTINE_THRESHOLD = 8;
2516
+ var DELIVERY_PULL_PAGE = 100;
2275
2517
  var BootstrapRaceLostError = class extends Error {
2276
2518
  constructor() {
2277
2519
  super("bootstrap race lost: another device created the group; join instead");
@@ -2296,6 +2538,16 @@ var AmbiguousOwnLeafError = class extends Error {
2296
2538
  this.name = "AmbiguousOwnLeafError";
2297
2539
  }
2298
2540
  };
2541
+ var OwnLeafRemoveRefusedError = class extends Error {
2542
+ op;
2543
+ reason;
2544
+ constructor(op, reason) {
2545
+ super(`${op}: refusing a Remove that would or could evict this committer's own leaf (${reason})`);
2546
+ this.op = op;
2547
+ this.reason = reason;
2548
+ this.name = "OwnLeafRemoveRefusedError";
2549
+ }
2550
+ };
2299
2551
  var HealAddAfterRemoveError = class extends Error {
2300
2552
  constructor(cause) {
2301
2553
  super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
@@ -2427,8 +2679,67 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2427
2679
  * past-epoch {@link permanentlySkipped} drain. Read via
2428
2680
  * {@link retiredDrainedCount}. */
2429
2681
  retiredDrained = 0;
2682
+ /**
2683
+ * C1 (P0 containment): CUSTODY of every application row this provider has
2684
+ * decrypted, keyed by queue_id, until the server shows the row gone.
2685
+ *
2686
+ * Why: a row's message key comes from the LIVE exporter secret only, so once
2687
+ * any drain applies commit N->N+1 the first successful decrypt of an epoch-N
2688
+ * row is the ONLY one there will ever be. Before C1, internal drains
2689
+ * (reconcileForSend, joinAndReconcile, the committer 409 drain) decrypted such
2690
+ * rows, discarded the plaintext and applied the commit; the redelivered row
2691
+ * then failed AEAD, was classified permanentlySkipped and acked: silent loss.
2692
+ * A CONCURRENT receiveOnce that pulled the row before another drain applied
2693
+ * the commit lost it the same way (C1 review, Important #1).
2694
+ *
2695
+ * Rule: an application row whose queue_id is in custody is NEVER decrypted or
2696
+ * classified again, by any call. {@link receiveOnce} returns the stored entry
2697
+ * for it, un-acked, in the row's seq position, on every pull that contains the
2698
+ * row, so the caller's ack stays the only ack. An internal drain's discard is
2699
+ * therefore harmless (the next receiveOnce serves the entry), and a caller
2700
+ * that drops an entry without acking is offered it again on the next pull,
2701
+ * exactly like a row that still decrypts. Eviction happens only when an
2702
+ * own-device pull SHORTER than {@link DELIVERY_PULL_PAGE} does not contain the
2703
+ * row (acked, expired, dead-lettered or fenced; it cannot come back).
2704
+ *
2705
+ * Limit: memory only. A process restart empties custody, and an un-acked row
2706
+ * whose epoch has moved on then fails AEAD and is acked as permanentlySkipped
2707
+ * (C1 report: durable-sink follow-up).
2708
+ */
2709
+ custody = /* @__PURE__ */ new Map();
2710
+ /*
2711
+ * C2 DEVIATION FROM SPEC §4.2's IN-MEMORY PENDING SET, deliberate: there is
2712
+ * none. Every C2 decision (the send fence, the receive resolver, the
2713
+ * lock-entry resolve, promotion) reads the CHANNEL's DURABLE record, one
2714
+ * store read each. A per-process set can be wrong in BOTH directions across
2715
+ * tabs and processes (MEDIUM-1): it misses a record another tab wrote, and
2716
+ * it keeps fencing a channel another tab already resolved. The durable read
2717
+ * is right in both, and the plan already accepted one store read per send.
2718
+ * Nothing reads `listPendingCommits()` either, so one unreadable record
2719
+ * (T2's SQLite `MlsStoreSealError` refuses the whole list) cannot fence
2720
+ * every channel: it fences only its own, whose per-channel read throws.
2721
+ */
2722
+ /** Memo for the provider-start re-run of owed follow-ups (MEDIUM-3). */
2723
+ owedRerun = null;
2724
+ /** Last `mls.pending_commit.unresolved` log per channel (rate limit). */
2725
+ unresolvedLoggedAt = /* @__PURE__ */ new Map();
2726
+ /** Channels whose legacy (pre-C2) staging slot was already discarded by
2727
+ * this process (spec §4.5 "Legacy staging residue"). */
2728
+ legacyStagingChecked = /* @__PURE__ */ new Set();
2729
+ /** C2 T3c (H1): last automatic resolve per channel (rate limit). */
2730
+ autoResolveAt = /* @__PURE__ */ new Map();
2731
+ /** C2 T3c (H1): automatic resolves in flight, awaited by drainInFlight. */
2732
+ backgroundResolves = /* @__PURE__ */ new Set();
2733
+ /** C2 T3c (H1): per channel, the run of consecutive pulls that left rows,
2734
+ * and when it started. Cleared by a pull that leaves none. */
2735
+ leftStreak = /* @__PURE__ */ new Map();
2736
+ /** C2 T3c (H2): last receive_resolve_failed log per channel. */
2737
+ receiveFailLoggedAt = /* @__PURE__ */ new Map();
2430
2738
  constructor(o) {
2431
2739
  this.o = o;
2740
+ if (typeof o.onPendingCommitResolved !== "function") {
2741
+ throw new TypeError("MlsGroupKeyProvider: onPendingCommitResolved is required and must be a function");
2742
+ }
2432
2743
  this.suite = o.suite ?? "xchacha20poly1305";
2433
2744
  this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
2434
2745
  this.quarantineThreshold = Math.max(1, o.quarantineThreshold ?? DEFAULT_QUARANTINE_THRESHOLD);
@@ -2559,7 +2870,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2559
2870
  timer.unref?.();
2560
2871
  });
2561
2872
  const drain = (async () => {
2562
- while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0) {
2873
+ while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0 || this.backgroundResolves.size > 0) {
2874
+ await Promise.allSettled([...this.backgroundResolves]);
2563
2875
  await this.locks.idle();
2564
2876
  await this.publishLock.idle();
2565
2877
  }
@@ -2737,7 +3049,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2737
3049
  orderedDeviceIds
2738
3050
  });
2739
3051
  } else if (outcome !== "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
2740
- 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.`);
3052
+ writeServiceLog("warn", "guest.swap_skipped_add_noop");
2741
3053
  }
2742
3054
  return outcome;
2743
3055
  }
@@ -2805,17 +3117,23 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2805
3117
  try {
2806
3118
  await this.locks.run(channelId, async () => {
2807
3119
  const mgr = await this.manager(channelId);
2808
- for (const c of commits) {
2809
- if (c.epoch <= Number(mgr.epoch))
2810
- continue;
2811
- await mgr.decrypt(hexToBytes(c.payload));
2812
- applied++;
3120
+ try {
3121
+ for (const c of commits) {
3122
+ if (c.epoch <= Number(mgr.epoch))
3123
+ continue;
3124
+ await mgr.decrypt(hexToBytes(c.payload));
3125
+ applied++;
3126
+ }
3127
+ if (applied > 0)
3128
+ await this.saveMain(channelId, mgr);
3129
+ } catch (e) {
3130
+ if (applied > 0)
3131
+ this.evictIfCached(channelId, mgr);
3132
+ throw e;
2813
3133
  }
2814
- if (applied > 0)
2815
- await this.o.store.save(channelId, mgr.exportState());
2816
3134
  });
2817
3135
  } catch (e) {
2818
- console.warn(`[mls] resyncByReplay ${channelId.slice(0, 8)}: commit failed to apply after ${applied} \u2014 state is forked, not behind:`, e?.message);
3136
+ writeServiceLog("warn", "resync.replay_commit_failed", { applied }, e);
2819
3137
  return false;
2820
3138
  }
2821
3139
  if (applied > 0) {
@@ -2875,6 +3193,494 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2875
3193
  * delete (a re-delete is a no-op). On success the manager stays cached as
2876
3194
  * before — no eviction.
2877
3195
  */
3196
+ /**
3197
+ * Phase 3 C4: persist a manager that has ALREADY ADVANCED in memory by applying
3198
+ * a commit the SERVER can hand us again (a delivery row we have not acked, a
3199
+ * /sync replay). If the save throws, evict the cached manager before
3200
+ * rethrowing, so the next access reloads the persisted (pre-commit) state and
3201
+ * re-applies the still-queued commit.
3202
+ *
3203
+ * Why evict rather than keep the advanced cache: with the cache ahead of disk,
3204
+ * the redelivered commit no longer applies to it, the classifier sees a commit
3205
+ * at or below the live epoch and ACKS it as dead, and the disk keeps the old
3206
+ * epoch. The next restart loads that epoch while the server has moved on: a
3207
+ * silent fork (mls.evict-on-save-failure.test.ts).
3208
+ *
3209
+ * ⛔ ONLY for server-redeliverable commits. A committer's OWN commit that the
3210
+ * server already accepted cannot be re-applied from the wire (a member cannot
3211
+ * process its own commit), so evicting there throws away the only copy of the
3212
+ * true state. The committer paths keep their own handling.
3213
+ *
3214
+ * `MLSGroupManager.decrypt` assigns its new state only after processing
3215
+ * succeeds, so a decrypt that THROWS leaves the manager unchanged and needs no
3216
+ * eviction; only the advanced-but-unsaved case does.
3217
+ */
3218
+ async saveAdvancedOrEvict(channelId, mgr) {
3219
+ try {
3220
+ await this.saveMain(channelId, mgr);
3221
+ } catch (e) {
3222
+ this.evictIfCached(channelId, mgr);
3223
+ throw e;
3224
+ }
3225
+ }
3226
+ /**
3227
+ * C2 (spec §4.2, MEDIUM-1): the ONE writer of MAIN outside promotion. Every
3228
+ * MAIN save in this file goes through here (pinned by
3229
+ * `save-main-call-sites.arch.test.ts`), and here goes through the store's
3230
+ * `saveMainGuarded`, which refuses ATOMICALLY when the channel holds a
3231
+ * pending-commit record whose `prior` is not this manager's (group, epoch).
3232
+ * So while a commit's outcome is unknown, MAIN can move only within the
3233
+ * epoch the commit was built on, whether the write comes from this tab, a
3234
+ * second tab, or another process.
3235
+ *
3236
+ * On ANY throw the cached manager is evicted if it is still `mgr`, so the
3237
+ * cache never runs ahead of MAIN: a refusal (`PendingCommitUnresolvedError`)
3238
+ * and a raw store failure (SQLite `SQLITE_BUSY` after its busy timeout, an
3239
+ * IndexedDB abort) are the same fact here, "MAIN did not move". The error
3240
+ * propagates: a receive caller leaves the row un-acked (uncounted, for a
3241
+ * refusal), a committer caller fails.
3242
+ */
3243
+ async saveMain(channelId, mgr) {
3244
+ try {
3245
+ await this.o.store.saveMainGuarded(channelId, mgr.exportState(), tagOf(mgr));
3246
+ } catch (e) {
3247
+ this.evictIfCached(channelId, mgr);
3248
+ throw e;
3249
+ }
3250
+ }
3251
+ // ── C2: pending-commit records ────────────────────────────────────────────
3252
+ //
3253
+ // A commit is written to a durable record BEFORE /distribute, resolved only
3254
+ // by proof (a 2xx, a coded definite rejection, or a receive-side proof), and
3255
+ // RESENT byte-for-byte, never rebuilt. See spec §4 and the plan's Task T3.
3256
+ //
3257
+ // ⛔ IRREVERSIBILITY. Nothing below builds a commit, adds a member or removes
3258
+ // one. Recovery only resends `rec.request`, whose bytes were built, and
3259
+ // checked by the admission rule (duplicate-leaf) and the own-leaf guard
3260
+ // (C3), at the site that created the record, against the tree they apply
3261
+ // to. A resend that lands can land only on that tree: the server's
3262
+ // expected_epoch compare-and-set (one commit per (group, epoch), H4/T1a) and
3263
+ // the superseded refusal (§3.2) reject it anywhere else.
3264
+ nowMs() {
3265
+ return this.o.now?.() ?? Date.now();
3266
+ }
3267
+ /** The (group, epoch) of the DURABLE MAIN, or null when there is none. Read
3268
+ * from the store, not the cache: promotion compares against what is at
3269
+ * rest. */
3270
+ async durableMainTag(channelId) {
3271
+ const saved = await this.o.store.load(channelId);
3272
+ return saved ? tagOfState(saved) : null;
3273
+ }
3274
+ /** Start the provider-start re-run of owed follow-ups once per process,
3275
+ * detached (see {@link resumePendingCommits}). A failure is logged and the
3276
+ * memo clears, so the next call retries it. Synchronous: callers never wait
3277
+ * on the hook. */
3278
+ kickOwedRerun() {
3279
+ if (this.owedRerun)
3280
+ return;
3281
+ void this.resumePendingCommits().catch((e) => writeServiceLog("warn", "mls.pending_commit.owed_rerun_failed", {}, e));
3282
+ }
3283
+ /**
3284
+ * C2 (MEDIUM-3): provider-start work, idempotent per process (a rejected
3285
+ * run clears its memo). Re-runs every owed follow-up (the post-commit work
3286
+ * of a commit that landed and was promoted, whose hook had not yet returned)
3287
+ * through `onPendingCommitResolved` with `via: "resolver"`, clearing each
3288
+ * slot only after its hook returns. The provider starts this itself,
3289
+ * detached, on the first send-fence check or receive pass; a host may call
3290
+ * it at startup to run the owed work before anything else. The hook is
3291
+ * at-least-once, hence required to be idempotent.
3292
+ */
3293
+ async resumePendingCommits() {
3294
+ this.owedRerun ??= (async () => {
3295
+ for (const { channelId, owed } of await this.o.store.listOwedFollowUps()) {
3296
+ await this.runFollowUpHook(channelId, owed.op, owed.followUp, "resolver", owed.createdAt);
3297
+ }
3298
+ })().catch((e) => {
3299
+ this.owedRerun = null;
3300
+ throw e;
3301
+ });
3302
+ await this.owedRerun;
3303
+ }
3304
+ /** Run the hook for a landed commit that owes a follow-up, then clear the
3305
+ * owed slot. A throwing hook KEEPS the slot (re-run at the next provider
3306
+ * start) and is logged, never propagated: the commit itself landed and
3307
+ * MAIN is promoted, so failing the op would misreport it.
3308
+ *
3309
+ * C2 T3c (M1): the clear is keyed on the slot's `createdAt`, so it removes
3310
+ * only the slot whose hook just ran. The provider-start re-run is detached
3311
+ * and holds no channel lock; a newer commit's slot written meanwhile (it
3312
+ * overwrites the channel's slot) is kept for its own hook. */
3313
+ async runFollowUpHook(channelId, op, followUp, via, slotCreatedAt) {
3314
+ try {
3315
+ await this.o.onPendingCommitResolved(channelId, { op, outcome: "landed", followUp, via });
3316
+ } catch (e) {
3317
+ writeServiceLog("warn", "mls.pending_commit.hook_failed", {}, e);
3318
+ return;
3319
+ }
3320
+ await this.o.store.clearOwedFollowUp(channelId, slotCreatedAt);
3321
+ }
3322
+ /**
3323
+ * C2 (coordinator ruling on T2, MEDIUM-3): run and clear any follow-up still
3324
+ * OWED on this channel, and THROW if that fails. Called before a commit that
3325
+ * will itself carry a follow-up is built: every store's `commitPending`
3326
+ * overwrites the channel's owed slot, so promoting the new commit over an
3327
+ * undrained slot would silently drop the older owed work. Fail closed: a
3328
+ * hook that throws (or an unreadable slot) refuses the new commit and keeps
3329
+ * the old slot. Caller holds the channel lock.
3330
+ *
3331
+ * C2 T3c (M2): reads ONLY this channel's slot (`getOwedFollowUp`), so an
3332
+ * unreadable slot of another channel cannot refuse this channel's commit.
3333
+ * With `abandon` (the owner's explicit Reset), an UNREADABLE slot of this
3334
+ * channel is discarded (logged `mls.pending_commit.owed_abandoned`) rather
3335
+ * than blocking the owner's one way out; a hook that throws still refuses.
3336
+ */
3337
+ async drainOwedFollowUpUnlocked(channelId, abandon = false) {
3338
+ let owed;
3339
+ try {
3340
+ owed = await this.o.store.getOwedFollowUp(channelId);
3341
+ } catch (e) {
3342
+ if (!abandon)
3343
+ throw e;
3344
+ writeServiceLog("warn", "mls.pending_commit.owed_abandoned", {}, e);
3345
+ await this.o.store.clearOwedFollowUp(channelId);
3346
+ return;
3347
+ }
3348
+ if (!owed)
3349
+ return;
3350
+ await this.o.onPendingCommitResolved(channelId, {
3351
+ op: owed.op,
3352
+ outcome: "landed",
3353
+ followUp: owed.followUp,
3354
+ via: "resolver"
3355
+ });
3356
+ await this.o.store.clearOwedFollowUp(channelId, owed.createdAt);
3357
+ }
3358
+ /** Tell the host how a record resolved (no follow-up owed). A throwing hook
3359
+ * is logged and swallowed for the same reason as above. */
3360
+ async notifyPendingResolved(channelId, ev) {
3361
+ try {
3362
+ await this.o.onPendingCommitResolved(channelId, ev);
3363
+ } catch (e) {
3364
+ writeServiceLog("warn", "mls.pending_commit.hook_failed", {}, e);
3365
+ }
3366
+ }
3367
+ /** Persist the record BEFORE `/distribute`. A throw here propagates and the
3368
+ * commit is never sent (spec §4.1).
3369
+ *
3370
+ * C2 T3c: CREATE-ONLY, atomically in the store. This instance resolved the
3371
+ * channel's record at lock entry, but another tab or process holds its own
3372
+ * lock and may have staged a commit since. Overwriting that record would
3373
+ * orphan a commit that may land (the other instance could then never
3374
+ * promote it). So a present record refuses this commit with
3375
+ * `PendingCommitUnresolvedError` before anything is sent. */
3376
+ async stagePendingCommit(channelId, rec, replacing) {
3377
+ const opts = replacing ? { replacing } : { onlyIfAbsent: true };
3378
+ let wrote;
3379
+ try {
3380
+ wrote = await this.o.store.savePendingCommit(channelId, rec, opts);
3381
+ } catch (e) {
3382
+ if (replacing)
3383
+ await this.o.store.clearPendingCommit(channelId, replacing).catch(() => false);
3384
+ throw e;
3385
+ }
3386
+ if (!wrote)
3387
+ throw new PendingCommitUnresolvedError(channelId);
3388
+ }
3389
+ /** Send `rec.request` byte-for-byte, then up to `resends` more times while
3390
+ * the outcome stays ambiguous (spec §4.3). Never rebuilds the commit. */
3391
+ async sendPendingCommit(rec, resends) {
3392
+ const delays = this.o.pendingResendDelaysMs ?? DEFAULT_PENDING_RESEND_DELAYS_MS;
3393
+ let last;
3394
+ for (let attempt = 0; attempt <= resends; attempt++) {
3395
+ if (attempt > 0)
3396
+ await sleepMs(delays[Math.min(attempt - 1, delays.length - 1)] ?? 0);
3397
+ try {
3398
+ await this.o.backend.distribute(rec.groupId, rec.request);
3399
+ return { kind: "landed" };
3400
+ } catch (e) {
3401
+ if (classifyDistributeError(e) === "definite")
3402
+ return { kind: "definite", error: e };
3403
+ last = e;
3404
+ }
3405
+ }
3406
+ return { kind: "ambiguous", error: last };
3407
+ }
3408
+ /**
3409
+ * Apply a send outcome to the channel's record. Called under the channel lock.
3410
+ * - landed: custody of the epoch-N rows (C1b; skipped when there is no
3411
+ * prior MAIN), then conditional promotion.
3412
+ * - definite: the commit did not land. Clear the record, evict the cache,
3413
+ * tell the hook `not_landed`.
3414
+ * - ambiguous: KEEP the record, evict the cache (MAIN is still `prior`),
3415
+ * log the unresolved age (rate-limited). Nothing auto-discards on age.
3416
+ */
3417
+ async settlePendingCommit(channelId, rec, out, via, built) {
3418
+ if (out.kind === "landed") {
3419
+ if (rec.prior !== null)
3420
+ await this.custodyAppRowsBeforeOwnCommit(channelId, rec.prior.epoch);
3421
+ const p = await this.promotePendingUnlocked(channelId, via, rec, built);
3422
+ return p === "none" ? "vanished" : p;
3423
+ }
3424
+ if (out.kind === "definite") {
3425
+ await this.o.store.clearPendingCommit(channelId, pendingCommitKey(rec));
3426
+ this.managers.delete(channelId);
3427
+ await this.notifyPendingResolved(channelId, { op: rec.op, outcome: "not_landed", via });
3428
+ return "not_landed";
3429
+ }
3430
+ this.managers.delete(channelId);
3431
+ this.logPendingUnresolved(channelId, rec);
3432
+ return "ambiguous";
3433
+ }
3434
+ logPendingUnresolved(channelId, rec) {
3435
+ const now = this.nowMs();
3436
+ const last = this.unresolvedLoggedAt.get(channelId);
3437
+ if (last !== void 0 && now - last < PENDING_UNRESOLVED_LOG_INTERVAL_MS)
3438
+ return;
3439
+ this.unresolvedLoggedAt.set(channelId, now);
3440
+ writeServiceLog("warn", "mls.pending_commit.unresolved", {
3441
+ age_s: Math.max(0, Math.floor((now - rec.createdAt) / 1e3))
3442
+ });
3443
+ }
3444
+ /**
3445
+ * C2 (spec §4.2, H2): promote the record `proven` (whose landing this caller
3446
+ * PROVED: a 2xx for its bytes, or a trial decrypt under its state) ONLY if MAIN
3447
+ * is still the state the commit was built on. Caller holds the channel lock
3448
+ * (the receive path calls this inside the lock it already holds: LOW-4, the
3449
+ * queue is not reentrant).
3450
+ *
3451
+ * `prior` is compared by STRICT equality, and `null` means "no MAIN" (ruling
3452
+ * 2026-09-25): MAIN absent → promote; MAIN present at any group or epoch →
3453
+ * the record is stale. Match → `commitPending` (MAIN := rec.state, record
3454
+ * deleted, owed follow-up slot written, one atomic store step), install the
3455
+ * promoted manager, run the hook. Mismatch → discard the record, keep MAIN,
3456
+ * log `mls.pending_commit.stale_prior`: MAIN moved out of band (a v8 store
3457
+ * write after a downgrade, say), and promoting would rewind it.
3458
+ *
3459
+ * ⛔ C2 T3c (security review C1): EVERY store step here names `proven`'s key
3460
+ * (group, commit epoch, commit bytes) and the store checks it INSIDE the
3461
+ * step, together with "MAIN is exactly the state this caller just checked
3462
+ * against `prior`". A store shared by two tabs or processes may by now hold
3463
+ * ANOTHER record (the other instance resolved ours and staged its own, still
3464
+ * in flight). Promoting that record would make MAIN a state no server
3465
+ * accepted (a fork); clearing it would strand a commit that then lands.
3466
+ *
3467
+ * When `proven` is no longer in the store: it was resolved by another
3468
+ * instance. If MAIN is at or past `proven` in its group, that instance
3469
+ * promoted it (`proven` landed, so no foreign commit can hold its epoch):
3470
+ * report `landed` and drop the cache so MAIN reloads. Otherwise `none`.
3471
+ *
3472
+ * A throw from the store propagates with MAIN and the record unchanged (the
3473
+ * store's all-or-nothing contract).
3474
+ *
3475
+ * `built`: the manager an INLINE commit site built the commit with. Its state
3476
+ * IS `rec.state` (exported from it when the record was written, not mutated
3477
+ * since), so it is installed as the cached manager instead of a re-import,
3478
+ * exactly as the pre-C2 sites cached it after their save.
3479
+ */
3480
+ async promotePendingUnlocked(channelId, via, proven, built) {
3481
+ const key = pendingCommitKey(proven);
3482
+ for (let attempt = 0; attempt < 3; attempt++) {
3483
+ const mainState = await this.o.store.load(channelId);
3484
+ const main = mainState ? tagOfState(mainState) : null;
3485
+ const matches = proven.prior === null ? main === null : main !== null && main.groupId === proven.prior.groupId && main.epoch === proven.prior.epoch;
3486
+ if (!matches) {
3487
+ if (await this.o.store.clearPendingCommit(channelId, key)) {
3488
+ writeServiceLog("warn", "mls.pending_commit.stale_prior");
3489
+ await this.notifyPendingResolved(channelId, { op: proven.op, outcome: "stale_prior", via });
3490
+ return "stale_prior";
3491
+ }
3492
+ return this.provenRecordGone(channelId, proven);
3493
+ }
3494
+ const r = await this.o.store.commitPending(channelId, { ...key, main: mainState });
3495
+ if (r === "record_mismatch")
3496
+ return this.provenRecordGone(channelId, proven);
3497
+ if (r === "main_moved")
3498
+ continue;
3499
+ let mgr = built;
3500
+ if (!mgr) {
3501
+ mgr = new MLSGroupManager2(proven.state.ciphersuite);
3502
+ mgr.importState(proven.state);
3503
+ }
3504
+ this.managers.set(channelId, mgr);
3505
+ if (proven.followUp)
3506
+ await this.runFollowUpHook(channelId, proven.op, proven.followUp, via, proven.createdAt);
3507
+ else
3508
+ await this.notifyPendingResolved(channelId, { op: proven.op, outcome: "landed", via });
3509
+ return "landed";
3510
+ }
3511
+ throw new PendingCommitUnresolvedError(channelId);
3512
+ }
3513
+ /** C2 T3c (C1): the proven record is not in the store. See
3514
+ * {@link promotePendingUnlocked}. */
3515
+ async provenRecordGone(channelId, proven) {
3516
+ const mainState = await this.o.store.load(channelId);
3517
+ const main = mainState ? tagOfState(mainState) : null;
3518
+ if (main !== null && main.groupId === proven.groupId && main.epoch >= proven.commitEpoch) {
3519
+ this.managers.delete(channelId);
3520
+ writeServiceLog("info", "mls.pending_commit.promoted_elsewhere");
3521
+ return "landed";
3522
+ }
3523
+ return "none";
3524
+ }
3525
+ /**
3526
+ * C2 (spec §4.4 "Commit", plan step 3.6): resolve the channel's record at
3527
+ * lock entry, before a committer op, `ensureSession`, a reset or a supersede
3528
+ * builds anything. Resends the stored bytes and settles the outcome with
3529
+ * `via: "resolver"`. Returns `none` when there was no record (after
3530
+ * discarding any legacy pre-C2 staging slot once per channel per process:
3531
+ * MAIN is authoritative, and a staged state with no request cannot be
3532
+ * resent). The CALLER decides what `ambiguous` means; most throw
3533
+ * `PendingCommitUnresolvedError`.
3534
+ */
3535
+ async resolvePendingCommitUnlocked(channelId, resends = PENDING_INLINE_RESENDS, saw) {
3536
+ const rec = await this.o.store.loadPendingCommit(channelId);
3537
+ saw?.(rec);
3538
+ if (!rec) {
3539
+ if (!this.legacyStagingChecked.has(channelId)) {
3540
+ await this.o.store.discardStaging(channelId);
3541
+ this.legacyStagingChecked.add(channelId);
3542
+ }
3543
+ return "none";
3544
+ }
3545
+ if (isPendingReservation(rec)) {
3546
+ this.logPendingUnresolved(channelId, rec);
3547
+ return "ambiguous";
3548
+ }
3549
+ const out = await this.sendPendingCommit(rec, resends);
3550
+ const r = await this.settlePendingCommit(channelId, rec, out, "resolver");
3551
+ return r === "vanished" ? "none" : r;
3552
+ }
3553
+ /** {@link resolvePendingCommitUnlocked}, then refuse to continue while the
3554
+ * record stays ambiguous. The standard lock-entry guard. */
3555
+ async requirePendingResolvedUnlocked(channelId) {
3556
+ const r = await this.resolvePendingCommitUnlocked(channelId);
3557
+ if (r === "ambiguous")
3558
+ throw new PendingCommitUnresolvedError(channelId);
3559
+ return r;
3560
+ }
3561
+ /**
3562
+ * C2 (OQ-C2-1 ruling): the owner's explicit abandon, reached ONLY through
3563
+ * `resetSessionSupersede(..., { abandonPending: true })`. Discards the
3564
+ * record without knowing whether its commit landed; the attested reset that
3565
+ * follows is the correct recovery either way. Logs
3566
+ * `mls.pending_commit.abandoned` and tells the hook `abandoned`.
3567
+ */
3568
+ /**
3569
+ * C2 T3d (MEDIUM-1): `seen` is the record the owner's resolve ACTUALLY saw
3570
+ * and found ambiguous, or "unreadable" when that load threw. A seen record
3571
+ * is cleared KEYED: if another instance meanwhile proved it landed, promoted
3572
+ * it and staged a newer, unproven record, that newer record is not the one
3573
+ * the owner abandoned, and clearing it would strand a commit that may land.
3574
+ * Then nothing is cleared and this returns false (the caller refuses; the
3575
+ * owner retries). Only an UNREADABLE record is cleared unkeyed: nothing can
3576
+ * read its key, and this is the owner's one way out of it.
3577
+ */
3578
+ async abandonPendingCommitUnlocked(channelId, seen) {
3579
+ if (seen === "unreadable") {
3580
+ writeServiceLog("warn", "mls.pending_commit.abandon_unreadable");
3581
+ await this.o.store.clearPendingCommit(channelId);
3582
+ this.managers.delete(channelId);
3583
+ writeServiceLog("warn", "mls.pending_commit.abandoned");
3584
+ return true;
3585
+ }
3586
+ if (!await this.o.store.clearPendingCommit(channelId, pendingCommitKey(seen))) {
3587
+ writeServiceLog("warn", "mls.pending_commit.abandon_raced");
3588
+ return false;
3589
+ }
3590
+ this.managers.delete(channelId);
3591
+ writeServiceLog("warn", "mls.pending_commit.abandoned");
3592
+ await this.notifyPendingResolved(channelId, { op: seen.op, outcome: "abandoned", via: "resolver" });
3593
+ return true;
3594
+ }
3595
+ /**
3596
+ * The commit tail shared by the six commit sites (spec §4.1): persist the
3597
+ * record, send, settle inline. Returns `{ landed: true }` after promotion,
3598
+ * or the definite rejection for the site's own existing path. Throws
3599
+ * `PendingCommitUnresolvedError` when the outcome stays ambiguous after the
3600
+ * inline resends (the record is kept and the channel fenced), and a plain
3601
+ * content-free `Error` when promotion found MAIN moved out of band.
3602
+ */
3603
+ async commitWithPendingRecord(channelId, rec, built, replacing) {
3604
+ await this.stagePendingCommit(channelId, rec, replacing);
3605
+ const out = await this.sendPendingCommit(rec, PENDING_INLINE_RESENDS);
3606
+ const r = await this.settlePendingCommit(channelId, rec, out, "inline", built);
3607
+ if (r === "landed")
3608
+ return { landed: true };
3609
+ if (r === "not_landed")
3610
+ return { landed: false, error: out.error };
3611
+ if (r === "ambiguous")
3612
+ throw new PendingCommitUnresolvedError(channelId);
3613
+ throw new Error(`mls.pending_commit.${r}: the commit's prior state moved before promotion; not applied locally`);
3614
+ }
3615
+ /** Build the record for a commit about to be sent. `prior` is the MAIN the
3616
+ * commit was built on (null: none). */
3617
+ pendingRecord(op, groupId, request, mgr, prior, followUp) {
3618
+ return {
3619
+ groupId,
3620
+ op,
3621
+ request,
3622
+ state: mgr.exportState(),
3623
+ commitEpoch: Number(mgr.epoch),
3624
+ prior,
3625
+ ...followUp ? { followUp } : {},
3626
+ createdAt: this.nowMs()
3627
+ };
3628
+ }
3629
+ /**
3630
+ * C2 (spec §4.4 "Send", HIGH-3, MEDIUM-1): the send fence. Throws
3631
+ * `PendingCommitUnresolvedError` while the channel holds a pending record,
3632
+ * so no key is derived for SEND (message, vault KEK seal, 1:1 voice) at an
3633
+ * epoch a landed Remove may already have rotated away from. Re-reads the
3634
+ * DURABLE record on every call, so a record another tab or process wrote is
3635
+ * seen the first time, and a record another tab resolved no longer fences.
3636
+ * Receive never calls this: it derives through {@link exporterKeyUnfenced}.
3637
+ */
3638
+ async assertNotPending(channelId) {
3639
+ this.kickOwedRerun();
3640
+ if (await this.o.store.loadPendingCommit(channelId)) {
3641
+ this.scheduleAutoResolve(channelId);
3642
+ throw new PendingCommitUnresolvedError(channelId);
3643
+ }
3644
+ }
3645
+ /**
3646
+ * C2 T3c (H1): resolve the channel's pending record in the background, at
3647
+ * most once per minute per channel. Deferred to a later tick and run under
3648
+ * the channel lock like any lock-entry resolve (never inside the receive
3649
+ * lock that noticed it; the queue is not reentrant). It only RESENDS the
3650
+ * stored bytes, exactly as a committer's lock entry does. Failures are
3651
+ * logged, rate-limited, and otherwise ignored: the record stays and the
3652
+ * next trigger tries again.
3653
+ */
3654
+ scheduleAutoResolve(channelId) {
3655
+ const now = this.nowMs();
3656
+ const last = this.autoResolveAt.get(channelId);
3657
+ if (last !== void 0 && now - last < PENDING_AUTO_RESOLVE_INTERVAL_MS)
3658
+ return;
3659
+ this.autoResolveAt.set(channelId, now);
3660
+ const job = (async () => {
3661
+ await Promise.resolve();
3662
+ try {
3663
+ await this.locks.run(channelId, () => this.resolvePendingCommitUnlocked(channelId));
3664
+ } catch (e) {
3665
+ writeServiceLog("warn", "mls.pending_commit.auto_resolve_failed", {}, e);
3666
+ }
3667
+ })();
3668
+ this.backgroundResolves.add(job);
3669
+ void job.finally(() => this.backgroundResolves.delete(job));
3670
+ }
3671
+ /** C2 (for T6's stuck state): ms since the channel's pending record was
3672
+ * written, or null when it has none. A durable read; never throws on
3673
+ * absence. */
3674
+ async pendingCommitAgeMs(channelId) {
3675
+ const rec = await this.o.store.loadPendingCommit(channelId);
3676
+ return rec ? Math.max(0, this.nowMs() - rec.createdAt) : null;
3677
+ }
3678
+ /** Drop `channelId`'s cached manager only if it is still `mgr` (a concurrent
3679
+ * supersede may already have installed a different one, which must survive). */
3680
+ evictIfCached(channelId, mgr) {
3681
+ if (this.managers.get(channelId) === mgr)
3682
+ this.managers.delete(channelId);
3683
+ }
2878
3684
  async evictOnThrow(channelId, op) {
2879
3685
  try {
2880
3686
  return await op();
@@ -2883,7 +3689,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2883
3689
  throw e;
2884
3690
  }
2885
3691
  }
3692
+ /**
3693
+ * The public exporter key. C2 (HIGH-3): fenced. While the channel holds an
3694
+ * unresolved pending commit this throws `PendingCommitUnresolvedError`, so
3695
+ * nothing is sealed at an epoch a landed Remove may have rotated away from.
3696
+ *
3697
+ * ⚠️ C2 T3c (review M4) CORRECTED THIS COMMENT: not every external caller
3698
+ * SEALS. Its callers are the envelope builder and the fake's
3699
+ * `sendApplication` (seal), the vault KEK seal in connect-web (seal), AND
3700
+ * connect-web's vault OPEN (`useVault.ts` `openVaultKekset`, which peeks and
3701
+ * opens the sealed KEK set with it). The fence cannot tell a seal from an
3702
+ * open, so a pending record also fails the vault open closed (the accepted
3703
+ * HIGH-3 consequence; T6 maps it to the retryable not-ready outcome). The
3704
+ * same holds for {@link voiceMediaKey}: BOTH ends of a 1:1 call derive the
3705
+ * media key through it, so the callee's join fails closed too while a record
3706
+ * is pending. Receive-side derivations inside this file use
3707
+ * {@link exporterKeyUnfenced} and keep decrypting epoch N. A refused call
3708
+ * starts the automatic resolve (T3c, H1), so the fence lifts on its own
3709
+ * once the outcome is known.
3710
+ */
2886
3711
  async exporterKey(enclaveId, channelId, epoch) {
3712
+ await this.assertNotPending(channelId);
3713
+ return this.exporterKeyUnfenced(enclaveId, channelId, epoch);
3714
+ }
3715
+ /** C2: the unfenced exporter read, for RECEIVE derivations only
3716
+ * (`decryptApplicationRow`, `replayMissedApplicationMessages`). Reads MAIN,
3717
+ * which never moves past a pending record. */
3718
+ async exporterKeyUnfenced(enclaveId, channelId, epoch) {
2887
3719
  const mgr = await this.manager(channelId);
2888
3720
  return mgr.exportSecret(LABEL, buildExporterContext(enclaveId, channelId, epoch), 32);
2889
3721
  }
@@ -2904,6 +3736,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2904
3736
  * inside another `locks.run(channelId, ...)` for the same channel.
2905
3737
  */
2906
3738
  async voiceMediaKey(enclaveId, channelId, epoch, callId) {
3739
+ await this.assertNotPending(channelId);
2907
3740
  return this.locks.run(channelId, async () => {
2908
3741
  const mgr = await this.manager(channelId);
2909
3742
  return mgr.exportSecret(VOICE_MEDIA_EXPORTER_LABEL, buildVoiceExporterContext(enclaveId, channelId, epoch, callId), 32);
@@ -3060,13 +3893,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3060
3893
  const exp = kpId === "" ? null : await this.o.backend.fetchKeyPackageExpiry(kpId);
3061
3894
  const expired = exp !== null && isServerExpired(exp, this.o.now?.() ?? Date.now());
3062
3895
  if (!expired) {
3063
- console.warn("publishKeyPackage: pending bundle exists; skipping republish");
3896
+ writeServiceLog("warn", "keypackage.publish_skipped_pending");
3064
3897
  return kpId;
3065
3898
  }
3066
- console.warn(`publishKeyPackage: server KeyPackage expired for device ${this.o.deviceId.slice(0, 8)}; rotating`);
3899
+ writeServiceLog("warn", "keypackage.expired_rotation");
3067
3900
  await this.o.store.deletePendingBundle(this.o.deviceId);
3068
3901
  }
3069
- console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
3902
+ writeServiceLog("warn", "keypackage.minting_fresh");
3070
3903
  const mgr = new MLSGroupManager2();
3071
3904
  const bundle = await mgr.generateKeyPackage(this.o.identity);
3072
3905
  const hex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
@@ -3081,7 +3914,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3081
3914
  return this.o.store.loadLastKpId(this.o.deviceId);
3082
3915
  }
3083
3916
  async ensureSession(channelId, opts) {
3084
- return this.locks.run(channelId, () => this.ensureSessionUnlocked(channelId, opts));
3917
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
3918
+ await this.requirePendingResolvedUnlocked(channelId);
3919
+ return this.ensureSessionUnlocked(channelId, opts);
3920
+ }));
3085
3921
  }
3086
3922
  async ensureSessionUnlocked(channelId, opts) {
3087
3923
  if (!opts.forceReinit && await this.o.store.load(channelId))
@@ -3115,16 +3951,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3115
3951
  member_device_ids: [this.o.deviceId, ...others],
3116
3952
  welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
3117
3953
  });
3118
- await this.o.store.save(channelId, mgr.exportState());
3954
+ await this.saveMain(channelId, mgr);
3119
3955
  this.managers.set(channelId, mgr);
3120
3956
  return;
3121
3957
  }
3122
3958
  if (opts.bootstrap) {
3123
- const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
3124
- if (init.created === false)
3125
- throw new BootstrapRaceLostError();
3126
- const mgr = new MLSGroupManager2();
3127
- await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
3128
3959
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
3129
3960
  const kps = await this.o.backend.fetchKeyPackages(others);
3130
3961
  const pkgs = others.map((d) => {
@@ -3133,20 +3964,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3133
3964
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
3134
3965
  return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
3135
3966
  });
3967
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
3968
+ if (init.created === false) {
3969
+ if (!this.isAdoptableOrphan(init, others)) {
3970
+ if (this.isOrphanShape(init, others))
3971
+ throw new AttestedOrphanRefusedError(channelId);
3972
+ throw new BootstrapRaceLostError();
3973
+ }
3974
+ writeServiceLog("warn", "session.bootstrap_orphan_adopted");
3975
+ }
3976
+ const mgr = new MLSGroupManager2();
3977
+ await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
3136
3978
  const { commit, welcome } = await mgr.addMembers(pkgs);
3137
- await this.o.backend.distribute(init.group_id, {
3979
+ const rec = this.pendingRecord("bootstrap", init.group_id, {
3138
3980
  device_id: this.o.deviceId,
3139
3981
  commit: bytesToHex2(commit),
3140
3982
  commit_epoch: Number(mgr.epoch),
3141
3983
  expected_epoch: init.current_epoch,
3142
3984
  member_device_ids: [this.o.deviceId, ...others],
3143
3985
  welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
3144
- });
3145
- this.managers.set(channelId, mgr);
3146
- await this.o.store.save(channelId, mgr.exportState());
3986
+ }, mgr, null);
3987
+ const res = await this.commitWithPendingRecord(channelId, rec, mgr);
3988
+ if (!res.landed) {
3989
+ const e = res.error;
3990
+ if (e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT")
3991
+ throw new BootstrapRaceLostError();
3992
+ throw e;
3993
+ }
3147
3994
  } else {
3148
3995
  const rows = await this.o.backend.pullDelivery(this.o.deviceId);
3149
- const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
3996
+ const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
3150
3997
  if (!w)
3151
3998
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
3152
3999
  const mgr = new MLSGroupManager2();
@@ -3154,15 +4001,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3154
4001
  await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
3155
4002
  } catch (err) {
3156
4003
  this.recordDecryptError(w, err);
3157
- console.warn(`ensureSession(join): discarding un-openable Welcome for channel ${channelId} (queue_id=${w.queue_id}); private KeyPackage key no longer held`);
4004
+ writeServiceLog("warn", "session.unopenable_welcome_discarded", {}, err);
3158
4005
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
3159
4006
  return;
3160
4007
  }
3161
4008
  this.managers.set(channelId, mgr);
3162
- await this.o.store.save(channelId, mgr.exportState());
4009
+ await this.saveMain(channelId, mgr);
3163
4010
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
3164
4011
  }
3165
4012
  }
4013
+ /**
4014
+ * C5 (Phase 3): may a bootstrap ADOPT the existing server group `init`
4015
+ * returned (created=false)? Only an orphan of the right SHAPE
4016
+ * ({@link isOrphanShape}) that the server reports as NOT owner-attested:
4017
+ * `reset_attested === false`, strictly. C2 T3b: `true` and ABSENT both refuse
4018
+ * (fail closed; an older backend omits the field).
4019
+ */
4020
+ isAdoptableOrphan(init, others) {
4021
+ return this.isOrphanShape(init, others) && init.reset_attested === false;
4022
+ }
4023
+ /** C5: still at epoch 0 (no commit ever accepted, so nobody holds usable
4024
+ * state), with a roster that is exactly this device plus devices this
4025
+ * bootstrap Adds. Fails closed on a missing roster. Says nothing about
4026
+ * attestation (see {@link isAdoptableOrphan}). */
4027
+ isOrphanShape(init, others) {
4028
+ if (init.current_epoch !== 0)
4029
+ return false;
4030
+ if (!Array.isArray(init.member_device_ids))
4031
+ return false;
4032
+ return init.member_device_ids.every((d) => d === this.o.deviceId || others.includes(d));
4033
+ }
3166
4034
  /**
3167
4035
  * FileSafe vault owner bootstrap (Task 7, P4/P5 wiring): create the LOCAL
3168
4036
  * ts-mls group bound to a group_id the backend ALREADY minted, WITHOUT
@@ -3211,7 +4079,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3211
4079
  const mgr = new MLSGroupManager2();
3212
4080
  await mgr.createGroup(new TextEncoder().encode(groupId), bundle);
3213
4081
  this.managers.set(channelId, mgr);
3214
- await this.o.store.save(channelId, mgr.exportState());
4082
+ await this.saveMain(channelId, mgr);
3215
4083
  }
3216
4084
  /**
3217
4085
  * Committer-side: add a member to an ALREADY-bootstrapped (live) group.
@@ -3227,7 +4095,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3227
4095
  * parallel group_id-by-channel resolver.
3228
4096
  */
3229
4097
  async addMemberToLiveGroup(channelId, newDeviceId, opts) {
3230
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, opts)));
4098
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4099
+ await this.requirePendingResolvedUnlocked(channelId);
4100
+ return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, opts);
4101
+ }));
3231
4102
  }
3232
4103
  /**
3233
4104
  * Committer-side (owner): add a member to an already-bootstrapped VAULT
@@ -3248,7 +4119,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3248
4119
  if (!opts.expectedCredentialPubKey) {
3249
4120
  throw new VaultCredentialMismatchError(opts.expectedCredentialPubKey, "");
3250
4121
  }
3251
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, { vaultAdd: true, ...opts })));
4122
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4123
+ await this.requirePendingResolvedUnlocked(channelId);
4124
+ return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, { vaultAdd: true, ...opts });
4125
+ }));
3252
4126
  }
3253
4127
  async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
3254
4128
  const isVault = !!opts && "vaultAdd" in opts && opts.vaultAdd === true;
@@ -3303,13 +4177,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3303
4177
  deltaVouches
3304
4178
  });
3305
4179
  if (hasMalformedBinding(identityLeaves)) {
3306
- console.warn(`addMemberToLiveGroup: channel ${channelId} has a leaf with an unreadable device binding for identity ${claimedIdentity ?? "?"} \u2014 treated as UNBOUND, never as absent`);
4180
+ writeServiceLog("warn", "admission.malformed_leaf_binding");
3307
4181
  }
3308
4182
  if (targetLeaves.length > 0 && deltaVouches) {
3309
- 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.`);
4183
+ writeServiceLog("warn", "admission.possible_keypackage_substitution", {
4184
+ leaf_count: targetLeaves.length
4185
+ });
3310
4186
  }
3311
4187
  if (targetLeaves.length > 1) {
3312
- 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.`);
4188
+ writeServiceLog("warn", "admission.duplicate_device_leaves", {
4189
+ leaf_count: targetLeaves.length
4190
+ });
3313
4191
  }
3314
4192
  switch (verdict.decision) {
3315
4193
  case "admit":
@@ -3336,21 +4214,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3336
4214
  groupId = init.group_id;
3337
4215
  members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
3338
4216
  }
4217
+ if (new TextDecoder().decode(mgr.groupId) !== groupId) {
4218
+ throw new Error("addMemberToLiveGroup: group binding mismatch");
4219
+ }
3339
4220
  const priorEpoch = Number(mgr.epoch);
4221
+ const prior = tagOf(mgr);
3340
4222
  const { commit, welcome } = await mgr.addMembers([pkg]);
3341
- try {
3342
- await this.o.backend.distribute(groupId, {
3343
- device_id: this.o.deviceId,
3344
- commit: bytesToHex2(commit),
3345
- commit_epoch: Number(mgr.epoch),
3346
- expected_epoch: priorEpoch,
3347
- member_device_ids: members,
3348
- op: "add",
3349
- welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex2(welcome) }] : []
3350
- });
3351
- } catch (e) {
3352
- this.managers.delete(channelId);
3353
- if (_attempt >= 2)
4223
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("add", groupId, {
4224
+ device_id: this.o.deviceId,
4225
+ commit: bytesToHex2(commit),
4226
+ commit_epoch: Number(mgr.epoch),
4227
+ expected_epoch: priorEpoch,
4228
+ member_device_ids: members,
4229
+ op: "add",
4230
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex2(welcome) }] : []
4231
+ }, mgr, prior), mgr);
4232
+ if (!res.landed) {
4233
+ const e = res.error;
4234
+ if (isSupersededRefusal(e))
4235
+ throw new GroupSupersededError(channelId, groupId);
4236
+ if (_attempt >= 2 || !isEpochConflict(e))
3354
4237
  throw e;
3355
4238
  try {
3356
4239
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
@@ -3358,7 +4241,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3358
4241
  }
3359
4242
  return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
3360
4243
  }
3361
- await this.o.store.save(channelId, mgr.exportState());
3362
4244
  return "committed";
3363
4245
  }
3364
4246
  /**
@@ -3381,36 +4263,43 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3381
4263
  * of this group.
3382
4264
  */
3383
4265
  async removeMemberFromLiveGroup(channelId, identityId) {
3384
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeMemberFromLiveGroupUnlocked(channelId, identityId, 0)));
4266
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4267
+ await this.requirePendingResolvedUnlocked(channelId);
4268
+ return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, 0);
4269
+ }));
3385
4270
  }
3386
4271
  async removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt) {
3387
4272
  const mgr = await this.manager(channelId);
3388
4273
  const indices = mgr.leafIndicesForIdentity(identityId);
3389
4274
  if (indices.length === 0)
3390
4275
  return;
4276
+ this.refuseOwnLeafRemove(mgr, indices, "removeMember");
3391
4277
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
4278
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
4279
+ throw new Error("removeMemberFromLiveGroup: group binding mismatch");
4280
+ }
3392
4281
  const priorEpoch = Number(mgr.epoch);
4282
+ const prior = tagOf(mgr);
3393
4283
  const { commit } = await mgr.removeMembers(indices);
3394
4284
  if (Number(mgr.epoch) !== priorEpoch + 1) {
3395
4285
  throw new Error("removeMemberFromLiveGroup: unexpected epoch drift before distribute");
3396
4286
  }
3397
- await this.o.store.saveStaging(channelId, mgr.exportState());
3398
4287
  const members = init.member_device_ids ?? [this.o.deviceId];
3399
- try {
3400
- await this.o.backend.distribute(init.group_id, {
3401
- device_id: this.o.deviceId,
3402
- commit: bytesToHex2(commit),
3403
- commit_epoch: Number(mgr.epoch),
3404
- expected_epoch: priorEpoch,
3405
- member_device_ids: members,
3406
- op: "remove",
3407
- removed_identity_id: identityId,
3408
- welcomes: []
3409
- });
3410
- } catch (e) {
3411
- this.managers.delete(channelId);
3412
- await this.o.store.discardStaging(channelId);
3413
- if (_attempt >= 2)
4288
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
4289
+ device_id: this.o.deviceId,
4290
+ commit: bytesToHex2(commit),
4291
+ commit_epoch: Number(mgr.epoch),
4292
+ expected_epoch: priorEpoch,
4293
+ member_device_ids: members,
4294
+ op: "remove",
4295
+ removed_identity_id: identityId,
4296
+ welcomes: []
4297
+ }, mgr, prior), mgr);
4298
+ if (!res.landed) {
4299
+ const e = res.error;
4300
+ if (isSupersededRefusal(e))
4301
+ throw new GroupSupersededError(channelId, init.group_id);
4302
+ if (_attempt >= 2 || !isEpochConflict(e))
3414
4303
  throw e;
3415
4304
  try {
3416
4305
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
@@ -3418,7 +4307,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3418
4307
  }
3419
4308
  return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt + 1);
3420
4309
  }
3421
- await this.o.store.promoteStaging(channelId);
3422
4310
  }
3423
4311
  /**
3424
4312
  * Committer-side: REMOVE a SINGLE device (one leaf) from a live group (MD-3
@@ -3457,27 +4345,38 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3457
4345
  * should delegate to it.
3458
4346
  *
3459
4347
  * Returns null rather than throwing so every caller must decide explicitly what
3460
- * an unknown own-leaf means.
3461
- *
3462
- * ⚠️ The one caller today does NOT fail closed on null. In
3463
- * {@link removeDeviceFromLiveGroupUnlocked} the branch is
3464
- * `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
3465
- * identities at all, so a null return sends an OWN-identity removal down the
3466
- * positional `leaves[pos]` path, which is exactly the add-order guess the
3467
- * self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
3468
- * permutes that mapping after any Remove). Null is therefore currently
3469
- * indistinguishable from "foreign identity", and only the cardinality guard
3470
- * stands between it and evicting the wrong device of the same human.
3471
- * Correcting that is Phase 1b, not this comment.
4348
+ * an unknown own-leaf means. Every remove path now decides the same way: null
4349
+ * FAILS CLOSED (Phase 3 C3, {@link refuseOwnLeafRemove}). Before C3,
4350
+ * {@link removeDeviceFromLiveGroupUnlocked} treated null as "foreign identity"
4351
+ * and sent an own-identity removal down the positional add-order guess.
3472
4352
  */
3473
4353
  ownLeafIndex(mgr) {
3474
4354
  const idx = mgr.state?.privatePath?.leafIndex;
3475
4355
  return typeof idx === "number" ? idx : null;
3476
4356
  }
3477
- async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
3478
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
4357
+ /**
4358
+ * Phase 3 C3: the shared own-leaf refusal for every remove path. Throws
4359
+ * {@link OwnLeafRemoveRefusedError} when this committer's own leaf index is
4360
+ * unknown (fail closed) or is among `targetLeaves`. Returns the own leaf index
4361
+ * so a caller that needs it (self-exclusion) reads it from the same check.
4362
+ * Synchronous and called before any commit is built: a refusal distributes
4363
+ * nothing and leaves the cached manager untouched.
4364
+ */
4365
+ refuseOwnLeafRemove(mgr, targetLeaves, op) {
4366
+ const ownLeaf = this.ownLeafIndex(mgr);
4367
+ if (ownLeaf === null)
4368
+ throw new OwnLeafRemoveRefusedError(op, "own_leaf_unknown");
4369
+ if (targetLeaves.includes(ownLeaf))
4370
+ throw new OwnLeafRemoveRefusedError(op, "own_leaf");
4371
+ return ownLeaf;
3479
4372
  }
3480
- async removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt) {
4373
+ async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
4374
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4375
+ await this.requirePendingResolvedUnlocked(channelId);
4376
+ return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0);
4377
+ }));
4378
+ }
4379
+ async removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt) {
3481
4380
  const mgr = await this.manager(channelId);
3482
4381
  let shadowResolutionTaken;
3483
4382
  try {
@@ -3541,9 +4440,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3541
4440
  if (leaves.length !== opts.orderedDeviceIds.length) {
3542
4441
  throw new LeafRosterMismatchError();
3543
4442
  }
4443
+ if (deviceId === this.o.deviceId)
4444
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
3544
4445
  let leafIndex;
3545
4446
  const ownLeaf = this.ownLeafIndex(mgr);
3546
- if (ownLeaf !== null && leaves.includes(ownLeaf)) {
4447
+ if (ownLeaf === null)
4448
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf_unknown");
4449
+ if (leaves.includes(ownLeaf)) {
3547
4450
  const others = leaves.filter((i) => i !== ownLeaf);
3548
4451
  if (others.length !== 1) {
3549
4452
  throw new AmbiguousOwnLeafError(deviceId, leaves.length, others.length);
@@ -3553,30 +4456,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3553
4456
  leafIndex = leaves[pos];
3554
4457
  }
3555
4458
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
4459
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
4460
+ throw new Error("removeDeviceFromLiveGroup: group binding mismatch");
4461
+ }
3556
4462
  const priorEpoch = Number(mgr.epoch);
4463
+ const prior = tagOf(mgr);
3557
4464
  const { commit } = await mgr.removeMembers([leafIndex]);
3558
4465
  if (Number(mgr.epoch) !== priorEpoch + 1) {
3559
4466
  throw new Error("removeDeviceFromLiveGroup: unexpected epoch drift before distribute");
3560
4467
  }
3561
- await this.o.store.saveStaging(channelId, mgr.exportState());
3562
4468
  const members = init.member_device_ids ?? [this.o.deviceId];
3563
4469
  const removedIdentity = typeof opts.identity === "string" ? opts.identity : bytesToHex2(opts.identity);
3564
- try {
3565
- await this.o.backend.distribute(init.group_id, {
3566
- device_id: this.o.deviceId,
3567
- commit: bytesToHex2(commit),
3568
- commit_epoch: Number(mgr.epoch),
3569
- expected_epoch: priorEpoch,
3570
- member_device_ids: members,
3571
- op: "remove",
3572
- removed_identity_id: removedIdentity,
3573
- removed_device_id: deviceId,
3574
- welcomes: []
3575
- });
3576
- } catch (e) {
3577
- this.managers.delete(channelId);
3578
- await this.o.store.discardStaging(channelId);
3579
- if (_attempt >= 2)
4470
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
4471
+ device_id: this.o.deviceId,
4472
+ commit: bytesToHex2(commit),
4473
+ commit_epoch: Number(mgr.epoch),
4474
+ expected_epoch: priorEpoch,
4475
+ member_device_ids: members,
4476
+ op: "remove",
4477
+ removed_identity_id: removedIdentity,
4478
+ removed_device_id: deviceId,
4479
+ welcomes: []
4480
+ }, mgr, prior), mgr);
4481
+ if (!res.landed) {
4482
+ const e = res.error;
4483
+ if (isSupersededRefusal(e))
4484
+ throw new GroupSupersededError(channelId, init.group_id);
4485
+ if (_attempt >= 2 || !isEpochConflict(e))
3580
4486
  throw e;
3581
4487
  try {
3582
4488
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
@@ -3584,7 +4490,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3584
4490
  }
3585
4491
  return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
3586
4492
  }
3587
- await this.o.store.promoteStaging(channelId);
3588
4493
  }
3589
4494
  /**
3590
4495
  * Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
@@ -3642,7 +4547,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3642
4547
  * re-runs on every attempt.
3643
4548
  */
3644
4549
  async removeVaultLeafFromLiveGroup(channelId, expectedLeafSignaturePubKeyHex, opts) {
3645
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeVaultLeafUnlocked(channelId, expectedLeafSignaturePubKeyHex, opts, 0)));
4550
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4551
+ await this.requirePendingResolvedUnlocked(channelId);
4552
+ return this.removeVaultLeafUnlocked(channelId, expectedLeafSignaturePubKeyHex, opts, 0);
4553
+ }));
3646
4554
  }
3647
4555
  async removeVaultLeafUnlocked(channelId, expectedHex, opts, _attempt) {
3648
4556
  const mgr = await this.manager(channelId);
@@ -3668,33 +4576,37 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3668
4576
  const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
3669
4577
  if (claimed !== opts.expectedIdentityId)
3670
4578
  throw new VaultLeafTOCTOUError();
4579
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeVaultLeaf");
4580
+ const followUp = opts.deviceId && opts.enclaveId ? { kind: "vault-remove", enclaveId: opts.enclaveId, deviceId: opts.deviceId } : void 0;
4581
+ if (followUp)
4582
+ await this.drainOwedFollowUpUnlocked(channelId);
3671
4583
  const priorEpoch = Number(mgr.epoch);
4584
+ const prior = tagOf(mgr);
3672
4585
  const { commit } = await mgr.removeMembers([leafIndex]);
3673
4586
  if (Number(mgr.epoch) !== priorEpoch + 1) {
3674
4587
  throw new Error("removeVaultLeafFromLiveGroup: unexpected epoch drift before distribute");
3675
4588
  }
3676
4589
  const groupId = new TextDecoder().decode(mgr.groupId);
3677
- await this.o.store.saveStaging(channelId, mgr.exportState());
3678
- try {
3679
- await this.o.backend.distribute(groupId, {
3680
- device_id: this.o.deviceId,
3681
- commit: bytesToHex2(commit),
3682
- commit_epoch: Number(mgr.epoch),
3683
- expected_epoch: priorEpoch,
3684
- member_device_ids: opts.vaultContext.memberDeviceIds,
3685
- op: "remove",
3686
- removed_identity_id: opts.expectedIdentityId,
3687
- // Device-granular distribute (C1): when opts.deviceId is empty (no
3688
- // owner-local leafSigKey->deviceId binding), OMIT removed_device_id —
3689
- // the server falls back to identity-level eviction. The crypto
3690
- // removal above already stands regardless; only delivery degrades.
3691
- ...opts.deviceId ? { removed_device_id: opts.deviceId } : {},
3692
- welcomes: []
3693
- });
3694
- } catch (e) {
3695
- this.managers.delete(channelId);
3696
- await this.o.store.discardStaging(channelId);
3697
- if (_attempt >= 2)
4590
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", groupId, {
4591
+ device_id: this.o.deviceId,
4592
+ commit: bytesToHex2(commit),
4593
+ commit_epoch: Number(mgr.epoch),
4594
+ expected_epoch: priorEpoch,
4595
+ member_device_ids: opts.vaultContext.memberDeviceIds,
4596
+ op: "remove",
4597
+ removed_identity_id: opts.expectedIdentityId,
4598
+ // Device-granular distribute (C1): when opts.deviceId is empty (no
4599
+ // owner-local leafSigKey->deviceId binding), OMIT removed_device_id —
4600
+ // the server falls back to identity-level eviction. The crypto
4601
+ // removal above already stands regardless; only delivery degrades.
4602
+ ...opts.deviceId ? { removed_device_id: opts.deviceId } : {},
4603
+ welcomes: []
4604
+ }, mgr, prior, followUp), mgr);
4605
+ if (!res.landed) {
4606
+ const e = res.error;
4607
+ if (isSupersededRefusal(e))
4608
+ throw new GroupSupersededError(channelId, groupId);
4609
+ if (_attempt >= 2 || !isEpochConflict(e))
3698
4610
  throw e;
3699
4611
  try {
3700
4612
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
@@ -3702,7 +4614,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3702
4614
  }
3703
4615
  return this.removeVaultLeafUnlocked(channelId, expectedHex, opts, _attempt + 1);
3704
4616
  }
3705
- await this.o.store.promoteStaging(channelId);
3706
4617
  }
3707
4618
  /**
3708
4619
  * Committer-side: HEAL a permanently-diverged member by Remove(stale leaf) then
@@ -3793,6 +4704,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3793
4704
  */
3794
4705
  async healDivergedMember(channelId, memberDeviceId, opts) {
3795
4706
  return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4707
+ await this.requirePendingResolvedUnlocked(channelId);
3796
4708
  const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
3797
4709
  const hex = kps[memberDeviceId];
3798
4710
  if (!hex)
@@ -3819,7 +4731,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3819
4731
  }
3820
4732
  }
3821
4733
  if (leaves.length === 1) {
3822
- 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.`);
4734
+ writeServiceLog("warn", "heal.single_leaf_device_unattributed");
3823
4735
  }
3824
4736
  let removeLanded = false;
3825
4737
  if (leaves.length === 1) {
@@ -3921,6 +4833,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3921
4833
  */
3922
4834
  async resetChannelForRejoin(channelId) {
3923
4835
  await this.locks.run(channelId, async () => {
4836
+ const r = await this.resolvePendingCommitUnlocked(channelId);
4837
+ if (r === "ambiguous")
4838
+ throw new PendingCommitUnresolvedError(channelId);
4839
+ if (r === "landed") {
4840
+ writeServiceLog("info", "mls.pending_commit.resolved_pending");
4841
+ return;
4842
+ }
3924
4843
  this.evictChannelState(channelId);
3925
4844
  await this.o.store.delete(channelId);
3926
4845
  await this.o.store.discardStaging(channelId);
@@ -3945,7 +4864,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3945
4864
  * (required subset of supplied) is still satisfied even though it never
3946
4865
  * goes through addMembers. */
3947
4866
  async resetSessionSupersede(channelId, opts) {
3948
- return this.locks.run(channelId, async () => {
4867
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4868
+ let resolved;
4869
+ let seen = "unreadable";
4870
+ try {
4871
+ resolved = await this.resolvePendingCommitUnlocked(channelId, PENDING_INLINE_RESENDS, (r) => {
4872
+ if (r)
4873
+ seen = r;
4874
+ });
4875
+ } catch (e) {
4876
+ if (opts.abandonPending !== true)
4877
+ throw e;
4878
+ resolved = "ambiguous";
4879
+ }
4880
+ if (resolved === "ambiguous") {
4881
+ if (opts.abandonPending !== true)
4882
+ throw new PendingCommitUnresolvedError(channelId);
4883
+ if (!await this.abandonPendingCommitUnlocked(channelId, seen))
4884
+ throw new PendingCommitUnresolvedError(channelId);
4885
+ }
4886
+ await this.drainOwedFollowUpUnlocked(channelId, opts.abandonPending === true);
3949
4887
  let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
3950
4888
  if (!bundle) {
3951
4889
  await this.publishKeyPackage();
@@ -3954,9 +4892,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3954
4892
  if (!bundle)
3955
4893
  throw new Error("resetSessionSupersede: call publishKeyPackage() first");
3956
4894
  const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
3957
- const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
3958
- const mgr = new MLSGroupManager2();
3959
- await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
3960
4895
  const kps = await this.o.backend.fetchKeyPackages(others);
3961
4896
  const pkgs = others.map((d) => {
3962
4897
  const hex = kps[d];
@@ -3964,19 +4899,62 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3964
4899
  throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
3965
4900
  return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
3966
4901
  });
3967
- const { commit, welcome } = await mgr.addMembers(pkgs);
3968
- await this.o.backend.distribute(reinit.group_id, {
4902
+ const mainState0 = await this.o.store.load(channelId);
4903
+ const prior0 = mainState0 ? tagOfState(mainState0) : null;
4904
+ const reservation = pendingReservation({
4905
+ groupId: opts.newGroupId,
4906
+ deviceId: this.o.deviceId,
4907
+ prior: prior0,
4908
+ placeholder: mainState0 ?? RESERVATION_PLACEHOLDER_STATE,
4909
+ createdAt: this.nowMs()
4910
+ });
4911
+ if (!await this.o.store.savePendingCommit(channelId, reservation, { onlyIfAbsent: true })) {
4912
+ throw new PendingCommitUnresolvedError(channelId);
4913
+ }
4914
+ const reservationKey = pendingCommitKey(reservation);
4915
+ const mainState1 = await this.o.store.load(channelId);
4916
+ const prior1 = mainState1 ? tagOfState(mainState1) : null;
4917
+ let prior = prior0;
4918
+ if (!sameMainTag(prior0, prior1)) {
4919
+ const corrected = { ...reservation, prior: prior1, state: mainState1 ?? RESERVATION_PLACEHOLDER_STATE };
4920
+ if (!await this.o.store.savePendingCommit(channelId, corrected, { replacing: reservationKey })) {
4921
+ await this.o.store.clearPendingCommit(channelId, reservationKey).catch(() => false);
4922
+ throw new PendingCommitUnresolvedError(channelId);
4923
+ }
4924
+ prior = prior1;
4925
+ }
4926
+ let reinit;
4927
+ try {
4928
+ reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
4929
+ } catch (e) {
4930
+ if (isDefiniteReinitRefusal(e))
4931
+ await this.o.store.clearPendingCommit(channelId, reservationKey);
4932
+ throw e;
4933
+ }
4934
+ let mgr;
4935
+ let commit;
4936
+ let welcome;
4937
+ try {
4938
+ mgr = new MLSGroupManager2();
4939
+ await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
4940
+ ({ commit, welcome } = await mgr.addMembers(pkgs));
4941
+ } catch (e) {
4942
+ await this.o.store.clearPendingCommit(channelId, reservationKey);
4943
+ throw e;
4944
+ }
4945
+ const staleMemberDeviceIds = reinit.stale_member_device_ids ?? [];
4946
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("supersede", reinit.group_id, {
3969
4947
  device_id: this.o.deviceId,
3970
4948
  commit: bytesToHex2(commit),
3971
4949
  commit_epoch: Number(mgr.epoch),
3972
4950
  expected_epoch: reinit.current_epoch,
3973
4951
  member_device_ids: [this.o.deviceId, ...others],
3974
4952
  welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
3975
- });
3976
- await this.o.store.save(channelId, mgr.exportState());
3977
- this.managers.set(channelId, mgr);
3978
- return { staleMemberDeviceIds: reinit.stale_member_device_ids ?? [] };
3979
- });
4953
+ }, mgr, prior, { kind: "supersede", staleMemberDeviceIds }), mgr, reservationKey);
4954
+ if (!res.landed)
4955
+ throw res.error;
4956
+ return { staleMemberDeviceIds };
4957
+ }));
3980
4958
  }
3981
4959
  /** Returns true when an in-memory MLS manager is live for the channel.
3982
4960
  * Cheap synchronous check; does NOT consult the store. */
@@ -4041,7 +5019,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4041
5019
  const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(m.payload)));
4042
5020
  if (envelope.channel_id !== channelId)
4043
5021
  continue;
4044
- const key = await this.locks.run(channelId, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
5022
+ const key = await this.locks.run(channelId, () => this.exporterKeyUnfenced(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
4045
5023
  const aad = await buildAssociatedData({
4046
5024
  message_id: envelope.message_id,
4047
5025
  channel_id: envelope.channel_id,
@@ -4238,7 +5216,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4238
5216
  } catch (err) {
4239
5217
  this.recordDecryptError(row, err);
4240
5218
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
4241
- console.warn(`[trace] supersede joinFromWelcome FAILED ch=${channelId.slice(0, 8)} g=${row.group_id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)} -> self-heal`);
5219
+ writeServiceLog("debug", "supersede.welcome_open_failed", {}, err);
4242
5220
  }
4243
5221
  let requested = false;
4244
5222
  try {
@@ -4256,6 +5234,23 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4256
5234
  if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
4257
5235
  return refuse("supersede refused: post-join group id != attested new_group_id");
4258
5236
  }
5237
+ let dead = null;
5238
+ try {
5239
+ dead = await this.o.store.loadPendingCommit(channelId);
5240
+ await this.resolvePendingCommitUnlocked(channelId, 0);
5241
+ } catch (e) {
5242
+ writeServiceLog("warn", "mls.pending_commit.supersede_resolve_failed", {}, e);
5243
+ }
5244
+ if (dead)
5245
+ await this.o.store.clearPendingCommit(channelId, pendingCommitKey(dead));
5246
+ try {
5247
+ const after = await this.o.store.loadPendingCommit(channelId);
5248
+ if (after && after.groupId !== row.group_id) {
5249
+ await this.o.store.clearPendingCommit(channelId, pendingCommitKey(after));
5250
+ }
5251
+ } catch (e) {
5252
+ writeServiceLog("warn", "mls.pending_commit.supersede_resolve_failed", {}, e);
5253
+ }
4259
5254
  this.evictChannelState(channelId);
4260
5255
  await this.o.store.delete(channelId);
4261
5256
  await this.o.store.discardStaging(channelId);
@@ -4263,13 +5258,21 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4263
5258
  this.errors.delete(channelId);
4264
5259
  this.supersedeRefusals.delete(`${channelId}:${row.group_id}`);
4265
5260
  this.managers.set(channelId, mgr);
4266
- await this.o.store.save(channelId, mgr.exportState());
5261
+ await this.saveMain(channelId, mgr);
4267
5262
  await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
4268
5263
  return true;
4269
5264
  });
4270
5265
  }
4271
5266
  async receiveOnce(deviceId) {
5267
+ this.kickOwedRerun();
4272
5268
  const rows = await this.o.backend.pullDelivery(deviceId);
5269
+ if (deviceId === this.o.deviceId && this.custody.size && rows.length < DELIVERY_PULL_PAGE) {
5270
+ const pulledIds = new Set(rows.map((r) => r.queue_id));
5271
+ for (const qid of this.custody.keys()) {
5272
+ if (!pulledIds.has(qid))
5273
+ this.custody.delete(qid);
5274
+ }
5275
+ }
4273
5276
  const appEntries = [];
4274
5277
  const handshakeAcked = [];
4275
5278
  const nackIds = [];
@@ -4278,7 +5281,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4278
5281
  const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
4279
5282
  const ordered = [...welcomes, ...nonWelcomes];
4280
5283
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
4281
- 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(", "));
5284
+ writeServiceLog("debug", "delivery.rows_pulled", { count: ordered.length });
4282
5285
  }
4283
5286
  const classCtx = /* @__PURE__ */ new Map();
4284
5287
  const classCtxFor = async (cid) => {
@@ -4305,10 +5308,58 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4305
5308
  }
4306
5309
  return ctx;
4307
5310
  };
5311
+ const pendingAtPull = /* @__PURE__ */ new Map();
5312
+ const leftThisPull = /* @__PURE__ */ new Map();
5313
+ const pullNow = this.nowMs();
5314
+ const escalatedAtPull = (cid) => {
5315
+ const st = this.leftStreak.get(cid);
5316
+ return st !== void 0 && st.pulls >= PENDING_LEFT_ESCALATION_PULLS && pullNow - st.since >= PENDING_LEFT_ESCALATION_MS;
5317
+ };
5318
+ const hadPendingAtPull = async (cid) => {
5319
+ let v = pendingAtPull.get(cid);
5320
+ if (v === void 0) {
5321
+ try {
5322
+ v = await this.o.store.loadPendingCommit(cid) !== null;
5323
+ } catch {
5324
+ v = true;
5325
+ }
5326
+ pendingAtPull.set(cid, v);
5327
+ }
5328
+ return v;
5329
+ };
4308
5330
  for (const row of ordered) {
4309
5331
  try {
4310
5332
  if (row.message_type !== "welcome" && row.connect_channel_id) {
4311
5333
  await classCtxFor(row.connect_channel_id);
5334
+ if (await hadPendingAtPull(row.connect_channel_id)) {
5335
+ const pcid = row.connect_channel_id;
5336
+ let verdict;
5337
+ try {
5338
+ verdict = await this.resolveRowAgainstPending(pcid, row, classCtx.get(pcid));
5339
+ } catch (e) {
5340
+ const now = this.nowMs();
5341
+ const last = this.receiveFailLoggedAt.get(pcid);
5342
+ if (last === void 0 || now - last >= PENDING_RECEIVE_FAIL_LOG_INTERVAL_MS) {
5343
+ this.receiveFailLoggedAt.set(pcid, now);
5344
+ writeServiceLog("warn", "mls.pending_commit.receive_resolve_failed", {}, e);
5345
+ }
5346
+ verdict = { kind: "leave" };
5347
+ }
5348
+ if (verdict.kind === "leave") {
5349
+ leftThisPull.set(pcid, (leftThisPull.get(pcid) ?? 0) + 1);
5350
+ if (escalatedAtPull(pcid))
5351
+ throw new PendingRowEscalatedError();
5352
+ continue;
5353
+ }
5354
+ if (verdict.kind === "acked") {
5355
+ handshakeAcked.push(row.queue_id);
5356
+ continue;
5357
+ }
5358
+ if (verdict.kind === "entry") {
5359
+ appEntries.push(verdict.entry);
5360
+ continue;
5361
+ }
5362
+ }
4312
5363
  }
4313
5364
  if (row.message_type === "welcome") {
4314
5365
  const wcid = row.connect_channel_id;
@@ -4323,14 +5374,19 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4323
5374
  heldGroupId = null;
4324
5375
  }
4325
5376
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
4326
- console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} heldGroup=${heldGroupId ? heldGroupId.slice(0, 8) : "NULL"} -> ${heldGroupId === null ? "SKIP(manager-unloadable)" : heldGroupId === row.group_id ? "stray-ack" : "trySupersede"}`);
5377
+ if (heldGroupId === null)
5378
+ writeServiceLog("debug", "delivery.welcome_manager_unloadable");
5379
+ else if (heldGroupId === row.group_id)
5380
+ writeServiceLog("debug", "delivery.welcome_stray");
5381
+ else
5382
+ writeServiceLog("debug", "delivery.welcome_supersede_candidate");
4327
5383
  }
4328
5384
  if (heldGroupId !== null && heldGroupId === row.group_id) {
4329
5385
  handshakeAcked.push(row.queue_id);
4330
5386
  } else if (heldGroupId !== null && heldGroupId !== row.group_id) {
4331
5387
  if (this.supersedeBackoffActive(wcid, row.group_id)) {
4332
5388
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
4333
- console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} g=${row.group_id.slice(0, 8)} -> SKIP(refusal back-off)`);
5389
+ writeServiceLog("debug", "delivery.welcome_refusal_backoff");
4334
5390
  }
4335
5391
  } else {
4336
5392
  const ok = await this.trySupersede(wcid, row, heldGroupId);
@@ -4339,7 +5395,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4339
5395
  }
4340
5396
  }
4341
5397
  } else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
4342
- console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} -> NO-LOCAL-STATE (leave un-acked for join path)`);
5398
+ writeServiceLog("debug", "delivery.welcome_no_local_state");
4343
5399
  }
4344
5400
  }
4345
5401
  } else if (row.message_type === "commit") {
@@ -4350,7 +5406,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4350
5406
  await this.locks.run(cid, async () => {
4351
5407
  const mgr = await this.manager(cid);
4352
5408
  await mgr.decrypt(hexToBytes(row.payload));
4353
- await this.o.store.save(cid, mgr.exportState());
5409
+ await this.saveAdvancedOrEvict(cid, mgr);
4354
5410
  advancedEpoch = Number(mgr.epoch);
4355
5411
  });
4356
5412
  this.recordDecryptSuccess(cid);
@@ -4361,36 +5417,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4361
5417
  }
4362
5418
  handshakeAcked.push(row.queue_id);
4363
5419
  } else {
4364
- const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
4365
- if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
4366
- throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
4367
- }
4368
- const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
4369
- const aad = await buildAssociatedData({
4370
- message_id: envelope.message_id,
4371
- channel_id: envelope.channel_id,
4372
- sender_id: envelope.sender_id,
4373
- mls_group_epoch: envelope.mls_group_epoch,
4374
- replay_nonce: envelope.replay_nonce,
4375
- policy_binding: envelope.policy_binding
4376
- });
4377
- const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
4378
- const appChannelId = row.connect_channel_id ?? envelope.channel_id;
4379
- this.recordDecryptSuccess(appChannelId);
4380
- appEntries.push({
4381
- queueId: row.queue_id,
4382
- messageId: envelope.message_id,
4383
- channelId: appChannelId,
4384
- plaintext,
4385
- envelope,
4386
- receivedAt: Date.now(),
4387
- retryCount: 0,
4388
- expires_at: row.expires_at,
4389
- voiceMode: row.voice_mode ?? void 0,
4390
- voiceDefaultResponder: row.voice_default_responder ?? void 0
4391
- });
5420
+ const held = this.custody.get(row.queue_id);
5421
+ appEntries.push(held ? { ...held } : await this.decryptApplicationRow(row, { lockHeld: false }));
4392
5422
  }
4393
5423
  } catch (err) {
5424
+ const heldAfterWait = row.message_type === "application" ? this.custody.get(row.queue_id) : void 0;
5425
+ if (heldAfterWait) {
5426
+ appEntries.push({ ...heldAfterWait });
5427
+ continue;
5428
+ }
5429
+ if (err instanceof PendingCommitUnresolvedError) {
5430
+ continue;
5431
+ }
4394
5432
  this.recordDecryptError(row, err);
4395
5433
  const cid = row.connect_channel_id;
4396
5434
  let drained = false;
@@ -4435,6 +5473,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4435
5473
  }
4436
5474
  }
4437
5475
  }
5476
+ for (const [cid, had] of pendingAtPull) {
5477
+ if (!had)
5478
+ continue;
5479
+ if ((leftThisPull.get(cid) ?? 0) === 0) {
5480
+ this.leftStreak.delete(cid);
5481
+ continue;
5482
+ }
5483
+ const st = this.leftStreak.get(cid);
5484
+ if (st)
5485
+ st.pulls += 1;
5486
+ else
5487
+ this.leftStreak.set(cid, { pulls: 1, since: pullNow });
5488
+ this.scheduleAutoResolve(cid);
5489
+ }
4438
5490
  if (handshakeAcked.length)
4439
5491
  await this.o.backend.ackDelivery(deviceId, handshakeAcked);
4440
5492
  if (nackIds.length) {
@@ -4448,6 +5500,195 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4448
5500
  }
4449
5501
  return appEntries;
4450
5502
  }
5503
+ /**
5504
+ * C2 (spec §4.4 "Receive resolves the record from the queue"; plan 3.8):
5505
+ * decide one delivery row of a channel that holds a pending record. Runs
5506
+ * under the channel lock and RE-READS the record inside it (a row earlier in
5507
+ * the same pull may have resolved it).
5508
+ *
5509
+ * Device-wide pulls arrive in `seq` order, and C1c puts every epoch-N row
5510
+ * before the commit that moves to N+1, so the first row for this channel past
5511
+ * epoch N is decisive:
5512
+ * 1. A row of the record's group at or below `prior.epoch` (an epoch-N app
5513
+ * row, an old duplicate) → `normal`: decrypt at MAIN and ack as usual.
5514
+ * Nothing at or below N can move MAIN.
5515
+ * 2. A COMMIT at `commitEpoch` in the record's group is someone else's (the
5516
+ * server never fans a commit back to its sender). If it applies at MAIN,
5517
+ * that proves ours was lost: clear the record, THEN save MAIN (a crash
5518
+ * between them redelivers the commit), hook `not_landed`.
5519
+ * 3. A row framed at `commitEpoch` (an app row at `commitEpoch`, or a commit
5520
+ * at `commitEpoch + 1`): trial it on a throwaway manager imported from
5521
+ * `rec.state`. If it applies, the group is provably on OUR epoch (a
5522
+ * cryptographic proof, stronger than a 2xx): promote with the UNLOCKED
5523
+ * promotion inside this held lock (LOW-4: the queue is not reentrant),
5524
+ * then process the row normally.
5525
+ * 4. Anything else of the record's group (fits neither state, or is past
5526
+ * both) → `leave`: un-acked, no nack, no error count, no dead-letter.
5527
+ *
5528
+ * LOW-5: cases 2 and 3 key on `row.group_id === rec.groupId`. A supersede
5529
+ * record targets the NEW group and a bootstrap record has no MAIN, so no row
5530
+ * of ANOTHER group ever resolves either; case 2 needs MAIN in the record's
5531
+ * group, so it applies to add and remove records only, and a supersede or
5532
+ * bootstrap record resolves by case 3 or by resend. Rows of another group
5533
+ * (the supersede's old group, a retired group) take the `normal` paths:
5534
+ * `saveMainGuarded` keeps any of them from moving MAIN past the record.
5535
+ *
5536
+ * Throws propagate to the caller, which leaves the row un-acked and
5537
+ * uncounted (a failed promotion included).
5538
+ */
5539
+ async resolveRowAgainstPending(channelId, row, ctx) {
5540
+ return this.locks.run(channelId, async () => {
5541
+ let rec;
5542
+ try {
5543
+ rec = await this.o.store.loadPendingCommit(channelId);
5544
+ } catch (e) {
5545
+ if (row.message_type === "application")
5546
+ return { kind: "normal" };
5547
+ throw e;
5548
+ }
5549
+ if (!rec) {
5550
+ return { kind: "normal" };
5551
+ }
5552
+ if (row.group_id !== rec.groupId)
5553
+ return { kind: "normal" };
5554
+ if (isPendingReservation(rec))
5555
+ return { kind: "leave" };
5556
+ const rowEpoch = typeof row.epoch === "number" ? row.epoch : void 0;
5557
+ if (rowEpoch === void 0)
5558
+ return { kind: "leave" };
5559
+ const mainInRecGroup = rec.prior !== null && rec.prior.groupId === rec.groupId;
5560
+ if (row.message_type === "commit" && rowEpoch === rec.commitEpoch) {
5561
+ if (!mainInRecGroup)
5562
+ return { kind: "leave" };
5563
+ const mgr = await this.manager(channelId);
5564
+ const at = tagOf(mgr);
5565
+ if (at.groupId !== rec.prior.groupId || at.epoch !== rec.prior.epoch)
5566
+ return { kind: "leave" };
5567
+ try {
5568
+ await mgr.decrypt(hexToBytes(row.payload));
5569
+ } catch {
5570
+ return { kind: "leave" };
5571
+ }
5572
+ if (!await this.o.store.clearPendingCommit(channelId, pendingCommitKey(rec))) {
5573
+ this.managers.delete(channelId);
5574
+ return { kind: "leave" };
5575
+ }
5576
+ await this.saveAdvancedOrEvict(channelId, mgr);
5577
+ this.recordDecryptSuccess(channelId);
5578
+ if (ctx)
5579
+ ctx.liveCommitEpoch = Number(mgr.epoch);
5580
+ await this.notifyPendingResolved(channelId, { op: rec.op, outcome: "not_landed", via: "resolver" });
5581
+ return { kind: "acked" };
5582
+ }
5583
+ const framedAtOurs = row.message_type === "application" && rowEpoch === rec.commitEpoch || row.message_type === "commit" && rowEpoch === rec.commitEpoch + 1;
5584
+ if (framedAtOurs) {
5585
+ const trial = new MLSGroupManager2(rec.state.ciphersuite);
5586
+ trial.importState(rec.state);
5587
+ let applies;
5588
+ if (row.message_type === "application") {
5589
+ applies = await this.trialOpenApplication(row, trial);
5590
+ } else {
5591
+ try {
5592
+ await trial.decrypt(hexToBytes(row.payload));
5593
+ applies = true;
5594
+ } catch {
5595
+ applies = false;
5596
+ }
5597
+ }
5598
+ if (!applies)
5599
+ return { kind: "leave" };
5600
+ const p = await this.promotePendingUnlocked(channelId, "resolver", rec);
5601
+ if (p !== "landed")
5602
+ return { kind: "normal" };
5603
+ if (row.message_type === "application") {
5604
+ return { kind: "entry", entry: await this.decryptApplicationRow(row, { lockHeld: true }) };
5605
+ }
5606
+ const mgr = await this.manager(channelId);
5607
+ await mgr.decrypt(hexToBytes(row.payload));
5608
+ await this.saveAdvancedOrEvict(channelId, mgr);
5609
+ this.recordDecryptSuccess(channelId);
5610
+ if (ctx)
5611
+ ctx.liveCommitEpoch = Number(mgr.epoch);
5612
+ return { kind: "acked" };
5613
+ }
5614
+ if (mainInRecGroup && rowEpoch <= rec.prior.epoch)
5615
+ return { kind: "normal" };
5616
+ return { kind: "leave" };
5617
+ });
5618
+ }
5619
+ /** C2 case 3: would this application row open under `mgr`'s exporter? A
5620
+ * pure trial: no custody, no success record, no ack. The SDK-5 binding
5621
+ * (envelope channel == server-attributed channel) is checked first. */
5622
+ async trialOpenApplication(row, mgr) {
5623
+ try {
5624
+ const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
5625
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id)
5626
+ return false;
5627
+ const key = mgr.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32);
5628
+ const aad = await buildAssociatedData({
5629
+ message_id: envelope.message_id,
5630
+ channel_id: envelope.channel_id,
5631
+ sender_id: envelope.sender_id,
5632
+ mls_group_epoch: envelope.mls_group_epoch,
5633
+ replay_nonce: envelope.replay_nonce,
5634
+ policy_binding: envelope.policy_binding
5635
+ });
5636
+ openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key: await key, associatedData: aad, suite: this.suite });
5637
+ return true;
5638
+ } catch {
5639
+ return false;
5640
+ }
5641
+ }
5642
+ /**
5643
+ * Decrypt one application delivery row into an {@link InboxEntry} and take
5644
+ * {@link custody} of it. Throws on any parse / binding / AEAD failure; the
5645
+ * caller decides what a failure means.
5646
+ *
5647
+ * `lockHeld`: the committer 409 drain and the own-commit drain run INSIDE the
5648
+ * channel lock already (the queue is not reentrant), so they derive the key
5649
+ * without re-taking it. The SDK-5 binding check below guarantees
5650
+ * envelope.channel_id equals the row's server-attributed channel, which is
5651
+ * the channel whose lock is held.
5652
+ *
5653
+ * `keyFrom`: derive the key from THIS manager instead of the cached live one.
5654
+ * Only {@link custodyAppRowsBeforeOwnCommit} passes it: a transient manager
5655
+ * rebuilt from the store's committed (pre-commit, epoch N) state while the
5656
+ * cached manager is already advanced to N+1.
5657
+ */
5658
+ async decryptApplicationRow(row, opts) {
5659
+ const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
5660
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
5661
+ throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
5662
+ }
5663
+ const keyFrom = opts.keyFrom;
5664
+ const derive = keyFrom ? () => keyFrom.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32) : () => this.exporterKeyUnfenced(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch);
5665
+ const key = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
5666
+ const aad = await buildAssociatedData({
5667
+ message_id: envelope.message_id,
5668
+ channel_id: envelope.channel_id,
5669
+ sender_id: envelope.sender_id,
5670
+ mls_group_epoch: envelope.mls_group_epoch,
5671
+ replay_nonce: envelope.replay_nonce,
5672
+ policy_binding: envelope.policy_binding
5673
+ });
5674
+ const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
5675
+ const appChannelId = row.connect_channel_id ?? envelope.channel_id;
5676
+ this.recordDecryptSuccess(appChannelId);
5677
+ const entry = {
5678
+ queueId: row.queue_id,
5679
+ messageId: envelope.message_id,
5680
+ channelId: appChannelId,
5681
+ plaintext,
5682
+ envelope,
5683
+ receivedAt: Date.now(),
5684
+ retryCount: 0,
5685
+ expires_at: row.expires_at,
5686
+ voiceMode: row.voice_mode ?? void 0,
5687
+ voiceDefaultResponder: row.voice_default_responder ?? void 0
5688
+ };
5689
+ this.custody.set(row.queue_id, entry);
5690
+ return entry;
5691
+ }
4451
5692
  /**
4452
5693
  * Channel-scoped, UNLOCKED commit drain for committer conflict-recovery.
4453
5694
  *
@@ -4457,22 +5698,39 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4457
5698
  * (which would re-acquire the same channel lock for that channel's commit rows
4458
5699
  * and self-deadlock — the queue is not reentrant). This helper does the commit
4459
5700
  * half of receiveOnce for ONLY the committer's own channel, without taking the
4460
- * lock (the caller already holds it). Application rows are left untouched — the
4461
- * committer only needs to converge its handshake epoch — and welcome rows are
4462
- * left for the join path, exactly as receiveOnce leaves them.
5701
+ * lock (the caller already holds it). Welcome rows are left for the join path,
5702
+ * exactly as receiveOnce leaves them. Rows of OTHER channels are untouched:
5703
+ * this drain never moves their epoch, so they stay decryptable.
5704
+ *
5705
+ * C1: application rows of THIS channel are decrypted in seq order, before
5706
+ * any commit behind them applies, which puts them in {@link custody} (never
5707
+ * acked here); the next receiveOnce serves them. Walking past them, as this
5708
+ * drain used to, then applying the commit behind them destroyed their key:
5709
+ * the redelivered row failed AEAD and was acked as permanentlySkipped. A row
5710
+ * that fails to decrypt here is left exactly as before (un-acked, not
5711
+ * counted), for receiveOnce to classify.
4463
5712
  */
4464
5713
  async drainChannelCommitsUnlocked(deviceId, channelId) {
4465
5714
  const rows = await this.o.backend.pullDelivery(deviceId);
4466
5715
  const acked = [];
4467
5716
  for (const row of rows) {
4468
- if (row.message_type !== "commit")
4469
- continue;
4470
5717
  if (row.connect_channel_id !== channelId)
4471
5718
  continue;
5719
+ if (row.message_type === "application") {
5720
+ if (this.custody.has(row.queue_id))
5721
+ continue;
5722
+ try {
5723
+ await this.decryptApplicationRow(row, { lockHeld: true });
5724
+ } catch {
5725
+ }
5726
+ continue;
5727
+ }
5728
+ if (row.message_type !== "commit")
5729
+ continue;
4472
5730
  try {
4473
5731
  const mgr = await this.manager(channelId);
4474
5732
  await mgr.decrypt(hexToBytes(row.payload));
4475
- await this.o.store.save(channelId, mgr.exportState());
5733
+ await this.saveAdvancedOrEvict(channelId, mgr);
4476
5734
  this.recordDecryptSuccess(channelId);
4477
5735
  acked.push(row.queue_id);
4478
5736
  } catch (err) {
@@ -4482,6 +5740,60 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4482
5740
  if (acked.length)
4483
5741
  await this.o.backend.ackDelivery(deviceId, acked);
4484
5742
  }
5743
+ /**
5744
+ * C1b: take custody of this channel's still-queued application rows at the
5745
+ * COMMITTED epoch, before a committer's OWN commit is persisted locally.
5746
+ *
5747
+ * The message key comes from the live exporter secret only. A committer that
5748
+ * persists its own commit N->N+1 while a peer's epoch-N application row sits
5749
+ * un-pulled in its queue can never decrypt that row again: its next
5750
+ * receiveOnce fails AEAD, classifies it permanentlySkipped and acks it.
5751
+ *
5752
+ * ORDERING (the committer-safety rule, B11): called only AFTER distribute has
5753
+ * succeeded and BEFORE the local save/promote, still inside the channel lock.
5754
+ * - After distribute: the server's group epoch is N+1, and the send path
5755
+ * rejects any application message claiming epoch < N+1
5756
+ * (validate_application_epoch, connect_messages.py step 6). So the set of
5757
+ * epoch-N rows is closed; one pull sees all of them (residual: a send whose
5758
+ * request read epoch N before our commit landed but commits after this pull;
5759
+ * see the C1 report).
5760
+ * - It is NOT a receiveOnce and applies NO commit: it only reads the queue
5761
+ * and derives exporter keys. The in-flight commit is already accepted, so
5762
+ * nothing here can race it or move the tree between resolve and
5763
+ * distribute; the cached (advanced) manager is never touched.
5764
+ * - The key comes from a TRANSIENT manager rebuilt from the store's MAIN
5765
+ * slot, which still holds the pre-commit epoch-N state (add: saved only
5766
+ * after this; removes: load() never returns staging). It is dropped when
5767
+ * this returns; no epoch-N secret is retained beyond the state that is
5768
+ * already at rest until the save/promote below.
5769
+ *
5770
+ * Best effort: a failed pull is retried once, then the commit is persisted
5771
+ * anyway (not persisting an accepted commit would diverge this device). Rows
5772
+ * that do not decrypt at N are left untouched for receiveOnce.
5773
+ */
5774
+ async custodyAppRowsBeforeOwnCommit(channelId, committedEpoch) {
5775
+ try {
5776
+ const saved = await this.o.store.load(channelId);
5777
+ if (!saved)
5778
+ return;
5779
+ const atCommitted = new MLSGroupManager2(saved.ciphersuite);
5780
+ atCommitted.importState(saved);
5781
+ if (Number(atCommitted.epoch) !== committedEpoch)
5782
+ return;
5783
+ let rows;
5784
+ for (let attempt = 0; attempt < 2 && !rows; attempt++) {
5785
+ rows = await this.o.backend.pullDelivery(this.o.deviceId).catch(() => void 0);
5786
+ }
5787
+ for (const row of rows ?? []) {
5788
+ if (row.message_type !== "application" || row.connect_channel_id !== channelId)
5789
+ continue;
5790
+ if (this.custody.has(row.queue_id))
5791
+ continue;
5792
+ await this.decryptApplicationRow(row, { lockHeld: true, keyFrom: atCommitted }).catch(() => void 0);
5793
+ }
5794
+ } catch {
5795
+ }
5796
+ }
4485
5797
  /** Delegate so the receive pump can ack without reaching the private backend. */
4486
5798
  async ackDelivery(deviceId, queueIds) {
4487
5799
  await this.o.backend.ackDelivery(deviceId, queueIds);
@@ -5005,6 +6317,12 @@ function describeType(v) {
5005
6317
  return "array";
5006
6318
  return typeof v;
5007
6319
  }
6320
+ function isAbsence404(err) {
6321
+ return err instanceof MlsBackendHttpError && err.status === 404;
6322
+ }
6323
+ function failureFields(err) {
6324
+ return err instanceof MlsBackendHttpError ? { status: err.status } : {};
6325
+ }
5008
6326
  function parseGroupRoster(endpoint, channelId, value) {
5009
6327
  if (!Array.isArray(value)) {
5010
6328
  throw new MalformedGroupRosterError(endpoint, channelId, describeType(value));
@@ -5040,7 +6358,7 @@ var RestMlsBackend = class {
5040
6358
  async get(path) {
5041
6359
  const res = await this.f(this.opts.baseUrl + path, { headers: this.headers() });
5042
6360
  if (!res.ok)
5043
- throw new Error(`${path} failed: ${res.status}`);
6361
+ throw new MlsBackendHttpError(path, res.status);
5044
6362
  return await res.json();
5045
6363
  }
5046
6364
  async getRegisterChallenge() {
@@ -5095,25 +6413,68 @@ var RestMlsBackend = class {
5095
6413
  member_device_ids: parseGroupRoster("init", channelId, j.member_device_ids),
5096
6414
  // Back-compat: servers that predate the created field return nothing;
5097
6415
  // treat absence as true (they always created a fresh group).
5098
- created: j.created === void 0 ? true : Boolean(j.created)
6416
+ created: j.created === void 0 ? true : Boolean(j.created),
6417
+ // C2 T3b: passed through ONLY as a real boolean. Absent (a backend that
6418
+ // predates the field) or any other type stays undefined, which the
6419
+ // provider reads as ATTESTED (fail closed). Never coerced: `Boolean("false")`
6420
+ // would read a malformed value as attested-true, and a default of `false`
6421
+ // would read a missing one as adoptable.
6422
+ ...typeof j.reset_attested === "boolean" ? { reset_attested: j.reset_attested } : {}
5099
6423
  };
5100
6424
  }
5101
6425
  async reinitConnectChannelGroup() {
5102
6426
  throw new Error("reinitConnectChannelGroup was removed (it minted unattested MLS groups that orphaned channel members permanently). Use resetReinitConnectChannelGroup with an owner attestation.");
5103
6427
  }
6428
+ /**
6429
+ * C2 T3e (item 1): does its own fetch, mirroring {@link distribute}, so a
6430
+ * non-2xx becomes a typed {@link MlsBackendHttpError} instead of a plain
6431
+ * `Error` whose status lives only in a `failed: <status>` message. Before
6432
+ * this, `resetSessionSupersede`'s `isDefiniteReinitRefusal` classified the
6433
+ * outcome by regex-parsing that text, which is safe only as long as
6434
+ * neither `post()` nor the web's `makeAuthedFetch` ever retries this call —
6435
+ * a retry would replay the request and the classifier has no way to know
6436
+ * that happened. Typing the error lets the classifier read `instanceof
6437
+ * MlsBackendHttpError` + `.status` directly, matching every other C2
6438
+ * definite-refusal site (`isSupersededRefusal`, `isEpochConflict`). The
6439
+ * message is `<path> failed: <status>` — no body text (ids stay out of
6440
+ * logs) — which is SHORTER than what `post()` used to throw (that included
6441
+ * the raw response body). PR #681 review: the web's `friendlyResetError`
6442
+ * used to substring-match `surviving_device_exists` /
6443
+ * `reset_member_set_incomplete` / `bad_reset_attestation` out of that body
6444
+ * text; it now reads them off this error's typed `.code` field instead
6445
+ * (with the old substring match kept as a fallback for other error shapes).
6446
+ */
5104
6447
  async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
5105
6448
  const committer = memberDeviceIds[0];
5106
6449
  if (!committer)
5107
6450
  throw new Error("resetReinitConnectChannelGroup: empty member set");
5108
- const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
5109
- member_device_ids: memberDeviceIds,
5110
- committer_device_id: committer,
5111
- mode: "reset",
5112
- new_group_id: att.newGroupId,
5113
- reset_attestation_signature: att.signatureHex,
5114
- reset_attestation_nonce: att.nonceHex,
5115
- reset_attestation_content: att.signedContentHex
6451
+ const path = `/api/v1/mls/connect-channels/${channelId}/reinit`;
6452
+ const res = await this.f(this.opts.baseUrl + path, {
6453
+ method: "POST",
6454
+ headers: this.headers(),
6455
+ body: JSON.stringify({
6456
+ member_device_ids: memberDeviceIds,
6457
+ committer_device_id: committer,
6458
+ mode: "reset",
6459
+ new_group_id: att.newGroupId,
6460
+ reset_attestation_signature: att.signatureHex,
6461
+ reset_attestation_nonce: att.nonceHex,
6462
+ reset_attestation_content: att.signedContentHex
6463
+ })
5116
6464
  });
6465
+ if (!res.ok) {
6466
+ let code;
6467
+ let reason;
6468
+ try {
6469
+ const body = JSON.parse(await res.text());
6470
+ code = parseErrorCode(body);
6471
+ const r = body !== null && typeof body === "object" ? body.reason : void 0;
6472
+ reason = typeof r === "string" ? r : void 0;
6473
+ } catch {
6474
+ }
6475
+ throw new MlsBackendHttpError(path, res.status, code, reason);
6476
+ }
6477
+ const j = res.status === 204 ? {} : await res.json();
5117
6478
  return {
5118
6479
  group_id: String(j.group_id),
5119
6480
  current_epoch: Number(j.current_epoch),
@@ -5136,7 +6497,10 @@ var RestMlsBackend = class {
5136
6497
  try {
5137
6498
  const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
5138
6499
  return String(j.key_package);
5139
- } catch {
6500
+ } catch (err) {
6501
+ if (!isAbsence404(err)) {
6502
+ writeServiceLog("warn", "keypackage.by_id_fetch_failed", failureFields(err), err);
6503
+ }
5140
6504
  return null;
5141
6505
  }
5142
6506
  }
@@ -5152,15 +6516,44 @@ var RestMlsBackend = class {
5152
6516
  if (Number.isNaN(ms))
5153
6517
  return null;
5154
6518
  return new Date(ms);
5155
- } catch {
6519
+ } catch (err) {
6520
+ if (!isAbsence404(err)) {
6521
+ writeServiceLog("warn", "keypackage.expiry_fetch_failed", failureFields(err), err);
6522
+ }
5156
6523
  return null;
5157
6524
  }
5158
6525
  }
6526
+ /**
6527
+ * C2 T3 (spec §4.3): `/distribute` does its own fetch so a non-2xx becomes a
6528
+ * typed {@link MlsBackendHttpError} carrying the body's error `code` (and a
6529
+ * 409's `reason`), which the pending-commit outcome classifier reads. `post()`
6530
+ * is left alone: its other callers keep their current plain-`Error` shape.
6531
+ *
6532
+ * The message is `<path> failed: <status>` with NO body text (ids stay out of
6533
+ * logs; `friendlyResetError` matches the status substring). A network failure
6534
+ * or abort propagates as the fetch threw it, which the classifier reads as
6535
+ * ambiguous. A 2xx resolves whatever its body is (`replayed: true` included):
6536
+ * 2xx means landed, and the SDK never reads the success body.
6537
+ */
5159
6538
  async distribute(groupId, req) {
5160
- await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
5161
- ...req,
5162
- group_id: groupId
6539
+ const path = `/api/v1/mls/groups/${groupId}/distribute`;
6540
+ const res = await this.f(this.opts.baseUrl + path, {
6541
+ method: "POST",
6542
+ headers: this.headers(),
6543
+ body: JSON.stringify({ ...req, group_id: groupId })
5163
6544
  });
6545
+ if (res.ok)
6546
+ return;
6547
+ let code;
6548
+ let reason;
6549
+ try {
6550
+ const body = JSON.parse(await res.text());
6551
+ code = parseErrorCode(body);
6552
+ const r = body !== null && typeof body === "object" ? body.reason : void 0;
6553
+ reason = typeof r === "string" ? r : void 0;
6554
+ } catch {
6555
+ }
6556
+ throw new MlsBackendHttpError(path, res.status, code, reason);
5164
6557
  }
5165
6558
  async pullDelivery(deviceId) {
5166
6559
  const j = await this.get(`/api/v1/mls/delivery?device_id=${deviceId}`);
@@ -5212,7 +6605,8 @@ var RestMlsBackend = class {
5212
6605
  return String(j.token);
5213
6606
  }
5214
6607
  async releaseCommitLease(channelId, token) {
5215
- await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease/release`, { method: "POST", headers: this.headers(), body: JSON.stringify({ token }) }).catch(() => {
6608
+ await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease/release`, { method: "POST", headers: this.headers(), body: JSON.stringify({ token }) }).catch((err) => {
6609
+ writeServiceLog("warn", "committer.lease_release_failed", {}, err);
5216
6610
  });
5217
6611
  }
5218
6612
  async fetchResetAttestation(groupId) {
@@ -5223,7 +6617,10 @@ var RestMlsBackend = class {
5223
6617
  signatureHex: String(j.signature_hex),
5224
6618
  signerIdentityId: String(j.signer_identity_id)
5225
6619
  };
5226
- } catch {
6620
+ } catch (err) {
6621
+ if (!isAbsence404(err)) {
6622
+ writeServiceLog("warn", "supersede.attestation_fetch_failed", failureFields(err), err);
6623
+ }
5227
6624
  return null;
5228
6625
  }
5229
6626
  }
@@ -5231,6 +6628,7 @@ var RestMlsBackend = class {
5231
6628
 
5232
6629
  // ../connect-sdk-ts/dist/mls/fake-backend.js
5233
6630
  import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
6631
+ var DELIVERY_PULL_LIMIT = 100;
5234
6632
  var FakeConnectBackend = class _FakeConnectBackend {
5235
6633
  // Key-package store: deviceId → hex-encoded KeyPackage
5236
6634
  kps = /* @__PURE__ */ new Map();
@@ -5361,7 +6759,10 @@ var FakeConnectBackend = class _FakeConnectBackend {
5361
6759
  // Real semantics: created=true on first call (group was just created),
5362
6760
  // created=false on idempotent return (group already existed). Without
5363
6761
  // this, race tests silently pass wrong (fake is laxer than the server).
5364
- created: !alreadyExists
6762
+ created: !alreadyExists,
6763
+ // C2 T3b: mirrors T1b's `reset_attested` (the group carries an owner
6764
+ // reset attestation). Always present, as T1b's field has no default.
6765
+ reset_attested: this.resetAttestations.has(groupId)
5365
6766
  };
5366
6767
  }
5367
6768
  async reinitConnectChannelGroup(channelId, memberDeviceIds) {
@@ -5429,16 +6830,58 @@ var FakeConnectBackend = class _FakeConnectBackend {
5429
6830
  }))
5430
6831
  };
5431
6832
  }
6833
+ /** C2 T3: every `/distribute` request, in order, including resends and
6834
+ * injected failures, so a test can assert exactly what was sent. */
6835
+ distributeCalls = [];
6836
+ /** C2 T3: one fault per upcoming `distribute` call, FIFO. See
6837
+ * {@link failNextDistribute}. */
6838
+ distributeFaults = [];
6839
+ /**
6840
+ * C2 T3 test seam: make the NEXT `distribute` call fail once.
6841
+ * - `{status, code?, reason?}`: a typed `MlsBackendHttpError`, exactly what
6842
+ * `RestMlsBackend.distribute` throws for that response body.
6843
+ * - `{network: true}`: a `TypeError`, what `fetch` throws on a network
6844
+ * failure.
6845
+ * - `afterApply: true` on either: the commit is APPLIED first (it landed)
6846
+ * and the failure is what the client sees, i.e. a lost 2xx.
6847
+ * Call it several times to queue several faults.
6848
+ */
6849
+ failNextDistribute(fault) {
6850
+ this.distributeFaults.push(fault);
6851
+ }
6852
+ static distributePath(groupId) {
6853
+ return `/api/v1/mls/groups/${groupId}/distribute`;
6854
+ }
5432
6855
  async distribute(groupId, req) {
6856
+ this.distributeCalls.push({ groupId, req: structuredClone(req) });
6857
+ const fault = this.distributeFaults.shift();
6858
+ if (fault && !fault.afterApply)
6859
+ throw _FakeConnectBackend.faultError(groupId, fault);
6860
+ await this.distributeApply(groupId, req);
6861
+ if (fault)
6862
+ throw _FakeConnectBackend.faultError(groupId, fault);
6863
+ }
6864
+ static faultError(groupId, fault) {
6865
+ if ("network" in fault)
6866
+ return new TypeError("fetch failed");
6867
+ return new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), fault.status, fault.code, fault.reason);
6868
+ }
6869
+ async distributeApply(groupId, req) {
5433
6870
  const channelId = this.channelByGroup.get(groupId);
5434
6871
  if (!channelId)
5435
6872
  throw new Error(`FakeConnectBackend.distribute: unknown group ${groupId}`);
6873
+ if (req.commit) {
6874
+ const hit = (this.commitLog.get(groupId) ?? []).some((c) => c.epoch === Number(req.commit_epoch ?? 0) && c.sender_device_id === String(req.device_id ?? "") && c.payload === String(req.commit));
6875
+ if (hit)
6876
+ return;
6877
+ }
6878
+ if (req.commit && this.groupIdByChannel.get(channelId) !== groupId) {
6879
+ throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "superseded");
6880
+ }
5436
6881
  if (req.commit && req.expected_epoch != null) {
5437
6882
  const current = this.epochByChannel.get(channelId) ?? 0;
5438
6883
  if (req.expected_epoch !== current) {
5439
- const e = new Error(`connect backend post failed: 409 EPOCH_CONFLICT (expected ${req.expected_epoch}, current ${current})`);
5440
- e.status = 409;
5441
- throw e;
6884
+ throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
5442
6885
  }
5443
6886
  }
5444
6887
  if (req.commit) {
@@ -5501,7 +6944,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
5501
6944
  }
5502
6945
  }
5503
6946
  async pullDelivery(deviceId) {
5504
- return this.queues.get(deviceId) ?? [];
6947
+ return (this.queues.get(deviceId) ?? []).slice(0, DELIVERY_PULL_LIMIT);
5505
6948
  }
5506
6949
  async ackDelivery(deviceId, queueIds) {
5507
6950
  const queue = this.queues.get(deviceId);
@@ -5684,8 +7127,10 @@ import { DatabaseSync } from "node:sqlite";
5684
7127
  import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
5685
7128
 
5686
7129
  // ../connect-mls-store/dist/schema.js
5687
- var SCHEMA_VERSION = 7;
7130
+ var SCHEMA_VERSION = 9;
7131
+ var BUSY_TIMEOUT_MS = 5e3;
5688
7132
  function openSchema(db) {
7133
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
5689
7134
  db.exec("PRAGMA journal_mode=WAL");
5690
7135
  const row = db.prepare("PRAGMA user_version").get();
5691
7136
  if (row.user_version === 0) {
@@ -5787,8 +7232,58 @@ function openSchema(db) {
5787
7232
  );
5788
7233
  `);
5789
7234
  }
7235
+ if (row.user_version < 8) {
7236
+ db.exec(`
7237
+ CREATE TABLE IF NOT EXISTS runtime_inbox (
7238
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
7239
+ identity_id TEXT NOT NULL,
7240
+ message_id TEXT NOT NULL,
7241
+ sealed_message BLOB NOT NULL,
7242
+ received_at INTEGER NOT NULL,
7243
+ UNIQUE (identity_id, message_id)
7244
+ );
7245
+ `);
7246
+ db.exec(`
7247
+ CREATE TABLE IF NOT EXISTS runtime_reply_receipt (
7248
+ identity_id TEXT NOT NULL,
7249
+ in_reply_to TEXT NOT NULL,
7250
+ channel_id TEXT NOT NULL,
7251
+ reply_message_id TEXT NOT NULL,
7252
+ replied_at INTEGER NOT NULL,
7253
+ PRIMARY KEY (identity_id, in_reply_to)
7254
+ );
7255
+ `);
7256
+ }
7257
+ if (row.user_version < 9) {
7258
+ db.exec(`
7259
+ CREATE TABLE IF NOT EXISTS mls_pending_commit (
7260
+ identity_id TEXT NOT NULL,
7261
+ channel_id TEXT NOT NULL,
7262
+ sealed_state BLOB NOT NULL,
7263
+ sealed_request BLOB NOT NULL,
7264
+ created_at INTEGER NOT NULL,
7265
+ PRIMARY KEY (identity_id, channel_id)
7266
+ );
7267
+ `);
7268
+ db.exec(`
7269
+ CREATE TABLE IF NOT EXISTS mls_owed_followup (
7270
+ identity_id TEXT NOT NULL,
7271
+ channel_id TEXT NOT NULL,
7272
+ sealed_followup BLOB NOT NULL,
7273
+ created_at INTEGER NOT NULL,
7274
+ PRIMARY KEY (identity_id, channel_id)
7275
+ );
7276
+ `);
7277
+ }
7278
+ ensureRuntimeInboxDeliveryAttempts(db);
5790
7279
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
5791
7280
  }
7281
+ function ensureRuntimeInboxDeliveryAttempts(db) {
7282
+ const cols = db.prepare("PRAGMA table_info(runtime_inbox)").all();
7283
+ if (cols.length === 0 || cols.some((c) => c.name === "delivery_attempts"))
7284
+ return;
7285
+ db.exec("ALTER TABLE runtime_inbox ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0");
7286
+ }
5792
7287
 
5793
7288
  // ../connect-mls-store/dist/errors.js
5794
7289
  var MlsStoreCorruptError = class extends Error {
@@ -5826,10 +7321,24 @@ function decodeBundle(bytes) {
5826
7321
  }
5827
7322
  };
5828
7323
  }
7324
+ function sameRow(a, b) {
7325
+ if (!a || !b)
7326
+ return a === b;
7327
+ return Buffer.compare(a.sealed_state, b.sealed_state) === 0 && Buffer.compare(a.sealed_request, b.sealed_request) === 0 && Number(a.created_at) === Number(b.created_at);
7328
+ }
7329
+ function sameBytes(a, b) {
7330
+ if (!a || !b)
7331
+ return a === b;
7332
+ return Buffer.compare(a, b) === 0;
7333
+ }
7334
+ function sameTag(prior, tag) {
7335
+ return prior !== null && prior.groupId === tag.groupId && prior.epoch === tag.epoch;
7336
+ }
5829
7337
  var DAY_MS = 24 * 60 * 60 * 1e3;
5830
7338
  var DEFAULT_MEMORY_RETENTION_MS = 30 * DAY_MS;
5831
7339
  var DEFAULT_MEMORY_FACT_RETENTION_K = 200;
5832
7340
  var DEFAULT_MEMORY_FACT_RETENTION_MS = 90 * DAY_MS;
7341
+ var RUNTIME_REPLY_RECEIPT_MAX = 5e3;
5833
7342
  var SqliteMlsStateStore = class {
5834
7343
  db;
5835
7344
  id;
@@ -5894,6 +7403,9 @@ var SqliteMlsStateStore = class {
5894
7403
  return this.cipher.seal(new TextEncoder().encode(JSON.stringify(state)));
5895
7404
  }
5896
7405
  async unseal(blob) {
7406
+ return this.unsealJson(blob);
7407
+ }
7408
+ async unsealJson(blob) {
5897
7409
  let bytes;
5898
7410
  try {
5899
7411
  bytes = await this.cipher.open(blob);
@@ -5963,7 +7475,7 @@ var SqliteMlsStateStore = class {
5963
7475
  try {
5964
7476
  bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
5965
7477
  } catch (e) {
5966
- console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
7478
+ writeServiceLog("warn", "store.pending_bundle_open_failed", {}, e);
5967
7479
  return null;
5968
7480
  }
5969
7481
  this.bundles.set(ref, bundle);
@@ -6078,6 +7590,96 @@ var SqliteMlsStateStore = class {
6078
7590
  const rows = this.db.prepare("SELECT DISTINCT enclave_id FROM channel_meta WHERE identity_id=? AND enclave_id IS NOT NULL").all(this.id);
6079
7591
  return rows.map((r) => r.enclave_id);
6080
7592
  }
7593
+ /**
7594
+ * Commit one decrypted daemon→runtime frame before the backend delivery row
7595
+ * is acknowledged. The payload is sealed at rest. A redelivery is opened and
7596
+ * compared so a same-id/different-payload collision becomes a loud refusal
7597
+ * without persisting a plaintext-derived digest.
7598
+ */
7599
+ async stageRuntimeInbox(messageId, payload, receivedAt = Date.now()) {
7600
+ const prior = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
7601
+ if (prior) {
7602
+ const priorPayload = await this.openRuntimeInboxPayload(prior.sealed_message);
7603
+ if (!Buffer.from(priorPayload).equals(Buffer.from(payload))) {
7604
+ throw new Error("runtime inbox message-id payload conflict");
7605
+ }
7606
+ return false;
7607
+ }
7608
+ const sealed = await this.cipher.seal(payload);
7609
+ const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_inbox
7610
+ (identity_id, message_id, sealed_message, received_at)
7611
+ VALUES (?, ?, ?, ?)`).run(this.id, messageId, sealed, receivedAt);
7612
+ if (info.changes === 0) {
7613
+ const raced = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
7614
+ const racedPayload = await this.openRuntimeInboxPayload(raced.sealed_message);
7615
+ if (!Buffer.from(racedPayload).equals(Buffer.from(payload))) {
7616
+ throw new Error("runtime inbox message-id payload conflict");
7617
+ }
7618
+ return false;
7619
+ }
7620
+ return true;
7621
+ }
7622
+ async loadRuntimeInbox() {
7623
+ const rows = this.db.prepare(`SELECT message_id, sealed_message, received_at, delivery_attempts
7624
+ FROM runtime_inbox WHERE identity_id=? ORDER BY seq ASC`).all(this.id);
7625
+ const out = [];
7626
+ for (const row of rows) {
7627
+ const payload = await this.openRuntimeInboxPayload(row.sealed_message);
7628
+ out.push({
7629
+ messageId: row.message_id,
7630
+ payload,
7631
+ receivedAt: row.received_at,
7632
+ deliveryAttempts: Number(row.delivery_attempts)
7633
+ });
7634
+ }
7635
+ return out;
7636
+ }
7637
+ /**
7638
+ * Count one delivery of a staged runtime frame and return the new total.
7639
+ * Returns 0 when the message is not staged (already acknowledged, or never
7640
+ * durably handed off), so the caller can fall back to a process-local count.
7641
+ * Content-free: only the counter changes, never the sealed payload.
7642
+ */
7643
+ noteRuntimeInboxDelivery(messageId) {
7644
+ const row = this.db.prepare(`UPDATE runtime_inbox SET delivery_attempts = delivery_attempts + 1
7645
+ WHERE identity_id=? AND message_id=?
7646
+ RETURNING delivery_attempts`).get(this.id, messageId);
7647
+ return row ? Number(row.delivery_attempts) : 0;
7648
+ }
7649
+ async openRuntimeInboxPayload(sealed) {
7650
+ try {
7651
+ return await this.cipher.open(sealed);
7652
+ } catch (error) {
7653
+ throw new MlsStoreSealError(String(error?.message ?? error));
7654
+ }
7655
+ }
7656
+ ackRuntimeInbox(messageId) {
7657
+ return this.db.prepare("DELETE FROM runtime_inbox WHERE identity_id=? AND message_id=?").run(this.id, messageId).changes === 1;
7658
+ }
7659
+ recordRuntimeReplyReceipt(inReplyTo, channelId, replyMessageId, repliedAt = Date.now()) {
7660
+ this.db.prepare(`INSERT INTO runtime_reply_receipt
7661
+ (identity_id, in_reply_to, channel_id, reply_message_id, replied_at)
7662
+ VALUES (?, ?, ?, ?, ?)
7663
+ ON CONFLICT(identity_id, in_reply_to) DO UPDATE SET
7664
+ channel_id=excluded.channel_id,
7665
+ reply_message_id=excluded.reply_message_id,
7666
+ replied_at=excluded.replied_at`).run(this.id, inReplyTo, channelId, replyMessageId, repliedAt);
7667
+ const receiptCount = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_receipt WHERE identity_id=?").get(this.id);
7668
+ if (receiptCount.n > RUNTIME_REPLY_RECEIPT_MAX) {
7669
+ this.db.prepare(`DELETE FROM runtime_reply_receipt
7670
+ WHERE identity_id=? AND rowid IN (
7671
+ SELECT rowid FROM runtime_reply_receipt
7672
+ WHERE identity_id=?
7673
+ ORDER BY replied_at DESC, rowid DESC
7674
+ LIMIT -1 OFFSET ?
7675
+ )`).run(this.id, this.id, RUNTIME_REPLY_RECEIPT_MAX);
7676
+ }
7677
+ return { in_reply_to: inReplyTo, channel_id: channelId, reply_message_id: replyMessageId, replied_at: repliedAt };
7678
+ }
7679
+ runtimeReplyReceipt(inReplyTo) {
7680
+ return this.db.prepare(`SELECT in_reply_to, channel_id, reply_message_id, replied_at
7681
+ FROM runtime_reply_receipt WHERE identity_id=? AND in_reply_to=?`).get(this.id, inReplyTo) ?? null;
7682
+ }
6081
7683
  async recordMemory(input) {
6082
7684
  const dedupKey = input.messageId ?? input.idempotencyKey ?? null;
6083
7685
  if (!dedupKey)
@@ -6203,6 +7805,231 @@ var SqliteMlsStateStore = class {
6203
7805
  }
6204
7806
  }
6205
7807
  }
7808
+ // ── C2 pending commit (schema v9) ─────────────────────────────────────
7809
+ // One sealed record per (identity, channel) in mls_pending_commit, and the
7810
+ // owed follow-up slot of a landed commit in mls_owed_followup. The
7811
+ // reference semantics are InMemoryMlsStateStore (connect-sdk-ts
7812
+ // src/mls/store.ts); the shared conformance suite pins them.
7813
+ //
7814
+ // THE AWAIT TRAP. seal/open are async. An await inside an open
7815
+ // DatabaseSync transaction lets another async call on this connection run
7816
+ // statements inside it, and another process can write between any read
7817
+ // and BEGIN. So every guarded method reads the record bytes, does ALL of
7818
+ // its crypto, and only then runs BEGIN IMMEDIATE, re-reads the record and
7819
+ // compares BYTES with what it read before the await (a reseal of the same
7820
+ // content has new bytes, so any rewrite is seen). Mismatch → ROLLBACK and
7821
+ // retry the whole method once; a second mismatch refuses. Inside the
7822
+ // transaction there is no await, so the decision and the write are one
7823
+ // step against every writer (MEDIUM-1).
7824
+ async sealJson(v) {
7825
+ return this.cipher.seal(new TextEncoder().encode(JSON.stringify(v)));
7826
+ }
7827
+ readPendingRow(channelId) {
7828
+ return this.db.prepare("SELECT sealed_state, sealed_request, created_at FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
7829
+ }
7830
+ async openPendingRow(row) {
7831
+ const meta = await this.unsealJson(row.sealed_request);
7832
+ const state = await this.unseal(row.sealed_state);
7833
+ const rec = {
7834
+ groupId: meta.groupId,
7835
+ op: meta.op,
7836
+ request: meta.request,
7837
+ state,
7838
+ commitEpoch: meta.commitEpoch,
7839
+ prior: meta.prior,
7840
+ createdAt: Number(row.created_at)
7841
+ };
7842
+ if (meta.followUp !== void 0)
7843
+ rec.followUp = meta.followUp;
7844
+ return rec;
7845
+ }
7846
+ /** Run `fn` inside BEGIN IMMEDIATE. `fn` is synchronous by type: nothing
7847
+ * can interleave with it. Any throw rolls back and propagates. */
7848
+ immediate(fn) {
7849
+ this.db.exec("BEGIN IMMEDIATE");
7850
+ try {
7851
+ const result = fn();
7852
+ this.db.exec("COMMIT");
7853
+ return result;
7854
+ } catch (e) {
7855
+ this.db.exec("ROLLBACK");
7856
+ throw e;
7857
+ }
7858
+ }
7859
+ async savePendingCommit(channelId, rec, opts) {
7860
+ assertPendingCommitShape(rec);
7861
+ const meta = {
7862
+ groupId: rec.groupId,
7863
+ op: rec.op,
7864
+ request: rec.request,
7865
+ commitEpoch: rec.commitEpoch,
7866
+ prior: rec.prior,
7867
+ followUp: rec.followUp
7868
+ };
7869
+ const sealedState = await this.seal(rec.state);
7870
+ const sealedRequest = await this.sealJson(meta);
7871
+ if (opts?.replacing) {
7872
+ for (let attempt = 0; attempt < 2; attempt++) {
7873
+ const row = this.readPendingRow(channelId);
7874
+ if (!row)
7875
+ return false;
7876
+ if (!matchesPendingCommitKey(await this.openPendingRow(row), opts.replacing))
7877
+ return false;
7878
+ const outcome = this.immediate(() => {
7879
+ if (!sameRow(row, this.readPendingRow(channelId)))
7880
+ return "retry";
7881
+ this.db.prepare(`UPDATE mls_pending_commit SET sealed_state=?, sealed_request=?, created_at=?
7882
+ WHERE identity_id=? AND channel_id=?`).run(sealedState, sealedRequest, rec.createdAt, this.id, channelId);
7883
+ return "written";
7884
+ });
7885
+ if (outcome === "written")
7886
+ return true;
7887
+ }
7888
+ throw new PendingCommitUnresolvedError(channelId);
7889
+ }
7890
+ const res = this.db.prepare(opts?.onlyIfAbsent ? `INSERT INTO mls_pending_commit (identity_id, channel_id, sealed_state, sealed_request, created_at)
7891
+ VALUES (?,?,?,?,?)
7892
+ ON CONFLICT(identity_id, channel_id) DO NOTHING` : `INSERT INTO mls_pending_commit (identity_id, channel_id, sealed_state, sealed_request, created_at)
7893
+ VALUES (?,?,?,?,?)
7894
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET
7895
+ sealed_state=excluded.sealed_state, sealed_request=excluded.sealed_request, created_at=excluded.created_at`).run(this.id, channelId, sealedState, sealedRequest, rec.createdAt);
7896
+ return Number(res.changes) > 0;
7897
+ }
7898
+ async loadPendingCommit(channelId) {
7899
+ const row = this.readPendingRow(channelId);
7900
+ return row ? this.openPendingRow(row) : null;
7901
+ }
7902
+ async listPendingCommits() {
7903
+ const rows = this.db.prepare(`SELECT channel_id, sealed_state, sealed_request, created_at FROM mls_pending_commit
7904
+ WHERE identity_id=? ORDER BY channel_id`).all(this.id);
7905
+ const out = [];
7906
+ for (const row of rows)
7907
+ out.push({ channelId: row.channel_id, rec: await this.openPendingRow(row) });
7908
+ return out;
7909
+ }
7910
+ readMainBytes(channelId) {
7911
+ const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
7912
+ return row?.sealed_main ?? void 0;
7913
+ }
7914
+ /**
7915
+ * C2 T3c (security review C1): promote ONLY the expected record, and only
7916
+ * while MAIN is exactly the state the caller checked against its `prior`.
7917
+ * Both checks are decided on bytes read BEFORE the await (the record opened
7918
+ * and matched on its key; MAIN unsealed and compared to `expect.main`) and
7919
+ * then re-proven INSIDE `BEGIN IMMEDIATE` by byte-comparing both rows, so
7920
+ * no writer in this or another process can slip between check and write.
7921
+ */
7922
+ async commitPending(channelId, expect) {
7923
+ for (let attempt = 0; attempt < 2; attempt++) {
7924
+ const row = this.readPendingRow(channelId);
7925
+ if (!row)
7926
+ return "record_mismatch";
7927
+ const rec = await this.openPendingRow(row);
7928
+ if (!matchesPendingCommitKey(rec, expect))
7929
+ return "record_mismatch";
7930
+ const mainBytes = this.readMainBytes(channelId);
7931
+ const main = mainBytes ? await this.unseal(mainBytes) : null;
7932
+ if (!sameMainState(main, expect.main))
7933
+ return "main_moved";
7934
+ const sealedOwed = rec.followUp ? await this.sealJson({ op: rec.op, followUp: rec.followUp }) : null;
7935
+ const done = this.immediate(() => {
7936
+ if (!sameRow(row, this.readPendingRow(channelId)))
7937
+ return false;
7938
+ if (!sameBytes(mainBytes, this.readMainBytes(channelId)))
7939
+ return false;
7940
+ this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
7941
+ VALUES (?,?,?,?)
7942
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET sealed_main=excluded.sealed_main, updated_at=excluded.updated_at`).run(this.id, channelId, row.sealed_state, Date.now());
7943
+ this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
7944
+ if (sealedOwed) {
7945
+ this.db.prepare(`INSERT INTO mls_owed_followup (identity_id, channel_id, sealed_followup, created_at)
7946
+ VALUES (?,?,?,?)
7947
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET
7948
+ sealed_followup=excluded.sealed_followup, created_at=excluded.created_at`).run(this.id, channelId, sealedOwed, rec.createdAt);
7949
+ }
7950
+ return true;
7951
+ });
7952
+ if (done)
7953
+ return "committed";
7954
+ }
7955
+ throw new PendingCommitUnresolvedError(channelId);
7956
+ }
7957
+ /** C2 T3c (C1): with `expect`, delete only the record carrying that key
7958
+ * (matched on the opened record, re-proven by byte-compare inside
7959
+ * `BEGIN IMMEDIATE`); without it, delete whatever is there (the owner's
7960
+ * abandon, which must work even when the record does not unseal). */
7961
+ async clearPendingCommit(channelId, expect) {
7962
+ if (!expect) {
7963
+ const res = this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
7964
+ return Number(res.changes) > 0;
7965
+ }
7966
+ for (let attempt = 0; attempt < 2; attempt++) {
7967
+ const row = this.readPendingRow(channelId);
7968
+ if (!row)
7969
+ return false;
7970
+ const rec = await this.openPendingRow(row);
7971
+ if (!matchesPendingCommitKey(rec, expect))
7972
+ return false;
7973
+ const outcome = this.immediate(() => {
7974
+ if (!sameRow(row, this.readPendingRow(channelId)))
7975
+ return "retry";
7976
+ this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
7977
+ return "deleted";
7978
+ });
7979
+ if (outcome === "deleted")
7980
+ return true;
7981
+ }
7982
+ throw new PendingCommitUnresolvedError(channelId);
7983
+ }
7984
+ async saveMainGuarded(channelId, state, tag) {
7985
+ let row = this.readPendingRow(channelId);
7986
+ const sealedMain = await this.seal(state);
7987
+ for (let attempt = 0; attempt < 2; attempt++) {
7988
+ if (attempt > 0)
7989
+ row = this.readPendingRow(channelId);
7990
+ const prior = row ? (await this.unsealJson(row.sealed_request)).prior : void 0;
7991
+ const outcome = this.immediate(() => {
7992
+ if (!sameRow(row, this.readPendingRow(channelId)))
7993
+ return "retry";
7994
+ if (row && !sameTag(prior ?? null, tag))
7995
+ return "refused";
7996
+ this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
7997
+ VALUES (?,?,?,?)
7998
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET sealed_main=excluded.sealed_main, updated_at=excluded.updated_at`).run(this.id, channelId, sealedMain, Date.now());
7999
+ return "written";
8000
+ });
8001
+ if (outcome === "written")
8002
+ return;
8003
+ if (outcome === "refused")
8004
+ throw new PendingCommitUnresolvedError(channelId);
8005
+ }
8006
+ throw new PendingCommitUnresolvedError(channelId);
8007
+ }
8008
+ async listOwedFollowUps() {
8009
+ const rows = this.db.prepare(`SELECT channel_id, sealed_followup, created_at FROM mls_owed_followup
8010
+ WHERE identity_id=? ORDER BY channel_id`).all(this.id);
8011
+ const out = [];
8012
+ for (const row of rows) {
8013
+ const o = await this.unsealJson(row.sealed_followup);
8014
+ out.push({ channelId: row.channel_id, owed: { op: o.op, followUp: o.followUp, createdAt: Number(row.created_at) } });
8015
+ }
8016
+ return out;
8017
+ }
8018
+ /** C2 T3c (M2): one channel's slot, so another channel's unreadable slot
8019
+ * cannot fail this read. */
8020
+ async getOwedFollowUp(channelId) {
8021
+ const row = this.db.prepare("SELECT sealed_followup, created_at FROM mls_owed_followup WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
8022
+ if (!row)
8023
+ return null;
8024
+ const o = await this.unsealJson(row.sealed_followup);
8025
+ return { op: o.op, followUp: o.followUp, createdAt: Number(row.created_at) };
8026
+ }
8027
+ /** C2 T3c (M1): with `expectCreatedAt`, delete only that slot, in ONE
8028
+ * statement (a newer slot written meanwhile has another created_at). */
8029
+ async clearOwedFollowUp(channelId, expectCreatedAt) {
8030
+ const res = expectCreatedAt === void 0 ? this.db.prepare("DELETE FROM mls_owed_followup WHERE identity_id=? AND channel_id=?").run(this.id, channelId) : this.db.prepare("DELETE FROM mls_owed_followup WHERE identity_id=? AND channel_id=? AND created_at=?").run(this.id, channelId, expectCreatedAt);
8031
+ return Number(res.changes) > 0;
8032
+ }
6206
8033
  /** @internal test-only */
6207
8034
  _rawMainForTest(identityId, channelId) {
6208
8035
  const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(identityId, channelId);
@@ -6299,12 +8126,13 @@ var LatencyRecorder = class {
6299
8126
  };
6300
8127
 
6301
8128
  // src/coordination.ts
8129
+ var TRAILING_PUNCT = /[^\w]+$/;
6302
8130
  function parseMentions(text) {
6303
8131
  const mentions = [];
6304
- const re = /@(\w[\w]*)/gi;
8132
+ const re = /@(\w[\w.-]*)/g;
6305
8133
  let match;
6306
8134
  while ((match = re.exec(text)) !== null) {
6307
- mentions.push(match[1].toLowerCase());
8135
+ mentions.push(match[1].replace(TRAILING_PUNCT, "").toLowerCase());
6308
8136
  }
6309
8137
  return mentions;
6310
8138
  }
@@ -6314,21 +8142,84 @@ function ownRoomNames(agentName, accountId, roomDisplayName) {
6314
8142
  if (!n) return;
6315
8143
  const lower = n.toLowerCase();
6316
8144
  names.add(lower);
6317
- names.add(lower.split(/[\s(]/)[0]);
8145
+ const first = lower.split(/[\s(]/)[0];
8146
+ names.add(first);
8147
+ const trimmed = first.replace(TRAILING_PUNCT, "");
8148
+ if (trimmed) names.add(trimmed);
6318
8149
  };
6319
8150
  add(agentName);
6320
8151
  add(accountId);
6321
8152
  add(roomDisplayName);
6322
8153
  return names;
6323
8154
  }
8155
+ var DEFAULT_MAX_AGENT_HOPS = 3;
8156
+ function agentHopAllowed(agentHops, maxAgentHops = DEFAULT_MAX_AGENT_HOPS) {
8157
+ return agentHops < maxAgentHops;
8158
+ }
6324
8159
  function shouldProcessRoomMessage(plaintext, agentName, accountId, opts) {
6325
8160
  const mentions = parseMentions(plaintext);
8161
+ const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
8162
+ const mentionsMe = mentions.some((m) => ownNames.has(m));
8163
+ if (opts?.senderIsAgent === true) {
8164
+ if (!mentionsMe) return false;
8165
+ return agentHopAllowed(opts.agentHops ?? 1, opts.maxAgentHops);
8166
+ }
6326
8167
  if (mentions.length === 0) return true;
6327
8168
  if (mentions.includes("all") || mentions.includes("everyone")) return true;
6328
- const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
6329
- if (mentions.some((m) => ownNames.has(m))) return true;
8169
+ if (mentionsMe) return true;
6330
8170
  return false;
6331
8171
  }
8172
+ var AGENT_HOP_QUIET_MS = 10 * 6e4;
8173
+ var AgentHopTracker = class {
8174
+ constructor(maxChannels = 1e4, opts = {}) {
8175
+ this.maxChannels = maxChannels;
8176
+ this.now = opts.now ?? (() => Date.now());
8177
+ this.quietMs = opts.quietMs ?? AGENT_HOP_QUIET_MS;
8178
+ }
8179
+ hops = /* @__PURE__ */ new Map();
8180
+ now;
8181
+ quietMs;
8182
+ /** Record an inbound message; returns the consecutive agent hops including it. */
8183
+ noteInbound(channelId, senderIsAgent) {
8184
+ if (senderIsAgent !== true) {
8185
+ this.set(channelId, { count: 0, lastAgentAt: 0 });
8186
+ return 0;
8187
+ }
8188
+ return this.bump(channelId);
8189
+ }
8190
+ /**
8191
+ * Record one of this agent's own SUBSTANTIVE sends (an agent turn in the
8192
+ * channel). Callers do not report status frames, daily-limit or dead-letter
8193
+ * notices, or continuation parts of one multi-part reply.
8194
+ */
8195
+ noteOwnSend(channelId) {
8196
+ this.bump(channelId);
8197
+ }
8198
+ size() {
8199
+ return this.hops.size;
8200
+ }
8201
+ bump(channelId) {
8202
+ const now = this.now();
8203
+ const prev = this.hops.get(channelId);
8204
+ const base = prev && prev.count > 0 && now - prev.lastAgentAt < this.quietMs ? prev.count : 0;
8205
+ const count = base + 1;
8206
+ this.set(channelId, { count, lastAgentAt: now });
8207
+ return count;
8208
+ }
8209
+ set(channelId, value) {
8210
+ this.hops.delete(channelId);
8211
+ this.hops.set(channelId, value);
8212
+ if (this.hops.size > this.maxChannels) {
8213
+ const oldest = this.hops.keys().next();
8214
+ if (!oldest.done) this.hops.delete(oldest.value);
8215
+ }
8216
+ }
8217
+ };
8218
+ function isAgentTurn(receipts, frame) {
8219
+ if (frame.notice === true) return false;
8220
+ if (typeof frame.in_reply_to === "string" && receipts.runtimeReplyReceipt(frame.in_reply_to)) return false;
8221
+ return true;
8222
+ }
6332
8223
 
6333
8224
  // src/rate-limit.ts
6334
8225
  var TokenBucketFloor = class {
@@ -6389,12 +8280,49 @@ var TokenBucketFloor = class {
6389
8280
  }
6390
8281
  };
6391
8282
 
8283
+ // src/service-log.ts
8284
+ var TOKEN2 = /^[a-z][a-z0-9_.-]{0,63}$/;
8285
+ var CLASS2 = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
8286
+ function errorClass2(error) {
8287
+ if (error === null) return "NullThrown";
8288
+ if (typeof error !== "object" && typeof error !== "function") {
8289
+ const primitive = {
8290
+ string: "StringThrown",
8291
+ number: "NumberThrown",
8292
+ boolean: "BooleanThrown",
8293
+ bigint: "BigIntThrown",
8294
+ symbol: "SymbolThrown",
8295
+ undefined: "UndefinedThrown"
8296
+ };
8297
+ return primitive[typeof error] ?? "UnknownThrown";
8298
+ }
8299
+ try {
8300
+ const name = Object.getPrototypeOf(error)?.constructor?.name;
8301
+ return typeof name === "string" && CLASS2.test(name) ? name : "Error";
8302
+ } catch {
8303
+ return "Error";
8304
+ }
8305
+ }
8306
+ function formatServiceLog2(level, event, fields = {}, error) {
8307
+ const safeEvent = TOKEN2.test(event) ? event : "invalid_event";
8308
+ const parts = [`[bridge] ${level} ${safeEvent}`];
8309
+ for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
8310
+ if (!TOKEN2.test(key)) continue;
8311
+ if (typeof value === "boolean") parts.push(`${key}=${value}`);
8312
+ else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
8313
+ }
8314
+ if (error !== void 0) parts.push(`error_class=${errorClass2(error)}`);
8315
+ return parts.join(" ");
8316
+ }
8317
+ function writeServiceLog2(level, event, fields, error) {
8318
+ const line = formatServiceLog2(level, event, fields, error);
8319
+ process.stderr.write(`${line}
8320
+ `);
8321
+ }
8322
+
6392
8323
  // src/decrypt-error-log-limiter.ts
6393
8324
  var DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 6e4;
6394
8325
  var MAX_DISTINCT_PER_WINDOW = 4;
6395
- function normalizeMessage(m) {
6396
- return m.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>").replace(/[0-9a-f]{16,}/gi, "<hex>");
6397
- }
6398
8326
  var DecryptErrorLogLimiter = class {
6399
8327
  windowMs;
6400
8328
  now;
@@ -6403,63 +8331,56 @@ var DecryptErrorLogLimiter = class {
6403
8331
  constructor(opts = {}) {
6404
8332
  this.windowMs = opts.windowMs ?? DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS;
6405
8333
  this.now = opts.now ?? Date.now;
6406
- this.log = opts.log ?? ((line) => console.warn(line));
8334
+ this.log = opts.log ?? writeServiceLog2;
6407
8335
  }
6408
- /**
6409
- * Record one decrypt error for `channelId`. Logs immediately if this is
6410
- * the channel's first error since its window opened (or its prior window
6411
- * had already elapsed, in which case a summary for that just-closed
6412
- * window is flushed first); otherwise the error is counted silently
6413
- * toward the window's eventual summary line.
6414
- */
6415
8336
  record(channelId, info) {
6416
8337
  const t = this.now();
8338
+ const kind = errorClass2(info.error);
6417
8339
  const w = this.windows.get(channelId);
6418
8340
  if (!w) {
6419
- this.logFirst(channelId, info);
6420
- this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
8341
+ this.logFirst(info);
8342
+ this.windows.set(channelId, {
8343
+ windowStart: t,
8344
+ count: 1,
8345
+ lastEpoch: info.epoch,
8346
+ loggedClasses: /* @__PURE__ */ new Set([kind])
8347
+ });
6421
8348
  return;
6422
8349
  }
6423
8350
  if (t - w.windowStart >= this.windowMs) {
6424
- this.flushSummary(channelId, w);
6425
- this.logFirst(channelId, info);
6426
- this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
8351
+ this.flushSummary(w);
8352
+ this.logFirst(info);
8353
+ this.windows.set(channelId, {
8354
+ windowStart: t,
8355
+ count: 1,
8356
+ lastEpoch: info.epoch,
8357
+ loggedClasses: /* @__PURE__ */ new Set([kind])
8358
+ });
6427
8359
  return;
6428
8360
  }
6429
8361
  w.count += 1;
6430
8362
  w.lastEpoch = info.epoch;
6431
- w.lastMessage = info.message;
6432
- const key = normalizeMessage(info.message);
6433
- if (!w.loggedKeys.has(key) && w.loggedKeys.size < MAX_DISTINCT_PER_WINDOW) {
6434
- this.logFirst(channelId, info);
6435
- w.loggedKeys.add(key);
8363
+ if (!w.loggedClasses.has(kind) && w.loggedClasses.size < MAX_DISTINCT_PER_WINDOW) {
8364
+ this.logFirst(info);
8365
+ w.loggedClasses.add(kind);
6436
8366
  }
6437
8367
  }
6438
- /**
6439
- * Clear a channel's window (e.g. once the caller learns the channel
6440
- * cleared/healed) so its NEXT error logs immediately as a fresh "first"
6441
- * rather than being folded into a stale window's summary. There is
6442
- * currently no onDecryptSuccess-style hook from the SDK to wire this to
6443
- * automatically (see bridge-agent.ts's onDecryptError wiring comment) —
6444
- * exposed here so a future success signal (or a caller with its own
6445
- * channel-cleared signal) can reset explicitly. In its absence, the
6446
- * window simply rolls over on interval elapse, which is an accepted
6447
- * simplification (see report).
6448
- */
6449
8368
  reset(channelId) {
6450
8369
  this.windows.delete(channelId);
6451
8370
  }
6452
- logFirst(channelId, info) {
6453
- this.log(
6454
- `[bridge] decrypt error on channel ${channelId} (queue ${info.queueId ?? "?"}, epoch ${info.epoch ?? "?"}): ${info.message}`
6455
- );
8371
+ logFirst(info) {
8372
+ const fields = {};
8373
+ if (typeof info.epoch === "number" && Number.isFinite(info.epoch)) fields.epoch = info.epoch;
8374
+ this.log("warn", "decrypt.failed", fields, info.error);
6456
8375
  }
6457
- flushSummary(channelId, w) {
8376
+ flushSummary(w) {
6458
8377
  if (w.count <= 1) return;
6459
- const seconds = Math.round(this.windowMs / 1e3);
6460
- this.log(
6461
- `[bridge] decrypt errors on channel ${channelId}: ${w.count} in last ${seconds}s (epoch ${w.lastEpoch ?? "?"}, ${w.lastMessage})`
6462
- );
8378
+ const fields = {
8379
+ count: w.count,
8380
+ window_seconds: Math.round(this.windowMs / 1e3)
8381
+ };
8382
+ if (typeof w.lastEpoch === "number" && Number.isFinite(w.lastEpoch)) fields.epoch = w.lastEpoch;
8383
+ this.log("warn", "decrypt.suppressed", fields);
6463
8384
  }
6464
8385
  };
6465
8386
 
@@ -6663,8 +8584,7 @@ var HermesMemoryProvider = class {
6663
8584
  try {
6664
8585
  this.store.probeWrite();
6665
8586
  } catch (e) {
6666
- process.stderr.write(`[bridge] HERMES MEMORY WRITE-BACK NON-FUNCTIONAL (schema probe failed): ${String(e)}
6667
- `);
8587
+ writeServiceLog2("error", "memory.hermes_writeback_unavailable", {}, e);
6668
8588
  }
6669
8589
  }
6670
8590
  store;
@@ -7357,9 +9277,7 @@ function parseSummarizeBody(parsed, selfIdentityId, log = () => {
7357
9277
  return null;
7358
9278
  }
7359
9279
  if (typeof body.requested_by !== "string" || body.requested_by !== transcript.host_identity_id) {
7360
- log(
7361
- `summarize_not_host: ${String(body.requested_by)} is not the host of ${body.transcript_message_id}`
7362
- );
9280
+ log("warn", "summarize.requester_not_host");
7363
9281
  return null;
7364
9282
  }
7365
9283
  return {
@@ -7414,9 +9332,8 @@ async function runSummarize(opts) {
7414
9332
  );
7415
9333
  } catch (e) {
7416
9334
  const aborted = controller.signal.aborted;
7417
- opts.log(
7418
- aborted ? `summarize_timeout after ${timeoutMs}ms for ${opts.request.transcriptMessageId}` : `summarize_failed for ${opts.request.transcriptMessageId}: ${String(e)}`
7419
- );
9335
+ if (aborted) opts.log("warn", "summarize.timeout", { timeout_ms: timeoutMs });
9336
+ else opts.log("warn", "summarize.failed", {}, e);
7420
9337
  return null;
7421
9338
  } finally {
7422
9339
  clearTimeout(timer);
@@ -7690,51 +9607,24 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
7690
9607
 
7691
9608
  // src/bridge-agent.ts
7692
9609
  import { homedir as homedir2 } from "node:os";
7693
-
7694
- // src/reply-receipts.ts
7695
- var DEFAULT_TTL_MS = 15 * 60 * 1e3;
7696
- var DEFAULT_CAPACITY2 = 256;
7697
- var RuntimeReplyReceiptLedger = class {
7698
- receipts = /* @__PURE__ */ new Map();
7699
- ttlMs;
7700
- capacity;
7701
- now;
7702
- constructor(opts = {}) {
7703
- this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
7704
- this.capacity = opts.capacity ?? DEFAULT_CAPACITY2;
7705
- this.now = opts.now ?? Date.now;
7706
- if (!Number.isFinite(this.ttlMs) || this.ttlMs <= 0) throw new Error("reply receipt ttlMs must be positive");
7707
- if (!Number.isInteger(this.capacity) || this.capacity <= 0) throw new Error("reply receipt capacity must be positive");
7708
- }
7709
- record(inReplyTo, channelId, replyMessageId) {
7710
- const receipt = Object.freeze({
7711
- in_reply_to: inReplyTo,
7712
- reply_message_id: replyMessageId,
7713
- channel_id: channelId,
7714
- replied_at: this.now()
7715
- });
7716
- this.prune(receipt.replied_at);
7717
- this.receipts.delete(inReplyTo);
7718
- this.receipts.set(inReplyTo, receipt);
7719
- while (this.receipts.size > this.capacity) {
7720
- const oldest = this.receipts.keys().next().value;
7721
- if (oldest === void 0) break;
7722
- this.receipts.delete(oldest);
7723
- }
7724
- return receipt;
7725
- }
7726
- get(inReplyTo) {
7727
- this.prune(this.now());
7728
- return this.receipts.get(inReplyTo) ?? null;
7729
- }
7730
- prune(now) {
7731
- for (const [key, receipt] of this.receipts) {
7732
- if (now - receipt.replied_at >= this.ttlMs) this.receipts.delete(key);
7733
- }
9610
+ function logPendingCommitResolved(outcome) {
9611
+ switch (outcome) {
9612
+ case "landed":
9613
+ writeServiceLog2("info", "mls.pending_commit.resolved.landed");
9614
+ return;
9615
+ case "not_landed":
9616
+ writeServiceLog2("info", "mls.pending_commit.resolved.not_landed");
9617
+ return;
9618
+ case "stale_prior":
9619
+ writeServiceLog2("info", "mls.pending_commit.resolved.stale_prior");
9620
+ return;
9621
+ case "abandoned":
9622
+ writeServiceLog2("info", "mls.pending_commit.resolved.abandoned");
9623
+ return;
9624
+ default:
9625
+ writeServiceLog2("info", "mls.pending_commit.resolved.unknown");
7734
9626
  }
7735
- };
7736
-
7737
- // src/bridge-agent.ts
9627
+ }
7738
9628
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
7739
9629
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
7740
9630
  var ROOM_VOICE_GUIDANCE_MENTION_ONLY = "You are in a group voice room. Reply ONLY when a message @mentions your display name. Keep replies to one short sentence. When you should not reply, send nothing.";
@@ -7746,6 +9636,14 @@ function selectVoiceGuidance(isDm, voiceDefaultResponder) {
7746
9636
  return VOICE_MODE_GUIDANCE;
7747
9637
  }
7748
9638
  var DEFAULT_MAX_BUFFER = 1e3;
9639
+ var DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
9640
+ var REDELIVERY_BACKOFF_BASE_MS = 5e3;
9641
+ function redeliveryDelayMs(attempts) {
9642
+ return 2 ** Math.min(Math.max(attempts, 0), 16) * REDELIVERY_BACKOFF_BASE_MS;
9643
+ }
9644
+ var DEAD_LETTER_NOTICE = "I couldn't process a message. Please resend it.";
9645
+ var DEAD_LETTER_NOTICE_INTERVAL_MS = 60 * 6e4;
9646
+ var PAUSE_RECHECK_MS = 3e4;
7749
9647
  var CHANNEL_META_TTL_MS = 5 * 6e4;
7750
9648
  var CHANNEL_ROSTER_TTL_MS = 5 * 6e4;
7751
9649
  var ZERO_TRACEPARENT = "00-" + "0".repeat(32) + "-" + "0".repeat(16) + "-01";
@@ -7825,6 +9723,13 @@ var AgentPausedSendError = class extends Error {
7825
9723
  this.name = "AgentPausedSendError";
7826
9724
  }
7827
9725
  };
9726
+ var SendForbiddenError = class extends Error {
9727
+ constructor(channelId) {
9728
+ super(`send refused for channel ${channelId} (credential revoked or not a member); no retry`);
9729
+ this.channelId = channelId;
9730
+ this.name = "SendForbiddenError";
9731
+ }
9732
+ };
7828
9733
  var UnknownActionRunError = class extends Error {
7829
9734
  constructor(runId) {
7830
9735
  super(`unknown or already-settled agent action run: ${runId}`);
@@ -7855,8 +9760,13 @@ var BridgeAgent = class _BridgeAgent {
7855
9760
  // half-open + wake + auth war-cap). On by default for the daemon;
7856
9761
  // SECURECOMS_WS_HARDENED=false reverts to the legacy 4001-terminal path.
7857
9762
  hardenedReconnect: process.env.SECURECOMS_WS_HARDENED !== "false",
7858
- onWsLifecycle: (event, detail) => process.stderr.write(`[bridge] delivery-ping ${event}${detail ? ` (${detail})` : ""}
7859
- `)
9763
+ onWsLifecycle: (event) => {
9764
+ if (event === "wake") writeServiceLog2("warn", "transport.delivery_ping_wake");
9765
+ else if (event === "halfopen") writeServiceLog2("warn", "transport.delivery_ping_halfopen");
9766
+ else if (event === "terminal") writeServiceLog2("error", "transport.delivery_ping_terminal");
9767
+ else if (event === "reconnect") writeServiceLog2("warn", "transport.delivery_ping_reconnect");
9768
+ else writeServiceLog2("warn", "transport.delivery_ping_lifecycle_unknown");
9769
+ }
7860
9770
  });
7861
9771
  this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
7862
9772
  this.store = new SqliteMlsStateStore({
@@ -7873,11 +9783,17 @@ var BridgeAgent = class _BridgeAgent {
7873
9783
  backend: this.backend,
7874
9784
  deviceId: cfg.deviceId,
7875
9785
  identity: new TextEncoder().encode(cfg.identityId),
9786
+ // C2 T0: required by the SDK. This host never commits (no committer
9787
+ // entry is reachable from its src/), so the hook only records the
9788
+ // outcome (see logPendingCommitResolved).
9789
+ onPendingCommitResolved: (_channelId, ev) => {
9790
+ logPendingCommitResolved(ev.outcome);
9791
+ },
7876
9792
  // ADR-0002: count + log agent-side decrypt failures. Without this hook the
7877
9793
  // daemon was 100% blind to a wedged channel (it "looked idle"); the
7878
9794
  // provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
7879
9795
  // P0b (daemon-stability): logging is routed through decryptErrorLogLimiter
7880
- // rather than a raw console.warn per failure — a wedged channel used to
9796
+ // rather than a raw diagnostic per failure — a wedged channel used to
7881
9797
  // redeliver the same undecryptable row on every pullDelivery cycle and
7882
9798
  // produced a 114 MB / 765,000-line daemon log in a single day. The
7883
9799
  // limiter still logs the first failure immediately and collapses every
@@ -7887,8 +9803,7 @@ var BridgeAgent = class _BridgeAgent {
7887
9803
  const cid = row.connect_channel_id ?? "(unmapped)";
7888
9804
  this.decryptErrorLogLimiter.record(cid, {
7889
9805
  epoch: row.epoch,
7890
- queueId: row.queue_id,
7891
- message: err instanceof Error ? err.message : String(err)
9806
+ error: err
7892
9807
  });
7893
9808
  },
7894
9809
  // P0b (daemon-stability): fired EXACTLY ONCE by the SDK (P0a) when a
@@ -7899,9 +9814,7 @@ var BridgeAgent = class _BridgeAgent {
7899
9814
  // healWedgedChannel below) — never throws back into the SDK caller.
7900
9815
  onWedged: (channelId) => {
7901
9816
  try {
7902
- console.warn(
7903
- `[bridge] channel ${channelId} quarantined after persistent decrypt failures \u2014 signalling re-Welcome`
7904
- );
9817
+ writeServiceLog2("warn", "decrypt.channel_quarantined");
7905
9818
  void this.healWedgedChannel(channelId);
7906
9819
  } catch {
7907
9820
  }
@@ -7926,6 +9839,8 @@ var BridgeAgent = class _BridgeAgent {
7926
9839
  this.voiceSettingsSource = cfg.voice ? "file" : "none";
7927
9840
  this.latency = deps.latency ?? new LatencyRecorder();
7928
9841
  this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
9842
+ this.maxDeliveryAttempts = deps.maxDeliveryAttempts ?? DEFAULT_MAX_DELIVERY_ATTEMPTS;
9843
+ this.maxAgentHops = deps.maxAgentHops ?? DEFAULT_MAX_AGENT_HOPS;
7929
9844
  this.rateFloor = new TokenBucketFloor({
7930
9845
  capacity: cfg.rateLimitCapacity ?? 20,
7931
9846
  refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
@@ -7993,6 +9908,15 @@ var BridgeAgent = class _BridgeAgent {
7993
9908
  * per channel on a miss.
7994
9909
  */
7995
9910
  channelRoster = /* @__PURE__ */ new Map();
9911
+ /**
9912
+ * 2026-09-25: this agent's own display_name (`ConnectIdentity.display_name`,
9913
+ * identity-level, so the same in every channel) from the MOST RECENT roster
9914
+ * refresh that listed it. Read by the room @mention filter as its
9915
+ * `roomDisplayName`. Kept across a later failed refresh, so a transient
9916
+ * members-fetch error does not make the agent deaf to its own name; a
9917
+ * successful refresh after a rename replaces it.
9918
+ */
9919
+ lastOwnDisplayName;
7996
9920
  /**
7997
9921
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
7998
9922
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -8003,13 +9927,37 @@ var BridgeAgent = class _BridgeAgent {
8003
9927
  */
8004
9928
  channelOwner = /* @__PURE__ */ new Map();
8005
9929
  buffer = [];
9930
+ /** Message ids durably staged for the attached runtime but not yet acked. */
9931
+ runtimePendingIds = /* @__PURE__ */ new Set();
8006
9932
  maxBuffer;
9933
+ /** Phase 3 F1: deliveries so far per pending runtime message (seeded from the store). */
9934
+ deliveryAttempts = /* @__PURE__ */ new Map();
9935
+ maxDeliveryAttempts;
9936
+ /** Phase 3 F1: cumulative messages dead-lettered at the delivery cap. */
9937
+ deadLetteredMessages = 0;
9938
+ /** Final-wave I1(a): message id → earliest redelivery time (epoch ms). */
9939
+ redeliverNotBefore = /* @__PURE__ */ new Map();
9940
+ /** Final-wave I1(a): one timer poking consumers when the earliest backoff ends. */
9941
+ redeliveryTimer = null;
9942
+ redeliveryTimerAt = Number.POSITIVE_INFINITY;
9943
+ /** Final-wave I1(c): channel → when its last dead-letter notice was posted. */
9944
+ deadLetterNoticeAt = /* @__PURE__ */ new Map();
9945
+ /** Phase 3 F1: channel → when a pause was last confirmed (send 403 or probe). */
9946
+ pausedChannels = /* @__PURE__ */ new Map();
8007
9947
  /**
8008
- * Short-lived, content-free runtime reply correlations. The attached runtime
8009
- * supplies the inbound message id only when an outbound send is its reply;
8010
- * ws-server/http-server record the receipt after send() publishes.
9948
+ * Phase 3 F1: runtime messages retired by a TERMINAL reply failure reported
9949
+ * outside the /v1/ws socket that holds them in flight (HTTP /v1/send). The
9950
+ * socket consults this so it neither redelivers them nor stays busy on them.
9951
+ * Bounded; oldest forgotten first.
8011
9952
  */
8012
- runtimeReplyReceipts = new RuntimeReplyReceiptLedger();
9953
+ retiredRuntimeIds = /* @__PURE__ */ new Set();
9954
+ /** Phase 3 F1: one pending membership recheck per paused channel holding work. */
9955
+ pauseRecheckTimers = /* @__PURE__ */ new Map();
9956
+ /** Phase 3 F3: consecutive agent turns per channel. */
9957
+ agentHops = new AgentHopTracker();
9958
+ maxAgentHops;
9959
+ /** Phase 3 F3: cumulative agent-authored messages not answered (no explicit mention / hop limit). */
9960
+ agentLoopSuppressed = 0;
8013
9961
  /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
8014
9962
  droppedMessages = 0;
8015
9963
  /**
@@ -8147,7 +10095,7 @@ var BridgeAgent = class _BridgeAgent {
8147
10095
  // H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
8148
10096
  static GRACEFUL_DRAIN_MS = 5e3;
8149
10097
  /**
8150
- * P0b (daemon-stability): collapses the onDecryptError console.warn from
10098
+ * P0b (daemon-stability): collapses the onDecryptError diagnostic from
8151
10099
  * "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
8152
10100
  * most one line immediately + one summary line per 60s per channel. LOG
8153
10101
  * OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
@@ -8311,16 +10259,13 @@ var BridgeAgent = class _BridgeAgent {
8311
10259
  if (!this.injectedFastLlm) {
8312
10260
  this.fastLlm = new FastLlmClient(this.cfg.voice.fastPath, this.fetchImpl);
8313
10261
  }
8314
- process.stderr.write(
8315
- `[bridge] voice settings v${this.voiceSettingsVersion} loaded (source: ${this.voiceSettingsSource})
8316
- `
8317
- );
10262
+ writeServiceLog2("info", "voice.settings_loaded", {
10263
+ version: this.voiceSettingsVersion,
10264
+ source_backend: this.voiceSettingsSource === "backend"
10265
+ });
8318
10266
  } catch (e) {
8319
10267
  if (this.voiceSettingsSource === "backend") return;
8320
- process.stderr.write(
8321
- `[bridge] voice settings unavailable (${e.message}); using ${this.voiceSettingsSource}
8322
- `
8323
- );
10268
+ writeServiceLog2("warn", "voice.settings_unavailable", {}, e);
8324
10269
  }
8325
10270
  }
8326
10271
  /**
@@ -8346,23 +10291,17 @@ var BridgeAgent = class _BridgeAgent {
8346
10291
  if (!res.ok) throw new Error(`runtime-config ${res.status}`);
8347
10292
  const payload = await res.json();
8348
10293
  if (!payload) {
8349
- process.stderr.write("[bridge] runtime config not applied (empty response)\n");
10294
+ writeServiceLog2("warn", "runtime_config.empty_response");
8350
10295
  return;
8351
10296
  }
8352
10297
  if (typeof payload.version !== "number") {
8353
- process.stderr.write(
8354
- `[bridge] runtime config not applied (version ${JSON.stringify(payload.version ?? null)} \u2014 invalid response)
8355
- `
8356
- );
10298
+ writeServiceLog2("warn", "runtime_config.invalid_version");
8357
10299
  return;
8358
10300
  }
8359
10301
  const version = payload.version;
8360
10302
  if (!version) {
8361
10303
  writeManagedFiles(this.home, this.cfg.identityId, {});
8362
- process.stderr.write(
8363
- `[bridge] runtime config not applied (version ${JSON.stringify(payload?.version ?? null)} \u2014 no saved config)
8364
- `
8365
- );
10304
+ writeServiceLog2("info", "runtime_config.not_saved");
8366
10305
  }
8367
10306
  const skillsJson = payload.skills_json ?? {};
8368
10307
  if (version) {
@@ -8395,10 +10334,10 @@ var BridgeAgent = class _BridgeAgent {
8395
10334
  }
8396
10335
  );
8397
10336
  if (result.sourcesDeleted > 0) {
8398
- process.stderr.write(
8399
- `[bridge] legacy-memory disposition applied (sources=${result.sourcesDeleted}, assigned=${result.factsAssigned})
8400
- `
8401
- );
10337
+ writeServiceLog2("info", "runtime_config.legacy_memory_disposition_applied", {
10338
+ assigned: result.factsAssigned,
10339
+ sources: result.sourcesDeleted
10340
+ });
8402
10341
  }
8403
10342
  }
8404
10343
  const local = readLocalReport(this.home, this.cfg.identityId);
@@ -8437,22 +10376,17 @@ var BridgeAgent = class _BridgeAgent {
8437
10376
  body: JSON.stringify(report)
8438
10377
  });
8439
10378
  if (!posted.ok) {
8440
- process.stderr.write(
8441
- version ? `[bridge] runtime config v${version} written, but reporting it failed (${posted.status}) \u2014 the console will keep showing it as not applied until the next cycle
8442
- ` : `[bridge] legacy-memory inventory report failed (${posted.status}); no saved runtime config was applied
8443
- `
8444
- );
10379
+ if (version) {
10380
+ writeServiceLog2("warn", "runtime_config.report_failed", { status: posted.status, version });
10381
+ } else {
10382
+ writeServiceLog2("warn", "runtime_config.legacy_memory_report_failed", { status: posted.status });
10383
+ }
8445
10384
  return;
8446
10385
  }
8447
- process.stderr.write(
8448
- version ? `[bridge] runtime config v${version} applied
8449
- ` : "[bridge] legacy-memory inventory reported; no saved runtime config was applied\n"
8450
- );
10386
+ if (version) writeServiceLog2("info", "runtime_config.applied", { version });
10387
+ else writeServiceLog2("info", "runtime_config.legacy_memory_reported");
8451
10388
  } catch (e) {
8452
- process.stderr.write(
8453
- `[bridge] runtime config refetch failed: ${e?.message ?? e}
8454
- `
8455
- );
10389
+ writeServiceLog2("warn", "runtime_config.refetch_failed", {}, e);
8456
10390
  }
8457
10391
  }
8458
10392
  /**
@@ -8470,12 +10404,13 @@ var BridgeAgent = class _BridgeAgent {
8470
10404
  if (this.maxBuffer <= 0) return;
8471
10405
  const overflow = this.buffer.length - this.maxBuffer;
8472
10406
  if (overflow > 0) {
8473
- this.buffer.splice(0, overflow);
10407
+ for (const shed of this.buffer.splice(0, overflow)) this.deliveryAttempts.delete(shed.message_id);
8474
10408
  this.droppedMessages += overflow;
8475
10409
  }
8476
10410
  }
8477
10411
  async start() {
8478
10412
  this.startedAt = Date.now();
10413
+ await this.restoreRuntimeInbox();
8479
10414
  try {
8480
10415
  await this.ingestOnce();
8481
10416
  } catch {
@@ -8494,7 +10429,7 @@ var BridgeAgent = class _BridgeAgent {
8494
10429
  // See the safetyTimer/selfHealTimer comment near close(): swallow the
8495
10430
  // expected post-close store-access rejection, log anything else.
8496
10431
  void this.selfHealSweep().catch((e) => {
8497
- if (!this.closingDown) console.error("[bridge] self-heal sweep failed:", e);
10432
+ if (!this.closingDown) writeServiceLog2("error", "self_heal.sweep_failed", {}, e);
8498
10433
  })
8499
10434
  ),
8500
10435
  this.sweepJitter()
@@ -8521,8 +10456,7 @@ var BridgeAgent = class _BridgeAgent {
8521
10456
  if (mapped) return mapped;
8522
10457
  if (!this.enclaveFallbackWarned.has(channelId)) {
8523
10458
  this.enclaveFallbackWarned.add(channelId);
8524
- process.stderr.write(`[bridge] send.enclave_fallback channel=${channelId} stamped=home
8525
- `);
10459
+ writeServiceLog2("warn", "send.enclave_fallback");
8526
10460
  }
8527
10461
  return this.cfg.enclaveId;
8528
10462
  }
@@ -8533,7 +10467,7 @@ var BridgeAgent = class _BridgeAgent {
8533
10467
  this.joinState.set(channelId, "JOINED");
8534
10468
  return true;
8535
10469
  } catch (e) {
8536
- console.warn(`[bridge] reconcileJoin ${channelId} failed:`, e);
10470
+ writeServiceLog2("warn", "join.reconcile_failed", {}, e);
8537
10471
  this.joinState.set(channelId, "PENDING");
8538
10472
  return false;
8539
10473
  }
@@ -8606,15 +10540,14 @@ var BridgeAgent = class _BridgeAgent {
8606
10540
  }
8607
10541
  this.lastWipe.set(channelId, now);
8608
10542
  } else {
8609
- console.warn(
8610
- `[bridge] healWedgedChannel ${channelId}: wipe suppressed by ${provenForked ? "proven-fork re-wipe floor" : "anti-thrash bound"} (${Math.round((floor - (now - lastWipe)) / 1e3)}s remaining) \u2014 NOT repudiated`
8611
- );
10543
+ writeServiceLog2("warn", "self_heal.wipe_suppressed", {
10544
+ proven_forked: provenForked,
10545
+ remaining_seconds: Math.round((floor - (now - lastWipe)) / 1e3)
10546
+ });
8612
10547
  }
8613
10548
  this.joinState.delete(channelId);
8614
10549
  if (!repudiated && await this.provider.hasPersistedChannelState(channelId)) {
8615
- console.warn(
8616
- `[bridge] healWedgedChannel ${channelId}: wipe suppressed and state still held \u2014 NOT requesting (the answer would be stray-acked and would cost the group two commits)`
8617
- );
10550
+ writeServiceLog2("warn", "self_heal.request_suppressed_state_held");
8618
10551
  return false;
8619
10552
  }
8620
10553
  const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
@@ -8623,7 +10556,7 @@ var BridgeAgent = class _BridgeAgent {
8623
10556
  await this.signalHeldKpWelcome(g.group_id);
8624
10557
  return repudiated;
8625
10558
  } catch (e) {
8626
- console.warn(`[bridge] healWedgedChannel ${channelId} failed:`, e);
10559
+ writeServiceLog2("warn", "self_heal.channel_failed", {}, e);
8627
10560
  return false;
8628
10561
  }
8629
10562
  }
@@ -8650,7 +10583,8 @@ var BridgeAgent = class _BridgeAgent {
8650
10583
  this.lastWelcomeRequest.set(channelId, now);
8651
10584
  await this.signalHeldKpWelcome(groupId);
8652
10585
  return true;
8653
- } catch {
10586
+ } catch (e) {
10587
+ writeServiceLog2("warn", "self_heal.supersede_signal_failed", {}, e);
8654
10588
  return false;
8655
10589
  }
8656
10590
  }
@@ -8722,40 +10656,50 @@ var BridgeAgent = class _BridgeAgent {
8722
10656
  * reads `null` and therefore degraded.
8723
10657
  */
8724
10658
  keypackageExpired = null;
10659
+ /** #335: dedupes the expiry-measurement warning to once per failure STREAK —
10660
+ * any non-throwing resolution (including the known-unknown `null` returns,
10661
+ * which are answers, not failures) re-arms it. Mirrors `enclaveListWarned`. */
10662
+ keypackageExpiryUnknownWarned = false;
8725
10663
  /** Resolve the flag from the SAME two inputs the guard uses, with every unknown
8726
10664
  * mapped to `null`. Never throws: a failure to measure is itself unknown. */
8727
10665
  async resolveKeypackageExpired() {
8728
10666
  try {
8729
10667
  const kpId = await this.provider.getLastPublishedKpId();
8730
- if (kpId === null || kpId === "") return null;
10668
+ if (kpId === null || kpId === "") return this.kpExpiryResolved(null);
8731
10669
  const exp = await this.backend.fetchKeyPackageExpiry(kpId);
8732
- if (exp === null) return null;
8733
- return isServerExpired(exp, Date.now());
8734
- } catch {
10670
+ if (exp === null) return this.kpExpiryResolved(null);
10671
+ return this.kpExpiryResolved(isServerExpired(exp, Date.now()));
10672
+ } catch (e) {
10673
+ if (!this.keypackageExpiryUnknownWarned) {
10674
+ this.keypackageExpiryUnknownWarned = true;
10675
+ writeServiceLog2("warn", "self_heal.keypackage_expiry_unknown", {}, e);
10676
+ }
8735
10677
  return null;
8736
10678
  }
8737
10679
  }
10680
+ /** Every non-throwing resolution ends the failure streak (the next throw is
10681
+ * a NEW streak and warns again) and passes the value through unchanged. */
10682
+ kpExpiryResolved(value) {
10683
+ this.keypackageExpiryUnknownWarned = false;
10684
+ return value;
10685
+ }
8738
10686
  async rotateDeviceKeyPackage() {
8739
10687
  try {
8740
10688
  await this.provider.publishKeyPackage();
8741
10689
  } catch (e) {
8742
- console.error("[bridge] selfHealSweep: device KeyPackage rotation failed (continuing):", e);
10690
+ writeServiceLog2("error", "self_heal.keypackage_rotation_failed", {}, e);
8743
10691
  }
8744
10692
  }
8745
10693
  async selfHealSweep() {
8746
10694
  if (this.closingDown) return;
8747
10695
  if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
8748
10696
  this.providerCapabilityMissing = true;
8749
- console.error(
8750
- "[bridge] selfHealSweep ABORTED: provider is missing required capabilities (resyncByReplay/heldGroupIdFor) \u2014 stale SDK dist? Rebuild SDK dist THEN bridge dist. Healing is NOT running; observations are reported STALE."
8751
- );
10697
+ writeServiceLog2("error", "self_heal.provider_capability_missing");
8752
10698
  return;
8753
10699
  }
8754
10700
  if (typeof this.backend?.fetchKeyPackageExpiry !== "function") {
8755
10701
  this.providerCapabilityMissing = true;
8756
- console.error(
8757
- "[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."
8758
- );
10702
+ writeServiceLog2("error", "self_heal.backend_capability_missing");
8759
10703
  return;
8760
10704
  }
8761
10705
  this.providerCapabilityMissing = false;
@@ -8782,13 +10726,13 @@ var BridgeAgent = class _BridgeAgent {
8782
10726
  failed.push(encId);
8783
10727
  if (!this.enclaveListWarned.has(encId)) {
8784
10728
  this.enclaveListWarned.add(encId);
8785
- console.warn(`[bridge] selfHealSweep: listChannels failed for enclave ${encId}; skipping (warn-once per streak):`, e);
10729
+ writeServiceLog2("warn", "self_heal.enclave_listing_failed", {}, e);
8786
10730
  }
8787
10731
  }
8788
10732
  }
8789
10733
  this.sweepEnclavesFailed = failed;
8790
10734
  if (!listedAny) {
8791
- console.warn("[bridge] selfHealSweep aborted: every enclave listing failed");
10735
+ writeServiceLog2("warn", "self_heal.all_enclave_listings_failed", { count: failed.length });
8792
10736
  return;
8793
10737
  }
8794
10738
  for (const c of chans) {
@@ -8839,26 +10783,20 @@ var BridgeAgent = class _BridgeAgent {
8839
10783
  }
8840
10784
  obs.state = "healthy";
8841
10785
  obs.diverged = false;
8842
- console.warn(
8843
- `[bridge] selfHealSweep: ${c.channel_id} caught up by REPLAY to epoch ${obs.held_epoch} (no re-admit, no epoch churn)`
8844
- );
10786
+ writeServiceLog2("info", "self_heal.replay_succeeded", { epoch: obs.held_epoch });
8845
10787
  continue;
8846
10788
  }
8847
10789
  const repudiated = await this.healWedgedChannel(c.channel_id, true);
8848
- console.warn(
8849
- `[bridge] selfHealSweep: ${c.channel_id} STUCK at epoch ${obs.held_epoch} (server=${obs.server_epoch}) across two sweeps \u2014 ${repudiated ? "repudiated and re-requested" : "repair SUPPRESSED (anti-thrash); state unchanged"}`
8850
- );
10790
+ writeServiceLog2("warn", "self_heal.stuck_channel", { repudiated });
8851
10791
  }
8852
10792
  continue;
8853
10793
  }
8854
- console.debug(
8855
- `[bridge] selfHealSweep: ${c.channel_id} ${behind ? `BEHIND (held=${obs.held_epoch} server=${obs.server_epoch}) \u2014 reporting only, action predicate is group-id` : "healthy, held group matches server"} \u2014 skipping`
8856
- );
10794
+ writeServiceLog2("debug", "self_heal.channel_healthy");
8857
10795
  continue;
8858
10796
  }
8859
10797
  obs.state = "diverged";
8860
10798
  obs.diverged = true;
8861
- console.warn(`[bridge] selfHealSweep: ${c.channel_id} holds STALE group state \u2014 signalling re-Welcome`);
10799
+ writeServiceLog2("warn", "self_heal.stale_group_state");
8862
10800
  } else {
8863
10801
  obs.state = "no_local_state";
8864
10802
  obs.diverged = false;
@@ -8881,7 +10819,7 @@ var BridgeAgent = class _BridgeAgent {
8881
10819
  this.lastWelcomeRequest.set(c.channel_id, now);
8882
10820
  await this.signalHeldKpWelcome(g.group_id);
8883
10821
  } catch (e) {
8884
- console.warn(`[bridge] selfHealSweep: channel ${c.channel_id} failed:`, e);
10822
+ writeServiceLog2("warn", "self_heal.channel_probe_failed", {}, e);
8885
10823
  } finally {
8886
10824
  this.observations.set(c.channel_id, obs);
8887
10825
  }
@@ -8924,16 +10862,28 @@ var BridgeAgent = class _BridgeAgent {
8924
10862
  async sendWithSelfHealTerminal(...args2) {
8925
10863
  const [, channelId] = args2;
8926
10864
  try {
8927
- return await sendWithSelfHeal(...args2);
10865
+ const result = await sendWithSelfHeal(...args2);
10866
+ if (this.pausedChannels.has(channelId)) this.clearPause(channelId);
10867
+ return result;
8928
10868
  } catch (e) {
8929
10869
  if (e instanceof AgentPausedError) {
8930
- console.warn(`[bridge] send suppressed: agent paused in channel ${channelId}`);
10870
+ writeServiceLog2("warn", "send.agent_paused");
10871
+ this.pausedChannels.set(channelId, Date.now());
8931
10872
  throw new AgentPausedSendError(channelId);
8932
10873
  }
10874
+ if (e instanceof Error && /^publish failed: 40[13]$/.test(e.message)) {
10875
+ writeServiceLog2("warn", "send.forbidden");
10876
+ throw new SendForbiddenError(channelId);
10877
+ }
8933
10878
  throw e;
8934
10879
  }
8935
10880
  }
8936
- async send(channelId, text, messageId) {
10881
+ /**
10882
+ * `opts.agentTurn` (default true): whether this send is a SUBSTANTIVE agent
10883
+ * turn for the Phase 3 F3 hop count. Notices (daily limit, dead letter) and
10884
+ * continuation parts of one multi-part reply pass false (final-wave I2).
10885
+ */
10886
+ async send(channelId, text, messageId, opts = {}) {
8937
10887
  const meta = this.freshMeta(channelId);
8938
10888
  if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
8939
10889
  const joined = await this.reconcileJoin(channelId);
@@ -8968,10 +10918,11 @@ var BridgeAgent = class _BridgeAgent {
8968
10918
  (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
8969
10919
  );
8970
10920
  this.latency.record("send.total", performance.now() - sendStart);
10921
+ if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
8971
10922
  void recordOutboundMemory(this.memory, channelId, text, id);
8972
10923
  return { message_id: id, status: "sent" };
8973
10924
  }
8974
- async sendA2A(channelId, a2a, messageId) {
10925
+ async sendA2A(channelId, a2a, messageId, opts = {}) {
8975
10926
  this.enforceRateFloor("a2a", channelId);
8976
10927
  const joined = await this.reconcileJoin(channelId);
8977
10928
  if (!joined) throw new ChannelPendingError(channelId);
@@ -9005,6 +10956,7 @@ var BridgeAgent = class _BridgeAgent {
9005
10956
  (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
9006
10957
  );
9007
10958
  this.latency.record("send.total", performance.now() - sendStart);
10959
+ if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
9008
10960
  return { message_id: id, status: "sent" };
9009
10961
  }
9010
10962
  sendDeps() {
@@ -9070,6 +11022,7 @@ var BridgeAgent = class _BridgeAgent {
9070
11022
  (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
9071
11023
  );
9072
11024
  this.latency.record("send.total", performance.now() - sendStart);
11025
+ this.agentHops.noteOwnSend(channelId);
9073
11026
  return { message_id: id, status: "sent" };
9074
11027
  }
9075
11028
  // ── B7: FileSafe vault commands (loopback boundary) ──────────────────────
@@ -9398,10 +11351,7 @@ var BridgeAgent = class _BridgeAgent {
9398
11351
  return defer ? "deferred" : "answered";
9399
11352
  } catch (e) {
9400
11353
  this.fastPathFailed++;
9401
- process.stderr.write(
9402
- `[bridge] voice fast path failed for ${m.channelId}/${m.messageId}: ${e.message ?? "unknown error"}
9403
- `
9404
- );
11354
+ writeServiceLog2("warn", "voice.fast_path_failed", {}, e);
9405
11355
  return "failed";
9406
11356
  }
9407
11357
  }
@@ -9590,10 +11540,7 @@ var BridgeAgent = class _BridgeAgent {
9590
11540
  } catch (e) {
9591
11541
  if (!this.enclaveEnumWarned) {
9592
11542
  this.enclaveEnumWarned = true;
9593
- console.warn(
9594
- "[bridge] enclave enumeration failed; sweep coverage degraded to home+learned enclaves:",
9595
- e
9596
- );
11543
+ writeServiceLog2("warn", "self_heal.enclave_enumeration_failed", {}, e);
9597
11544
  }
9598
11545
  }
9599
11546
  }
@@ -9641,6 +11588,8 @@ var BridgeAgent = class _BridgeAgent {
9641
11588
  });
9642
11589
  }
9643
11590
  this.channelRoster.set(channelId, { members, at: Date.now() });
11591
+ const self = members.get(this.cfg.identityId);
11592
+ if (self?.name) this.lastOwnDisplayName = self.name;
9644
11593
  await this.resolveAndPinOwner(channelId, rows);
9645
11594
  } catch {
9646
11595
  }
@@ -9701,9 +11650,7 @@ var BridgeAgent = class _BridgeAgent {
9701
11650
  keyAlgo: owner.key_algo ?? "ed25519"
9702
11651
  });
9703
11652
  } else if (existing.publicKeyHex !== publicKeyHex) {
9704
- console.warn(
9705
- `[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
9706
- );
11653
+ writeServiceLog2("warn", "identity.owner_pin_mismatch");
9707
11654
  this.channelOwner.set(channelId, "anomalous");
9708
11655
  }
9709
11656
  } catch {
@@ -9721,19 +11668,20 @@ var BridgeAgent = class _BridgeAgent {
9721
11668
  const cached = this.channelOwner.get(channelId);
9722
11669
  const TRACE = typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY;
9723
11670
  if (!cached || cached === "anomalous") {
9724
- if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: cache ${!cached ? "MISSING (cold)" : "anomalous"}`);
11671
+ if (TRACE && !cached) writeServiceLog2("debug", "identity.owner_cache_missing");
11672
+ else if (TRACE) writeServiceLog2("debug", "identity.owner_cache_anomalous");
9725
11673
  return null;
9726
11674
  }
9727
11675
  const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
9728
11676
  if (!pin) {
9729
- if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: NO PERSISTED PIN for owner ${cached.identityId.slice(0, 8)} (cache had ${cached.publicKeyHex.slice(0, 8)})`);
11677
+ if (TRACE) writeServiceLog2("debug", "identity.owner_pin_missing");
9730
11678
  return null;
9731
11679
  }
9732
11680
  if (pin.publicKeyHex !== cached.publicKeyHex) {
9733
- if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: PIN MISMATCH pin=${pin.publicKeyHex.slice(0, 8)} cache=${cached.publicKeyHex.slice(0, 8)}`);
11681
+ if (TRACE) writeServiceLog2("debug", "identity.owner_pin_mismatch");
9734
11682
  return null;
9735
11683
  }
9736
- if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> OK owner=${cached.identityId.slice(0, 8)}`);
11684
+ if (TRACE) writeServiceLog2("debug", "identity.owner_pin_resolved");
9737
11685
  return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
9738
11686
  }
9739
11687
  async reportStatus(channelId, state, detail) {
@@ -9824,13 +11772,13 @@ var BridgeAgent = class _BridgeAgent {
9824
11772
  const body = await runSummarize({
9825
11773
  request,
9826
11774
  complete: (prompt, o) => this.completeForSummary(prompt, o.signal),
9827
- log: (message) => this.log(message)
11775
+ log: writeServiceLog2
9828
11776
  });
9829
11777
  if (body === null) return;
9830
11778
  try {
9831
11779
  await this.sendSummary(channelId, body);
9832
11780
  } catch (e) {
9833
- this.log(`summarize_send_failed: ${String(e)}`);
11781
+ writeServiceLog2("warn", "summarize.send_failed", {}, e);
9834
11782
  }
9835
11783
  }
9836
11784
  /**
@@ -9847,7 +11795,7 @@ var BridgeAgent = class _BridgeAgent {
9847
11795
  */
9848
11796
  async completeForSummary(prompt, signal) {
9849
11797
  if (!this.fastLlm) {
9850
- this.log("summarize_no_runtime: no fast-path LLM configured for this agent");
11798
+ writeServiceLog2("warn", "summarize.runtime_unavailable");
9851
11799
  throw new Error("summarize_no_runtime");
9852
11800
  }
9853
11801
  const messages = [{ role: "user", content: prompt }];
@@ -9896,12 +11844,6 @@ var BridgeAgent = class _BridgeAgent {
9896
11844
  (envelope) => this.transport.publish(envelope)
9897
11845
  );
9898
11846
  }
9899
- /** Minimal stderr logger for non-fatal daemon events (mirrors the ad hoc
9900
- * `process.stderr.write` calls elsewhere in this file). */
9901
- log(message) {
9902
- process.stderr.write(`[bridge] ${message}
9903
- `);
9904
- }
9905
11847
  /**
9906
11848
  * The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
9907
11849
  * If the run has no dirty nodes, no-op. If the per-run bucket has no token,
@@ -10043,7 +11985,7 @@ var BridgeAgent = class _BridgeAgent {
10043
11985
  for await (const ping of sub.pings) {
10044
11986
  this.pingsReceived++;
10045
11987
  if (ping?.reconnected) {
10046
- process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
11988
+ writeServiceLog2("info", "transport.delivery_ping_connected");
10047
11989
  await this.refetchRuntimeConfig();
10048
11990
  }
10049
11991
  if (ping?.voiceSettingsChanged) {
@@ -10059,21 +12001,19 @@ var BridgeAgent = class _BridgeAgent {
10059
12001
  } catch {
10060
12002
  }
10061
12003
  if (!this.closingDown) {
10062
- process.stderr.write(
10063
- "[bridge] delivery-ping stream ENDED (terminal WS close, e.g. 4001) \u2014 inbound reduced to the 25s safety-floor drain until the daemon restarts\n"
10064
- );
12004
+ writeServiceLog2("error", "transport.delivery_ping_stream_ended");
10065
12005
  }
10066
12006
  })();
10067
12007
  this.safetyTimer = setInterval(() => {
10068
12008
  void this.ingestOnce().catch((e) => {
10069
- if (!this.closingDown) console.error("[bridge] ingestOnce failed:", e);
12009
+ if (!this.closingDown) writeServiceLog2("error", "receive.ingest_failed", {}, e);
10070
12010
  });
10071
12011
  }, 25e3);
10072
12012
  if (this.selfHealSweepEnabled) {
10073
12013
  const scheduleSweep = () => {
10074
12014
  this.selfHealTimer = setTimeout(() => {
10075
12015
  void this.selfHealSweep().catch((e) => {
10076
- if (!this.closingDown) console.error("[bridge] self-heal sweep failed:", e);
12016
+ if (!this.closingDown) writeServiceLog2("error", "self_heal.sweep_failed", {}, e);
10077
12017
  });
10078
12018
  scheduleSweep();
10079
12019
  }, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + this.sweepJitter());
@@ -10113,7 +12053,7 @@ var BridgeAgent = class _BridgeAgent {
10113
12053
  try {
10114
12054
  await this.signalHeldKpWelcome(gid);
10115
12055
  } catch (e) {
10116
- console.warn(`[bridge] ingestOnce: signalHeldKpWelcome for ${cid} failed:`, e);
12056
+ writeServiceLog2("warn", "self_heal.welcome_signal_failed", {}, e);
10117
12057
  }
10118
12058
  }
10119
12059
  } catch {
@@ -10154,11 +12094,7 @@ var BridgeAgent = class _BridgeAgent {
10154
12094
  );
10155
12095
  if (parsed.type && parsed.type !== "text") {
10156
12096
  if (parsed.type === "summarize") {
10157
- const request = parseSummarizeBody(
10158
- parsed,
10159
- this.cfg.identityId,
10160
- (msg) => this.log(msg)
10161
- );
12097
+ const request = parseSummarizeBody(parsed, this.cfg.identityId, writeServiceLog2);
10162
12098
  if (request !== null) {
10163
12099
  void this.handleSummarize(m.channelId, request);
10164
12100
  }
@@ -10171,12 +12107,38 @@ var BridgeAgent = class _BridgeAgent {
10171
12107
  const senderType = m.envelope.sender_type;
10172
12108
  const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
10173
12109
  const chanMeta = this.freshMeta(m.channelId);
12110
+ const loopSenderIsAgent = this.loopSenderIsAgent(
12111
+ m.channelId,
12112
+ m.envelope.sender_id,
12113
+ senderIsAgent,
12114
+ false
12115
+ );
12116
+ const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent);
12117
+ if (loopSenderIsAgent === true) {
12118
+ const shareText = typeof parsed.text === "string" ? parsed.text : "";
12119
+ const answerable = chanMeta && chanMeta.is_dm === false ? shouldProcessRoomMessage(
12120
+ shareText,
12121
+ this.cfg.agentName ?? this.cfg.identityId,
12122
+ this.cfg.identityId,
12123
+ {
12124
+ senderIsAgent: true,
12125
+ roomDisplayName: this.ownRoomDisplayName(),
12126
+ agentHops,
12127
+ maxAgentHops: this.maxAgentHops
12128
+ }
12129
+ ) : agentHopAllowed(agentHops, this.maxAgentHops);
12130
+ if (!answerable) {
12131
+ this.noteAgentTurnSuppressed(agentHops);
12132
+ acked.push(m.queueId);
12133
+ continue;
12134
+ }
12135
+ }
10174
12136
  const attachment = await materializeAttachment(
10175
12137
  parsed,
10176
12138
  { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
10177
12139
  this.receiveDeps()
10178
12140
  );
10179
- this.buffer.push({
12141
+ const runtimeMessage = {
10180
12142
  channel_id: m.channelId,
10181
12143
  message_id: m.messageId,
10182
12144
  sender_id: m.envelope.sender_id,
@@ -10190,14 +12152,11 @@ var BridgeAgent = class _BridgeAgent {
10190
12152
  voice: true,
10191
12153
  guidance: selectVoiceGuidance(chanMeta?.is_dm, m.voiceDefaultResponder)
10192
12154
  } : {}
10193
- });
10194
- this.capBuffer();
12155
+ };
12156
+ if (!await this.stageRuntimeMessage(runtimeMessage)) continue;
10195
12157
  } catch (e) {
10196
12158
  this.failedAttachments++;
10197
- process.stderr.write(
10198
- `[bridge] attachment failed on ${m.messageId}: ${e.message}
10199
- `
10200
- );
12159
+ writeServiceLog2("warn", "attachment.materialize_failed", {}, e);
10201
12160
  }
10202
12161
  }
10203
12162
  acked.push(m.queueId);
@@ -10207,16 +12166,35 @@ var BridgeAgent = class _BridgeAgent {
10207
12166
  const meta = this.freshMeta(m.channelId);
10208
12167
  const senderType = m.envelope.sender_type;
10209
12168
  const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
12169
+ const speaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string" ? {
12170
+ identityId: parsed.speaker_identity_id,
12171
+ displayName: parsed.speaker_display_name
12172
+ } : void 0;
12173
+ const loopSenderIsAgent = this.loopSenderIsAgent(
12174
+ m.channelId,
12175
+ m.envelope.sender_id,
12176
+ senderIsAgent,
12177
+ speaker !== void 0
12178
+ );
12179
+ const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent);
10210
12180
  let shouldBuffer = true;
10211
12181
  if (meta && meta.is_dm === false) {
10212
12182
  shouldBuffer = shouldProcessRoomMessage(
10213
12183
  parsed.text,
10214
12184
  this.cfg.agentName ?? this.cfg.identityId,
10215
12185
  this.cfg.identityId,
10216
- { senderIsAgent, roomDisplayName: meta.name ?? void 0 }
12186
+ {
12187
+ senderIsAgent: loopSenderIsAgent,
12188
+ roomDisplayName: this.ownRoomDisplayName(),
12189
+ agentHops,
12190
+ maxAgentHops: this.maxAgentHops
12191
+ }
10217
12192
  );
10218
12193
  if (!shouldBuffer) this.filteredRoomMessages++;
12194
+ } else if (loopSenderIsAgent === true && !agentHopAllowed(agentHops, this.maxAgentHops)) {
12195
+ shouldBuffer = false;
10219
12196
  }
12197
+ if (!shouldBuffer && loopSenderIsAgent === true) this.noteAgentTurnSuppressed(agentHops);
10220
12198
  if (shouldBuffer) {
10221
12199
  const senderName = this.freshRoster(m.channelId)?.get(
10222
12200
  m.envelope.sender_id
@@ -10226,10 +12204,6 @@ var BridgeAgent = class _BridgeAgent {
10226
12204
  m.envelope.sender_id
10227
12205
  );
10228
12206
  const runtimeMemory = this.memoryForTurn(m.channelId, turnAuthorization);
10229
- const speaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string" ? {
10230
- identityId: parsed.speaker_identity_id,
10231
- displayName: parsed.speaker_display_name
10232
- } : void 0;
10233
12207
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
10234
12208
  const humanTurn = senderIsAgent !== true || speaker !== void 0;
10235
12209
  const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
@@ -10238,7 +12212,7 @@ var BridgeAgent = class _BridgeAgent {
10238
12212
  if (this.rateFloor.available(`room:${m.channelId}`) < 2) fastPathRoomBlocked = true;
10239
12213
  }
10240
12214
  if (fastPathRoomBlocked) this.fastPathSkipped++;
10241
- const fastPathFiring = fastPathEligible && !fastPathRoomBlocked;
12215
+ const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedChannels.has(m.channelId);
10242
12216
  const turnText = parsed.text;
10243
12217
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
10244
12218
  const fastGreeting = isCallStart && fastPathFiring;
@@ -10262,7 +12236,7 @@ var BridgeAgent = class _BridgeAgent {
10262
12236
  });
10263
12237
  if (fastPathFiring) {
10264
12238
  const fastSpeaker = speaker ?? (senderName ? { identityId: m.envelope.sender_id, displayName: senderName } : void 0);
10265
- void this.runFastPath(
12239
+ const outcome = await this.runFastPath(
10266
12240
  {
10267
12241
  channelId: m.channelId,
10268
12242
  messageId: m.messageId,
@@ -10272,16 +12246,15 @@ var BridgeAgent = class _BridgeAgent {
10272
12246
  fastGreeting ? greetingTurnFor(this.voiceSettings ?? DEFAULT_VOICE_SETTINGS, fastSpeaker?.displayName) : parsed.text,
10273
12247
  voiceGuidance,
10274
12248
  fastSpeaker
10275
- ).then(async (outcome) => {
10276
- if (outcome === "answered") return;
10277
- this.buffer.push(runtimeEntry(outcome === "deferred", await runtimeMemory));
10278
- this.capBuffer();
10279
- this.wakeListeners();
10280
- });
12249
+ );
12250
+ if (outcome !== "answered") {
12251
+ if (!await this.stageRuntimeMessage(runtimeEntry(outcome === "deferred", await runtimeMemory))) {
12252
+ continue;
12253
+ }
12254
+ }
10281
12255
  } else {
10282
- this.buffer.push(runtimeEntry(false, await runtimeMemory));
12256
+ if (!await this.stageRuntimeMessage(runtimeEntry(false, await runtimeMemory))) continue;
10283
12257
  }
10284
- this.capBuffer();
10285
12258
  void recordInboundMemory(
10286
12259
  this.memory,
10287
12260
  m.channelId,
@@ -10312,7 +12285,10 @@ var BridgeAgent = class _BridgeAgent {
10312
12285
  }
10313
12286
  async drain(channelId, waitSeconds) {
10314
12287
  let got = this.takeBuffered(channelId);
10315
- if (got.length || waitSeconds <= 0) return got;
12288
+ if (got.length || waitSeconds <= 0) {
12289
+ this.ackRuntimeMessages(got.map((message) => message.message_id));
12290
+ return got;
12291
+ }
10316
12292
  const cap = Math.min(waitSeconds, 50) * 1e3;
10317
12293
  await new Promise((resolve) => {
10318
12294
  let fired = false;
@@ -10328,7 +12304,9 @@ var BridgeAgent = class _BridgeAgent {
10328
12304
  timer = setTimeout(wake, cap);
10329
12305
  this.waiters.push(wake);
10330
12306
  });
10331
- return this.takeBuffered(channelId);
12307
+ got = this.takeBuffered(channelId);
12308
+ this.ackRuntimeMessages(got.map((message) => message.message_id));
12309
+ return got;
10332
12310
  }
10333
12311
  /** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
10334
12312
  subscribe(listener) {
@@ -10337,19 +12315,319 @@ var BridgeAgent = class _BridgeAgent {
10337
12315
  }
10338
12316
  /** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
10339
12317
  takeBuffered(channelId) {
10340
- if (channelId === void 0) return this.buffer.splice(0);
12318
+ if (channelId === void 0 && this.pausedChannels.size === 0) return this.buffer.splice(0);
10341
12319
  const keep = [];
10342
12320
  const out = [];
10343
- for (const m of this.buffer) (m.channel_id === channelId ? out : keep).push(m);
12321
+ for (const m of this.buffer) {
12322
+ const wanted = channelId === void 0 || m.channel_id === channelId;
12323
+ (wanted && !this.heldForPause(m.channel_id) ? out : keep).push(m);
12324
+ }
10344
12325
  this.buffer.length = 0;
10345
12326
  this.buffer.push(...keep);
10346
12327
  return out;
10347
12328
  }
12329
+ /**
12330
+ * Phase 3 F2: per-channel ordered delivery for the /v1/ws runtime consumer.
12331
+ * Splices out, oldest first, at most ONE buffered message per channel that
12332
+ * is not in `busyChannels` (the channels the consumer still holds a message
12333
+ * in flight for); everything else stays buffered in order. A runtime is
12334
+ * therefore never handed a second message for a channel it is still
12335
+ * working on — which it used to refuse with a "busy" reply and ack — and a
12336
+ * backlog replays one message per channel at a time after a restart.
12337
+ */
12338
+ takeDeliverable(busyChannels) {
12339
+ const picked = /* @__PURE__ */ new Set();
12340
+ const out = [];
12341
+ const keep = [];
12342
+ const backingOff = /* @__PURE__ */ new Set();
12343
+ const now = Date.now();
12344
+ let wakeAt = Number.POSITIVE_INFINITY;
12345
+ for (const m of this.buffer) {
12346
+ const ch = m.channel_id;
12347
+ if (busyChannels.has(ch) || picked.has(ch) || backingOff.has(ch) || this.heldForPause(ch)) {
12348
+ keep.push(m);
12349
+ continue;
12350
+ }
12351
+ const notBefore = this.redeliverNotBefore.get(m.message_id);
12352
+ if (notBefore !== void 0 && notBefore > now) {
12353
+ backingOff.add(ch);
12354
+ wakeAt = Math.min(wakeAt, notBefore);
12355
+ keep.push(m);
12356
+ continue;
12357
+ }
12358
+ picked.add(ch);
12359
+ out.push(m);
12360
+ }
12361
+ if (wakeAt !== Number.POSITIVE_INFINITY) this.armRedeliveryWake(wakeAt);
12362
+ if (out.length) {
12363
+ this.buffer.length = 0;
12364
+ this.buffer.push(...keep);
12365
+ }
12366
+ return out;
12367
+ }
12368
+ /**
12369
+ * Phase 3 F1: gate ONE delivery of `message` to the runtime consumer. Returns
12370
+ * true when it may be sent (and counts the attempt durably), false when the
12371
+ * message already reached the delivery cap — then it is dead-lettered:
12372
+ * acknowledged, never redelivered, never reaching a model. (Paused channels
12373
+ * never get here: takeDeliverable holds them, so a pause spends no attempts.)
12374
+ * Logs are content-free (event + counts only).
12375
+ */
12376
+ admitRuntimeDelivery(message) {
12377
+ const prior = this.deliveryAttempts.get(message.message_id) ?? 0;
12378
+ if (prior >= this.maxDeliveryAttempts) {
12379
+ this.deadLetter(message, prior);
12380
+ return false;
12381
+ }
12382
+ this.redeliverNotBefore.delete(message.message_id);
12383
+ let durable = 0;
12384
+ try {
12385
+ durable = this.store.noteRuntimeInboxDelivery(message.message_id);
12386
+ } catch (e) {
12387
+ writeServiceLog2("warn", "runtime_inbox.attempt_persist_failed", {}, e);
12388
+ }
12389
+ this.deliveryAttempts.set(message.message_id, Math.max(durable, prior + 1));
12390
+ return true;
12391
+ }
12392
+ /**
12393
+ * Final-wave I1: put back runtime deliveries that FAILED — the runtime sent a
12394
+ * `nack` for the message, or the socket closed while it was in flight (an
12395
+ * older runtime closes on any failed turn). Each one's attempt was already
12396
+ * counted durably at delivery; here it either dead-letters (cap reached) or
12397
+ * is requeued at the front behind a 2^attempts × 5 s redelivery backoff.
12398
+ * Messages that were never handed to the runtime go back via requeueFront
12399
+ * instead and are not affected.
12400
+ */
12401
+ requeueFailedRuntimeDeliveries(messages) {
12402
+ const back = [];
12403
+ const now = Date.now();
12404
+ for (const m of messages) {
12405
+ const attempts = this.deliveryAttempts.get(m.message_id) ?? 0;
12406
+ if (attempts >= this.maxDeliveryAttempts) {
12407
+ this.deadLetter(m, attempts);
12408
+ continue;
12409
+ }
12410
+ if (attempts > 0) this.redeliverNotBefore.set(m.message_id, now + redeliveryDelayMs(attempts));
12411
+ back.push(m);
12412
+ }
12413
+ this.requeueFront(back);
12414
+ }
12415
+ /**
12416
+ * Phase 3 F1 dead letter: acknowledged, never redelivered, never reaching a
12417
+ * model — plus (final-wave I1(c)) one content-free notice to the channel,
12418
+ * at most once per channel per hour. Logs carry counts only.
12419
+ */
12420
+ deadLetter(message, attempts) {
12421
+ this.deadLetteredMessages++;
12422
+ writeServiceLog2("warn", "runtime_inbox.dead_letter", { attempts });
12423
+ this.ackRuntimeMessages([message.message_id]);
12424
+ this.noticeDeadLetter(message.channel_id);
12425
+ }
12426
+ noticeDeadLetter(channelId) {
12427
+ if (this.closingDown) return;
12428
+ const now = Date.now();
12429
+ const last = this.deadLetterNoticeAt.get(channelId);
12430
+ if (last !== void 0 && now - last < DEAD_LETTER_NOTICE_INTERVAL_MS) return;
12431
+ this.deadLetterNoticeAt.delete(channelId);
12432
+ this.deadLetterNoticeAt.set(channelId, now);
12433
+ if (this.deadLetterNoticeAt.size > 5e3) {
12434
+ const oldest = this.deadLetterNoticeAt.keys().next();
12435
+ if (!oldest.done) this.deadLetterNoticeAt.delete(oldest.value);
12436
+ }
12437
+ let pending;
12438
+ try {
12439
+ pending = this.send(channelId, DEAD_LETTER_NOTICE, void 0, { agentTurn: false });
12440
+ } catch (e) {
12441
+ pending = Promise.reject(e);
12442
+ }
12443
+ void pending.then(
12444
+ () => writeServiceLog2("info", "runtime_inbox.dead_letter_notice_sent"),
12445
+ (e) => writeServiceLog2("warn", "runtime_inbox.dead_letter_notice_failed", {}, e)
12446
+ );
12447
+ }
12448
+ /** Poke consumers once the earliest redelivery backoff ends. */
12449
+ armRedeliveryWake(at) {
12450
+ if (this.closingDown) return;
12451
+ if (this.redeliveryTimer !== null && this.redeliveryTimerAt <= at) return;
12452
+ if (this.redeliveryTimer !== null) clearTimeout(this.redeliveryTimer);
12453
+ this.redeliveryTimerAt = at;
12454
+ const timer = setTimeout(() => {
12455
+ this.redeliveryTimer = null;
12456
+ this.redeliveryTimerAt = Number.POSITIVE_INFINITY;
12457
+ this.wakeListeners();
12458
+ }, Math.max(0, at - Date.now()));
12459
+ timer.unref?.();
12460
+ this.redeliveryTimer = timer;
12461
+ }
12462
+ /**
12463
+ * 2026-09-25: this agent's OWN display_name for the room @mention filter
12464
+ * (`roomDisplayName`). Never the channel's name: a channel called
12465
+ * "Hermesav, Cortina011" must not make `@hermesav` address this agent. A pure
12466
+ * field read; the roster refresh that fills it runs once per ingest cycle,
12467
+ * TTL-gated, before the filter. Undefined until any roster has listed this
12468
+ * agent, in which case the filter still matches the credential agentName and
12469
+ * the identity id.
12470
+ */
12471
+ ownRoomDisplayName() {
12472
+ return this.lastOwnDisplayName;
12473
+ }
12474
+ /** Phase 3 F3: the loop guard's view of the sender — server roster first. */
12475
+ loopSenderIsAgent(channelId, senderId, envelopeSaysAgent, relayedHumanTurn) {
12476
+ if (relayedHumanTurn) return false;
12477
+ const row = this.freshRoster(channelId)?.get(senderId);
12478
+ if (row) return row.is_agent;
12479
+ return envelopeSaysAgent;
12480
+ }
12481
+ noteAgentTurnSuppressed(agentHops) {
12482
+ this.agentLoopSuppressed++;
12483
+ writeServiceLog2("info", "inbound.agent_turn_suppressed", { agent_hops: agentHops });
12484
+ }
12485
+ /**
12486
+ * Phase 3 F1: whether `channelId`'s buffered work must be HELD because the
12487
+ * agent is paused there. Held work stays durably pending (never acked, no
12488
+ * delivery attempt spent) and is released by {@link recheckPause} once the
12489
+ * owner resumes the agent. Only a channel whose send was refused with
12490
+ * `agent_paused` is ever held. Holding arms a recheck at the end of the
12491
+ * PAUSE_RECHECK_MS window, so release does not depend on new traffic.
12492
+ */
12493
+ heldForPause(channelId) {
12494
+ const at = this.pausedChannels.get(channelId);
12495
+ if (at === void 0) return false;
12496
+ this.schedulePauseRecheck(channelId, at);
12497
+ return true;
12498
+ }
12499
+ schedulePauseRecheck(channelId, confirmedAt) {
12500
+ if (this.closingDown || this.pauseRecheckTimers.has(channelId)) return;
12501
+ const delay = Math.max(0, confirmedAt + PAUSE_RECHECK_MS - Date.now());
12502
+ const timer = setTimeout(() => {
12503
+ void this.recheckPause(channelId).catch((e) => {
12504
+ writeServiceLog2("warn", "pause.recheck_failed", {}, e);
12505
+ });
12506
+ }, delay);
12507
+ timer.unref?.();
12508
+ this.pauseRecheckTimers.set(channelId, timer);
12509
+ }
12510
+ /**
12511
+ * Re-read this agent's own membership row. Still paused → keep holding and
12512
+ * re-arm while work is held. Resumed — or a failed/inconclusive probe, which
12513
+ * fails OPEN so a channel is never left deaf (the worst case is one model
12514
+ * call whose reply is refused again, re-arming the pause) — releases the
12515
+ * held work and pokes consumers to drain it now.
12516
+ */
12517
+ async recheckPause(channelId) {
12518
+ const timer = this.pauseRecheckTimers.get(channelId);
12519
+ if (timer) clearTimeout(timer);
12520
+ this.pauseRecheckTimers.delete(channelId);
12521
+ if (!this.pausedChannels.has(channelId) || this.closingDown) return;
12522
+ if (await this.probeOwnPause(channelId) === true) {
12523
+ const now = Date.now();
12524
+ this.pausedChannels.set(channelId, now);
12525
+ if (this.buffer.some((m) => m.channel_id === channelId)) this.schedulePauseRecheck(channelId, now);
12526
+ return;
12527
+ }
12528
+ this.clearPause(channelId);
12529
+ }
12530
+ clearPause(channelId) {
12531
+ this.pausedChannels.delete(channelId);
12532
+ const timer = this.pauseRecheckTimers.get(channelId);
12533
+ if (timer) clearTimeout(timer);
12534
+ this.pauseRecheckTimers.delete(channelId);
12535
+ writeServiceLog2("info", "pause.released");
12536
+ this.wakeListeners();
12537
+ }
12538
+ /** Read this agent's own `paused_at` from the enclave roster; undefined = unknown. */
12539
+ async probeOwnPause(channelId) {
12540
+ try {
12541
+ const url = `${this.cfg.apiBase}/api/v1/enclaves/${encodeURIComponent(
12542
+ this.resolveEnclave(channelId)
12543
+ )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
12544
+ const res = await this.fetchImpl(url, {
12545
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` }
12546
+ });
12547
+ if (!res.ok) return void 0;
12548
+ const rows = await res.json();
12549
+ if (!Array.isArray(rows)) return void 0;
12550
+ const own = rows.find(
12551
+ (r) => r?.identity_id === this.cfg.identityId
12552
+ );
12553
+ if (!own) return void 0;
12554
+ return typeof own.paused_at === "string" && own.paused_at.length > 0;
12555
+ } catch (e) {
12556
+ writeServiceLog2("warn", "pause.probe_failed", {}, e);
12557
+ return void 0;
12558
+ }
12559
+ }
10348
12560
  recordRuntimeReply(inReplyTo, channelId, replyMessageId) {
10349
- return this.runtimeReplyReceipts.record(inReplyTo, channelId, replyMessageId);
12561
+ return this.store.recordRuntimeReplyReceipt(inReplyTo, channelId, replyMessageId);
10350
12562
  }
10351
12563
  runtimeReplyReceipt(inReplyTo) {
10352
- return this.runtimeReplyReceipts.get(inReplyTo);
12564
+ return this.store.runtimeReplyReceipt(inReplyTo);
12565
+ }
12566
+ /**
12567
+ * Phase 3 F1: retire a runtime message whose reply failed TERMINALLY
12568
+ * (agent_paused / send_forbidden): acknowledged durably, never redelivered,
12569
+ * so the model is not run again for it. Pokes consumers so a socket holding
12570
+ * it in flight frees the channel for its next message.
12571
+ */
12572
+ retireRuntimeMessage(messageId) {
12573
+ this.ackRuntimeMessages([messageId]);
12574
+ if (this.retiredRuntimeIds.size >= 5e3) {
12575
+ const oldest = this.retiredRuntimeIds.values().next();
12576
+ if (!oldest.done) this.retiredRuntimeIds.delete(oldest.value);
12577
+ }
12578
+ this.retiredRuntimeIds.add(messageId);
12579
+ this.wakeListeners();
12580
+ }
12581
+ isRuntimeMessageRetired(messageId) {
12582
+ return this.retiredRuntimeIds.has(messageId);
12583
+ }
12584
+ /** Complete the durable handoff only after the runtime explicitly acks. */
12585
+ ackRuntimeMessages(messageIds) {
12586
+ for (const messageId of messageIds) {
12587
+ this.store.ackRuntimeInbox(messageId);
12588
+ this.runtimePendingIds.delete(messageId);
12589
+ this.deliveryAttempts.delete(messageId);
12590
+ this.redeliverNotBefore.delete(messageId);
12591
+ }
12592
+ }
12593
+ async restoreRuntimeInbox() {
12594
+ const rows = await this.store.loadRuntimeInbox();
12595
+ for (const row of rows) {
12596
+ const message = JSON.parse(new TextDecoder().decode(row.payload));
12597
+ if (!message || message.message_id !== row.messageId || typeof message.channel_id !== "string") {
12598
+ throw new Error("restored runtime inbox row has an invalid message binding");
12599
+ }
12600
+ this.runtimePendingIds.add(message.message_id);
12601
+ if (row.deliveryAttempts > 0) {
12602
+ this.deliveryAttempts.set(message.message_id, row.deliveryAttempts);
12603
+ if (row.deliveryAttempts < this.maxDeliveryAttempts) {
12604
+ this.redeliverNotBefore.set(message.message_id, Date.now() + redeliveryDelayMs(row.deliveryAttempts));
12605
+ }
12606
+ }
12607
+ this.buffer.push(message);
12608
+ }
12609
+ }
12610
+ /**
12611
+ * Seal a runtime frame before the backend queue id may be acknowledged.
12612
+ * Returning false is backpressure: the caller leaves the backend row unacked
12613
+ * so it remains the durable queue rather than shedding accepted work.
12614
+ */
12615
+ async stageRuntimeMessage(message) {
12616
+ if (this.runtimePendingIds.has(message.message_id)) return true;
12617
+ if (this.runtimePendingIds.size >= this.maxBuffer) {
12618
+ writeServiceLog2("warn", "runtime_inbox.full");
12619
+ return false;
12620
+ }
12621
+ try {
12622
+ const payload = new TextEncoder().encode(JSON.stringify(message));
12623
+ await this.store.stageRuntimeInbox(message.message_id, payload, message.ts);
12624
+ this.runtimePendingIds.add(message.message_id);
12625
+ this.buffer.push(message);
12626
+ return true;
12627
+ } catch (error) {
12628
+ writeServiceLog2("error", "runtime_inbox.stage_failed", {}, error);
12629
+ return false;
12630
+ }
10353
12631
  }
10354
12632
  /**
10355
12633
  * Put messages back at the front (SSE/disconnect write-failure recovery).
@@ -10437,6 +12715,9 @@ var BridgeAgent = class _BridgeAgent {
10437
12715
  buffered: this.buffer.length,
10438
12716
  dropped: this.droppedMessages,
10439
12717
  filtered_room: this.filteredRoomMessages,
12718
+ dead_lettered: this.deadLetteredMessages,
12719
+ paused_channels: this.pausedChannels.size,
12720
+ agent_loop_suppressed: this.agentLoopSuppressed,
10440
12721
  rate_limited: this.rateLimitedSends,
10441
12722
  permanently_skipped: this.provider.permanentlySkippedCount(),
10442
12723
  quarantine_drained: this.provider.quarantineDrainedCount(),
@@ -10489,6 +12770,9 @@ var BridgeAgent = class _BridgeAgent {
10489
12770
  errors: this.provider.errorStats(),
10490
12771
  dropped: this.droppedMessages,
10491
12772
  filteredRoomMessages: this.filteredRoomMessages,
12773
+ deadLetteredMessages: this.deadLetteredMessages,
12774
+ pausedChannels: this.pausedChannels.size,
12775
+ agentLoopSuppressed: this.agentLoopSuppressed,
10492
12776
  rateLimitedSends: this.rateLimitedSends,
10493
12777
  permanentlySkipped: this.provider.permanentlySkippedCount(),
10494
12778
  quarantineDrained: this.provider.quarantineDrainedCount(),
@@ -10500,6 +12784,10 @@ var BridgeAgent = class _BridgeAgent {
10500
12784
  }
10501
12785
  async close() {
10502
12786
  this.closingDown = true;
12787
+ for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
12788
+ this.pauseRecheckTimers.clear();
12789
+ if (this.redeliveryTimer !== null) clearTimeout(this.redeliveryTimer);
12790
+ this.redeliveryTimer = null;
10503
12791
  this.pingHandle?.close();
10504
12792
  if (this.safetyTimer) clearInterval(this.safetyTimer);
10505
12793
  if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
@@ -10518,8 +12806,7 @@ function loadConfig(opts) {
10518
12806
  const raw = readFileSync6(opts.credentialPath, "utf8");
10519
12807
  try {
10520
12808
  const mode = statSync3(opts.credentialPath).mode & 511;
10521
- if (mode & 63) process.stderr.write(`[bridge] WARNING: credential file ${opts.credentialPath} permission ${mode.toString(8)} is not 0600 \u2014 tighten it (chmod 600).
10522
- `);
12809
+ if (mode & 63) writeServiceLog2("warn", "config.credential_permissions_unsafe");
10523
12810
  } catch {
10524
12811
  }
10525
12812
  let json2;
@@ -10675,6 +12962,9 @@ var BRIDGE_ERRORS = {
10675
12962
  body_too_large: { http: 400, message: "request body exceeds the size limit" },
10676
12963
  channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
10677
12964
  rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
12965
+ // Phase 3 F1: TERMINAL send outcomes — do not retry the turn.
12966
+ agent_paused: { http: 403, message: "agent is paused in this channel; not sent" },
12967
+ send_forbidden: { http: 403, message: "send refused (credential revoked or not a member); not sent" },
10678
12968
  unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
10679
12969
  // FileSafe vault (B7): digest mismatch, DEK-wrap AEAD failure (bad/dropped
10680
12970
  // nonce, wrong KEK), owner-pin/signature/rollback rejection, or "no wrap
@@ -10697,7 +12987,7 @@ function wsErrorFrame(code, detail) {
10697
12987
  }
10698
12988
 
10699
12989
  // src/http-server.ts
10700
- function writeSse(res, msgs) {
12990
+ function writeSse(res, msgs, onWriteError) {
10701
12991
  for (let i = 0; i < msgs.length; i++) {
10702
12992
  if (res.destroyed || res.writableEnded) return msgs.slice(i);
10703
12993
  try {
@@ -10705,12 +12995,24 @@ function writeSse(res, msgs) {
10705
12995
  data: ${JSON.stringify(msgs[i])}
10706
12996
 
10707
12997
  `);
10708
- } catch {
12998
+ } catch (err) {
12999
+ try {
13000
+ onWriteError?.(err);
13001
+ } catch {
13002
+ }
10709
13003
  return msgs.slice(i);
10710
13004
  }
10711
13005
  }
10712
13006
  return [];
10713
13007
  }
13008
+ function sseWriteFailureReporter() {
13009
+ let warned = false;
13010
+ return (error) => {
13011
+ if (warned) return;
13012
+ warned = true;
13013
+ writeServiceLog2("warn", "sse.write_failed", {}, error);
13014
+ };
13015
+ }
10714
13016
  function capabilities(opts) {
10715
13017
  return {
10716
13018
  api_version: "1",
@@ -10737,6 +13039,8 @@ var sendSchema = z3.object({
10737
13039
  text: z3.string().optional(),
10738
13040
  idempotency_key: z3.string().optional(),
10739
13041
  in_reply_to: z3.string().min(1).max(200).optional(),
13042
+ /** Final-wave I2: a notice (e.g. a daily-limit reply) is not an agent turn. */
13043
+ notice: z3.boolean().optional(),
10740
13044
  a2a: a2aSchema.optional()
10741
13045
  }).refine((d) => typeof d.text === "string" || d.a2a !== void 0, {
10742
13046
  message: "send requires either text or a2a"
@@ -10862,7 +13166,16 @@ function createHttpServer(agent, opts) {
10862
13166
  if (method === "POST" && path === "/v1/send") {
10863
13167
  const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
10864
13168
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
10865
- const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a) : await agent.send(parsed.data.channel_id, parsed.data.text);
13169
+ let r;
13170
+ const turn = { agentTurn: isAgentTurn(agent, parsed.data) };
13171
+ try {
13172
+ r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a, void 0, turn) : await agent.send(parsed.data.channel_id, parsed.data.text, void 0, turn);
13173
+ } catch (e) {
13174
+ if (parsed.data.in_reply_to && (e instanceof AgentPausedSendError || e instanceof SendForbiddenError)) {
13175
+ agent.retireRuntimeMessage(parsed.data.in_reply_to);
13176
+ }
13177
+ throw e;
13178
+ }
10866
13179
  if (parsed.data.in_reply_to) {
10867
13180
  agent.recordRuntimeReply(parsed.data.in_reply_to, parsed.data.channel_id, r.message_id);
10868
13181
  }
@@ -10938,8 +13251,12 @@ function createHttpServer(agent, opts) {
10938
13251
  connection: "keep-alive"
10939
13252
  });
10940
13253
  res.write(":connected\n\n");
13254
+ const onWriteError = sseWriteFailureReporter();
10941
13255
  const drainToClient = () => {
10942
- const left2 = writeSse(res, agent.takeBuffered(channel));
13256
+ const batch = agent.takeBuffered(channel);
13257
+ const left2 = writeSse(res, batch, onWriteError);
13258
+ const leftIds = new Set(left2.map((message) => message.message_id));
13259
+ agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
10943
13260
  if (left2.length) agent.requeueFront(left2);
10944
13261
  };
10945
13262
  const unsubscribe = agent.subscribe(drainToClient);
@@ -10967,11 +13284,12 @@ function createHttpServer(agent, opts) {
10967
13284
  }
10968
13285
  if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
10969
13286
  if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
13287
+ if (e instanceof AgentPausedSendError) return jsonError(res, "agent_paused");
13288
+ if (e instanceof SendForbiddenError) return jsonError(res, "send_forbidden");
10970
13289
  if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
10971
13290
  if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
10972
13291
  if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
10973
- process.stderr.write(`[bridge] request error: ${e?.message ?? e}
10974
- `);
13292
+ writeServiceLog2("error", "http.request_failed", {}, e);
10975
13293
  return jsonError(res, "internal");
10976
13294
  }
10977
13295
  };
@@ -11036,21 +13354,35 @@ function attachAgentWebSocket(server, agent, opts) {
11036
13354
  });
11037
13355
  const inflight = /* @__PURE__ */ new Map();
11038
13356
  const flush = () => {
11039
- if (sock.readyState !== WebSocket.OPEN) return;
11040
- const batch = agent.takeBuffered();
11041
- for (let i = 0; i < batch.length; i++) {
11042
- if (sock.readyState !== WebSocket.OPEN) {
11043
- agent.requeueFront(batch.slice(i));
11044
- return;
13357
+ for (; ; ) {
13358
+ if (sock.readyState !== WebSocket.OPEN) return;
13359
+ const busy = /* @__PURE__ */ new Set();
13360
+ for (const [id, m] of inflight) {
13361
+ if (agent.isRuntimeMessageRetired(id)) inflight.delete(id);
13362
+ else busy.add(m.channel_id);
11045
13363
  }
11046
- const frame = { type: "message", ...batch[i] };
11047
- try {
11048
- sock.send(JSON.stringify(frame));
11049
- inflight.set(batch[i].message_id, batch[i]);
11050
- } catch {
11051
- agent.requeueFront(batch.slice(i));
11052
- return;
13364
+ const batch = agent.takeDeliverable(busy);
13365
+ if (batch.length === 0) return;
13366
+ let retired = false;
13367
+ for (let i = 0; i < batch.length; i++) {
13368
+ if (sock.readyState !== WebSocket.OPEN) {
13369
+ agent.requeueFront(batch.slice(i));
13370
+ return;
13371
+ }
13372
+ if (!agent.admitRuntimeDelivery(batch[i])) {
13373
+ retired = true;
13374
+ continue;
13375
+ }
13376
+ const frame = { type: "message", ...batch[i] };
13377
+ try {
13378
+ sock.send(JSON.stringify(frame));
13379
+ inflight.set(batch[i].message_id, batch[i]);
13380
+ } catch {
13381
+ agent.requeueFront(batch.slice(i));
13382
+ return;
13383
+ }
11053
13384
  }
13385
+ if (!retired) return;
11054
13386
  }
11055
13387
  };
11056
13388
  const unsubscribe = agent.subscribe(flush);
@@ -11061,7 +13393,9 @@ function attachAgentWebSocket(server, agent, opts) {
11061
13393
  agent.noteSessionClose();
11062
13394
  unsubscribe();
11063
13395
  if (inflight.size) {
11064
- agent.requeueFront([...inflight.values()]);
13396
+ agent.requeueFailedRuntimeDeliveries(
13397
+ [...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
13398
+ );
11065
13399
  inflight.clear();
11066
13400
  agent.wakeListeners();
11067
13401
  }
@@ -11071,7 +13405,7 @@ function attachAgentWebSocket(server, agent, opts) {
11071
13405
  sock.on("close", cleanup);
11072
13406
  sock.on("error", cleanup);
11073
13407
  sock.on("message", (data) => {
11074
- void handleInbound(sock, agent, data, inflight);
13408
+ void handleInbound(sock, agent, data, inflight, flush);
11075
13409
  });
11076
13410
  flush();
11077
13411
  });
@@ -11095,7 +13429,14 @@ function sendJson(sock, body) {
11095
13429
  } catch {
11096
13430
  }
11097
13431
  }
11098
- async function handleInbound(sock, agent, data, inflight) {
13432
+ function sendErrorCode(e) {
13433
+ if (e instanceof ChannelPendingError) return "channel_pending";
13434
+ if (e instanceof RateLimitedError) return "rate_limited";
13435
+ if (e instanceof AgentPausedSendError) return "agent_paused";
13436
+ if (e instanceof SendForbiddenError) return "send_forbidden";
13437
+ return "invalid_request";
13438
+ }
13439
+ async function handleInbound(sock, agent, data, inflight, flush) {
11099
13440
  let msg;
11100
13441
  try {
11101
13442
  msg = JSON.parse(data.toString("utf8"));
@@ -11114,8 +13455,40 @@ async function handleInbound(sock, agent, data, inflight) {
11114
13455
  return;
11115
13456
  }
11116
13457
  inflight.delete(m.message_id);
13458
+ agent.ackRuntimeMessages([m.message_id]);
13459
+ flush();
13460
+ return;
13461
+ }
13462
+ if (m.type === "hello") {
13463
+ sendJson(sock, { type: "hello", features: ["nack"] });
13464
+ return;
13465
+ }
13466
+ if (m.type === "nack") {
13467
+ if (typeof m.message_id !== "string" || !m.message_id) {
13468
+ sendJson(sock, wsErrorFrame("invalid_request"));
13469
+ return;
13470
+ }
13471
+ const failed = inflight.get(m.message_id);
13472
+ if (!failed) return;
13473
+ inflight.delete(m.message_id);
13474
+ if (agent.isRuntimeMessageRetired(m.message_id)) {
13475
+ flush();
13476
+ return;
13477
+ }
13478
+ writeServiceLog2("warn", "runtime_inbox.nack", { reply_failed: m.reason === "reply_failed" });
13479
+ agent.requeueFailedRuntimeDeliveries([failed]);
13480
+ flush();
11117
13481
  return;
11118
13482
  }
13483
+ const replyFailed = (e, inReplyTo) => {
13484
+ const code = sendErrorCode(e);
13485
+ if ((code === "agent_paused" || code === "send_forbidden") && typeof inReplyTo === "string") {
13486
+ inflight.delete(inReplyTo);
13487
+ agent.ackRuntimeMessages([inReplyTo]);
13488
+ }
13489
+ sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
13490
+ if (code === "agent_paused" || code === "send_forbidden") flush();
13491
+ };
11119
13492
  if (m.type === "send") {
11120
13493
  if (typeof m.channel_id !== "string" || !m.channel_id) {
11121
13494
  sendJson(sock, wsErrorFrame("invalid_request"));
@@ -11132,19 +13505,18 @@ async function handleInbound(sock, agent, data, inflight) {
11132
13505
  return;
11133
13506
  }
11134
13507
  try {
11135
- const r = await agent.sendA2A(m.channel_id, { a2a_version: a.a2a_version, kind: a.kind, object: a.object });
13508
+ const r = await agent.sendA2A(
13509
+ m.channel_id,
13510
+ { a2a_version: a.a2a_version, kind: a.kind, object: a.object },
13511
+ void 0,
13512
+ { agentTurn: isAgentTurn(agent, m) }
13513
+ );
11136
13514
  if (typeof m.in_reply_to === "string") {
11137
13515
  agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
11138
13516
  }
11139
- sendJson(sock, { type: "sent", message_id: r.message_id });
13517
+ sendJson(sock, { type: "sent", message_id: r.message_id, in_reply_to: m.in_reply_to });
11140
13518
  } catch (e) {
11141
- if (e instanceof ChannelPendingError) {
11142
- sendJson(sock, wsErrorFrame("channel_pending"));
11143
- } else if (e instanceof RateLimitedError) {
11144
- sendJson(sock, wsErrorFrame("rate_limited"));
11145
- } else {
11146
- sendJson(sock, wsErrorFrame("invalid_request"));
11147
- }
13519
+ replyFailed(e, m.in_reply_to);
11148
13520
  }
11149
13521
  return;
11150
13522
  }
@@ -11153,19 +13525,13 @@ async function handleInbound(sock, agent, data, inflight) {
11153
13525
  return;
11154
13526
  }
11155
13527
  try {
11156
- const r = await agent.send(m.channel_id, m.text);
13528
+ const r = await agent.send(m.channel_id, m.text, void 0, { agentTurn: isAgentTurn(agent, m) });
11157
13529
  if (typeof m.in_reply_to === "string") {
11158
13530
  agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
11159
13531
  }
11160
- sendJson(sock, { type: "sent", message_id: r.message_id });
13532
+ sendJson(sock, { type: "sent", message_id: r.message_id, in_reply_to: m.in_reply_to });
11161
13533
  } catch (e) {
11162
- if (e instanceof ChannelPendingError) {
11163
- sendJson(sock, wsErrorFrame("channel_pending"));
11164
- } else if (e instanceof RateLimitedError) {
11165
- sendJson(sock, wsErrorFrame("rate_limited"));
11166
- } else {
11167
- sendJson(sock, wsErrorFrame("invalid_request"));
11168
- }
13534
+ replyFailed(e, m.in_reply_to);
11169
13535
  }
11170
13536
  return;
11171
13537
  }