@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/bin.js CHANGED
@@ -180,14 +180,53 @@ function systemPrompt(p, settings) {
180
180
  );
181
181
  }
182
182
 
183
+ // src/service-log.ts
184
+ var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
185
+ var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
186
+ function errorClass(error) {
187
+ if (error === null) return "NullThrown";
188
+ if (typeof error !== "object" && typeof error !== "function") {
189
+ const primitive = {
190
+ string: "StringThrown",
191
+ number: "NumberThrown",
192
+ boolean: "BooleanThrown",
193
+ bigint: "BigIntThrown",
194
+ symbol: "SymbolThrown",
195
+ undefined: "UndefinedThrown"
196
+ };
197
+ return primitive[typeof error] ?? "UnknownThrown";
198
+ }
199
+ try {
200
+ const name = Object.getPrototypeOf(error)?.constructor?.name;
201
+ return typeof name === "string" && CLASS.test(name) ? name : "Error";
202
+ } catch {
203
+ return "Error";
204
+ }
205
+ }
206
+ function formatServiceLog(level, event, fields = {}, error) {
207
+ const safeEvent = TOKEN.test(event) ? event : "invalid_event";
208
+ const parts = [`[bridge] ${level} ${safeEvent}`];
209
+ for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
210
+ if (!TOKEN.test(key)) continue;
211
+ if (typeof value === "boolean") parts.push(`${key}=${value}`);
212
+ else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
213
+ }
214
+ if (error !== void 0) parts.push(`error_class=${errorClass(error)}`);
215
+ return parts.join(" ");
216
+ }
217
+ function writeServiceLog(level, event, fields, error) {
218
+ const line = formatServiceLog(level, event, fields, error);
219
+ process.stderr.write(`${line}
220
+ `);
221
+ }
222
+
183
223
  // src/config.ts
184
224
  var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
185
225
  function loadConfig(opts) {
186
226
  const raw = readFileSync(opts.credentialPath, "utf8");
187
227
  try {
188
228
  const mode = statSync(opts.credentialPath).mode & 511;
189
- if (mode & 63) process.stderr.write(`[bridge] WARNING: credential file ${opts.credentialPath} permission ${mode.toString(8)} is not 0600 \u2014 tighten it (chmod 600).
190
- `);
229
+ if (mode & 63) writeServiceLog("warn", "config.credential_permissions_unsafe");
191
230
  } catch {
192
231
  }
193
232
  let json2;
@@ -286,6 +325,55 @@ function validateBind(bind, tls) {
286
325
  }
287
326
  }
288
327
 
328
+ // ../connect-sdk-ts/dist/service-log.js
329
+ var TOKEN2 = /^[a-z][a-z0-9_.-]{0,63}$/;
330
+ var CLASS2 = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
331
+ function errorClass2(error) {
332
+ if (error === null)
333
+ return "NullThrown";
334
+ if (typeof error !== "object" && typeof error !== "function") {
335
+ const primitive = {
336
+ string: "StringThrown",
337
+ number: "NumberThrown",
338
+ boolean: "BooleanThrown",
339
+ bigint: "BigIntThrown",
340
+ symbol: "SymbolThrown",
341
+ undefined: "UndefinedThrown"
342
+ };
343
+ return primitive[typeof error] ?? "UnknownThrown";
344
+ }
345
+ try {
346
+ const name = Object.getPrototypeOf(error)?.constructor?.name;
347
+ return typeof name === "string" && CLASS2.test(name) ? name : "Error";
348
+ } catch {
349
+ return "Error";
350
+ }
351
+ }
352
+ function formatServiceLog2(level, event, fields = {}, error) {
353
+ const safeEvent = TOKEN2.test(event) ? event : "invalid_event";
354
+ const parts = [`[sdk] ${level} ${safeEvent}`];
355
+ for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
356
+ if (!TOKEN2.test(key))
357
+ continue;
358
+ if (typeof value === "boolean")
359
+ parts.push(`${key}=${value}`);
360
+ else if (typeof value === "number" && Number.isFinite(value))
361
+ parts.push(`${key}=${value}`);
362
+ }
363
+ if (error !== void 0)
364
+ parts.push(`error_class=${errorClass2(error)}`);
365
+ return parts.join(" ");
366
+ }
367
+ function writeServiceLog2(level, event, fields, error) {
368
+ const line = formatServiceLog2(level, event, fields, error);
369
+ if (level === "error")
370
+ console.error(line);
371
+ else if (level === "warn")
372
+ console.warn(line);
373
+ else
374
+ console.log(line);
375
+ }
376
+
289
377
  // ../connect-core/dist/cadence.js
290
378
  var SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
291
379
  var SELF_HEAL_SWEEP_JITTER_MS = 3e4;
@@ -1468,17 +1556,29 @@ var enc2 = new TextEncoder();
1468
1556
  var dec2 = new TextDecoder();
1469
1557
 
1470
1558
  // ../connect-clients-core/dist/filesafe/owner-pin.js
1559
+ function rejectionReason(e) {
1560
+ const msg = e instanceof Error ? e.message : "";
1561
+ if (msg.includes("does not chain"))
1562
+ return "not_chaining";
1563
+ if (msg.includes("bad handoff signature"))
1564
+ return "bad_signature";
1565
+ return "other";
1566
+ }
1471
1567
  async function resolvePinnedOwner(args2) {
1472
1568
  let pin = await args2.store.load(args2.enclaveId);
1473
1569
  if (pin === null) {
1474
1570
  pin = { g0Cred: args2.observedG0Cred, chain: [] };
1475
1571
  }
1476
1572
  let chain = pin.chain;
1477
- for (const link of args2.relayedLinks) {
1573
+ for (let i = 0; i < args2.relayedLinks.length; i++) {
1478
1574
  const tail = currentOwnerCred(pin.g0Cred, chain);
1479
1575
  try {
1480
- chain = await extendOwnerChain(chain, link, tail);
1481
- } catch {
1576
+ chain = await extendOwnerChain(chain, args2.relayedLinks[i], tail);
1577
+ } catch (e) {
1578
+ try {
1579
+ args2.onRejectedLink?.({ index: i, reason: rejectionReason(e) });
1580
+ } catch {
1581
+ }
1482
1582
  }
1483
1583
  }
1484
1584
  const next = { g0Cred: pin.g0Cred, chain };
@@ -1499,7 +1599,7 @@ var SoftwareKeyCustody = class _SoftwareKeyCustody {
1499
1599
  this.priv = priv;
1500
1600
  this.pub = pub;
1501
1601
  if (!quiet) {
1502
- console.warn("[AgentVault] using a software key (dev/CI only) -- envelopes are not attested. Use kms-hosted or byok custody in production.");
1602
+ writeServiceLog2("warn", "custody.software_key_unattested");
1503
1603
  }
1504
1604
  }
1505
1605
  static generate(algo) {
@@ -1609,6 +1709,56 @@ var MalformedGroupRosterError = class extends ConnectSdkError {
1609
1709
  this.receivedType = receivedType;
1610
1710
  }
1611
1711
  };
1712
+ var MlsBackendHttpError = class extends ConnectSdkError {
1713
+ status;
1714
+ code;
1715
+ reason;
1716
+ /**
1717
+ * `code` (C2 T0): the backend's error code parsed from the response body
1718
+ * (`{"detail": "x"}`, `{"detail": {"error": "x"}}` or `{"error": "x"}`),
1719
+ * when there is one. The distribute outcome classifier (C2 T3) reads it
1720
+ * against `DISTRIBUTE_DEFINITE_CODES`; absent means "no code", which is
1721
+ * ambiguous by construction. It never enters the message, so the message
1722
+ * stays byte-identical to the pre-C2 one.
1723
+ *
1724
+ * `reason` (C2 T3): the 409 `EPOCH_CONFLICT` body's `reason`
1725
+ * (`stale_epoch`, `epoch_not_monotonic`, `superseded`) when present. The
1726
+ * add and remove sites read it to route a `superseded` refusal away from
1727
+ * the drain-and-retry path (LOW-2). Never in the message either.
1728
+ */
1729
+ constructor(path, status, code, reason) {
1730
+ super(`${path} failed: ${status}`);
1731
+ this.status = status;
1732
+ this.code = code;
1733
+ this.reason = reason;
1734
+ }
1735
+ };
1736
+ var PendingCommitUnresolvedError = class extends ConnectSdkError {
1737
+ channelId;
1738
+ constructor(channelId) {
1739
+ super("mls.pending_commit.unresolved: a previous commit on this conversation is still being confirmed; retry later");
1740
+ this.channelId = channelId;
1741
+ this.name = "PendingCommitUnresolvedError";
1742
+ }
1743
+ };
1744
+ var GroupSupersededError = class extends ConnectSdkError {
1745
+ channelId;
1746
+ groupId;
1747
+ constructor(channelId, groupId) {
1748
+ super("mls.group_superseded: the conversation's group was replaced by an owner reset; the change was not applied");
1749
+ this.channelId = channelId;
1750
+ this.groupId = groupId;
1751
+ this.name = "GroupSupersededError";
1752
+ }
1753
+ };
1754
+ var AttestedOrphanRefusedError = class extends ConnectSdkError {
1755
+ channelId;
1756
+ constructor(channelId) {
1757
+ super("mls.attested_orphan_refused: the conversation's reset did not complete; it needs the owner to reset again");
1758
+ this.channelId = channelId;
1759
+ this.name = "AttestedOrphanRefusedError";
1760
+ }
1761
+ };
1612
1762
 
1613
1763
  // ../connect-sdk-ts/dist/envelope-builder.js
1614
1764
  async function buildOutboundEnvelope(p) {
@@ -1735,6 +1885,94 @@ function leafIndicesForKeyPackage(mgr, kp) {
1735
1885
  // ../connect-sdk-ts/dist/groupkey/mls.js
1736
1886
  import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1737
1887
 
1888
+ // ../connect-sdk-ts/dist/mls/store.js
1889
+ function assertPendingCommitShape(rec) {
1890
+ if (rec.commitEpoch !== rec.request.commit_epoch) {
1891
+ throw new Error("pending commit: commitEpoch does not equal request.commit_epoch");
1892
+ }
1893
+ if (rec.op === "bootstrap" && rec.prior !== null) {
1894
+ throw new Error("pending commit: a bootstrap record must have prior === null");
1895
+ }
1896
+ if (rec.op === "add" || rec.op === "remove") {
1897
+ if (rec.prior === null) {
1898
+ throw new Error(`pending commit: an ${rec.op} record must have a prior MAIN`);
1899
+ }
1900
+ if (rec.groupId !== rec.prior.groupId) {
1901
+ throw new Error(`pending commit: an ${rec.op} record's groupId must equal prior.groupId`);
1902
+ }
1903
+ }
1904
+ }
1905
+ function isPendingReservation(rec) {
1906
+ return rec.request.commit === void 0;
1907
+ }
1908
+ function pendingReservation(p) {
1909
+ return {
1910
+ groupId: p.groupId,
1911
+ op: "supersede",
1912
+ request: { device_id: p.deviceId, commit_epoch: 0 },
1913
+ state: p.placeholder,
1914
+ commitEpoch: 0,
1915
+ prior: p.prior,
1916
+ createdAt: p.createdAt
1917
+ };
1918
+ }
1919
+ function pendingCommitKey(rec) {
1920
+ return { groupId: rec.groupId, commitEpoch: rec.commitEpoch, commit: rec.request.commit ?? "" };
1921
+ }
1922
+ function matchesPendingCommitKey(rec, k) {
1923
+ return rec.groupId === k.groupId && rec.commitEpoch === k.commitEpoch && (rec.request.commit ?? "") === k.commit;
1924
+ }
1925
+ function sameMainState(a, b) {
1926
+ if (!a || !b)
1927
+ return !a && !b;
1928
+ return a.groupState === b.groupState && a.ciphersuite === b.ciphersuite;
1929
+ }
1930
+
1931
+ // ../connect-sdk-ts/dist/mls/distribute-outcome.js
1932
+ var CODES = [
1933
+ { status: 409, code: "EPOCH_CONFLICT" },
1934
+ { status: 409, code: "welcome_target_not_approved_member" },
1935
+ { status: 403, code: "not_group_committer" },
1936
+ { status: 403, code: "not_authorized_to_remove" },
1937
+ { status: 403, code: "removal_would_empty_group" },
1938
+ { status: 403, code: "removal_would_drop_last_committer" },
1939
+ { status: 422, code: "device_identity_mismatch" },
1940
+ { status: 422, code: "no_target_devices" },
1941
+ { status: 400, code: "invalid_commit_hex" },
1942
+ { status: 400, code: "expected_epoch_required" },
1943
+ { status: 400, code: "removed_identity_id required" },
1944
+ { status: 400, code: "commit required" },
1945
+ { status: 404, code: "not_connect_group" },
1946
+ { status: 404, code: "group_missing" },
1947
+ { status: 403, code: "agent_paused" },
1948
+ { status: 403, code: "forbidden" },
1949
+ { status: 400, code: "mls.stale_epoch" }
1950
+ ];
1951
+ var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
1952
+ var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
1953
+ function parseErrorCode(body) {
1954
+ if (body === null || typeof body !== "object" || Array.isArray(body))
1955
+ return void 0;
1956
+ const b = body;
1957
+ if (typeof b.detail === "string")
1958
+ return b.detail;
1959
+ if (b.detail !== null && typeof b.detail === "object" && !Array.isArray(b.detail)) {
1960
+ const inner = b.detail.error;
1961
+ if (typeof inner === "string")
1962
+ return inner;
1963
+ }
1964
+ if (typeof b.error === "string")
1965
+ return b.error;
1966
+ return void 0;
1967
+ }
1968
+ function classifyDistributeError(err) {
1969
+ if (!(err instanceof MlsBackendHttpError))
1970
+ return "ambiguous";
1971
+ if (typeof err.code !== "string")
1972
+ return "ambiguous";
1973
+ return DEFINITE_KEYS.has(`${err.status} ${err.code}`) ? "definite" : "ambiguous";
1974
+ }
1975
+
1738
1976
  // ../connect-sdk-ts/dist/groupkey/operation-queue.js
1739
1977
  var MlsOperationQueue = class {
1740
1978
  chains = /* @__PURE__ */ new Map();
@@ -2557,9 +2795,52 @@ function getSignaturePublicKeyFromLeafIndex(ratchetTree, leafIndex) {
2557
2795
  }
2558
2796
 
2559
2797
  // ../connect-sdk-ts/dist/groupkey/mls.js
2798
+ var PENDING_INLINE_RESENDS = 2;
2799
+ var DEFAULT_PENDING_RESEND_DELAYS_MS = [250, 1e3];
2800
+ var PENDING_UNRESOLVED_LOG_INTERVAL_MS = 6e4;
2801
+ var PENDING_AUTO_RESOLVE_INTERVAL_MS = 6e4;
2802
+ var PENDING_RECEIVE_FAIL_LOG_INTERVAL_MS = 6e4;
2803
+ var PENDING_LEFT_ESCALATION_PULLS = 20;
2804
+ var PENDING_LEFT_ESCALATION_MS = 10 * 6e4;
2805
+ var PendingRowEscalatedError = class extends Error {
2806
+ constructor() {
2807
+ super("mls.pending_commit.left_row_escalated");
2808
+ this.name = "PendingRowEscalatedError";
2809
+ }
2810
+ };
2811
+ function sleepMs(ms) {
2812
+ return ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
2813
+ }
2814
+ function tagOf(mgr) {
2815
+ return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
2816
+ }
2817
+ function tagOfState(state) {
2818
+ const m = new MLSGroupManager2(state.ciphersuite);
2819
+ m.importState(state);
2820
+ return tagOf(m);
2821
+ }
2822
+ function sameMainTag(a, b) {
2823
+ if (a === null || b === null)
2824
+ return a === b;
2825
+ return a.groupId === b.groupId && a.epoch === b.epoch;
2826
+ }
2827
+ var RESERVATION_PLACEHOLDER_STATE = {
2828
+ groupState: "",
2829
+ ciphersuite: "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519"
2830
+ };
2831
+ function isDefiniteReinitRefusal(e) {
2832
+ return e instanceof MlsBackendHttpError && e.status >= 400 && e.status < 500;
2833
+ }
2834
+ function isSupersededRefusal(e) {
2835
+ return e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT" && e.reason === "superseded";
2836
+ }
2837
+ function isEpochConflict(e) {
2838
+ return e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT" && !isSupersededRefusal(e);
2839
+ }
2560
2840
  var LABEL = "agentvault-connect-v1";
2561
2841
  var DEFAULT_STUCK_THRESHOLD = 2;
2562
2842
  var DEFAULT_QUARANTINE_THRESHOLD = 8;
2843
+ var DELIVERY_PULL_PAGE = 100;
2563
2844
  var BootstrapRaceLostError = class extends Error {
2564
2845
  constructor() {
2565
2846
  super("bootstrap race lost: another device created the group; join instead");
@@ -2584,6 +2865,16 @@ var AmbiguousOwnLeafError = class extends Error {
2584
2865
  this.name = "AmbiguousOwnLeafError";
2585
2866
  }
2586
2867
  };
2868
+ var OwnLeafRemoveRefusedError = class extends Error {
2869
+ op;
2870
+ reason;
2871
+ constructor(op, reason) {
2872
+ super(`${op}: refusing a Remove that would or could evict this committer's own leaf (${reason})`);
2873
+ this.op = op;
2874
+ this.reason = reason;
2875
+ this.name = "OwnLeafRemoveRefusedError";
2876
+ }
2877
+ };
2587
2878
  var HealAddAfterRemoveError = class extends Error {
2588
2879
  constructor(cause) {
2589
2880
  super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
@@ -2715,8 +3006,67 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2715
3006
  * past-epoch {@link permanentlySkipped} drain. Read via
2716
3007
  * {@link retiredDrainedCount}. */
2717
3008
  retiredDrained = 0;
3009
+ /**
3010
+ * C1 (P0 containment): CUSTODY of every application row this provider has
3011
+ * decrypted, keyed by queue_id, until the server shows the row gone.
3012
+ *
3013
+ * Why: a row's message key comes from the LIVE exporter secret only, so once
3014
+ * any drain applies commit N->N+1 the first successful decrypt of an epoch-N
3015
+ * row is the ONLY one there will ever be. Before C1, internal drains
3016
+ * (reconcileForSend, joinAndReconcile, the committer 409 drain) decrypted such
3017
+ * rows, discarded the plaintext and applied the commit; the redelivered row
3018
+ * then failed AEAD, was classified permanentlySkipped and acked: silent loss.
3019
+ * A CONCURRENT receiveOnce that pulled the row before another drain applied
3020
+ * the commit lost it the same way (C1 review, Important #1).
3021
+ *
3022
+ * Rule: an application row whose queue_id is in custody is NEVER decrypted or
3023
+ * classified again, by any call. {@link receiveOnce} returns the stored entry
3024
+ * for it, un-acked, in the row's seq position, on every pull that contains the
3025
+ * row, so the caller's ack stays the only ack. An internal drain's discard is
3026
+ * therefore harmless (the next receiveOnce serves the entry), and a caller
3027
+ * that drops an entry without acking is offered it again on the next pull,
3028
+ * exactly like a row that still decrypts. Eviction happens only when an
3029
+ * own-device pull SHORTER than {@link DELIVERY_PULL_PAGE} does not contain the
3030
+ * row (acked, expired, dead-lettered or fenced; it cannot come back).
3031
+ *
3032
+ * Limit: memory only. A process restart empties custody, and an un-acked row
3033
+ * whose epoch has moved on then fails AEAD and is acked as permanentlySkipped
3034
+ * (C1 report: durable-sink follow-up).
3035
+ */
3036
+ custody = /* @__PURE__ */ new Map();
3037
+ /*
3038
+ * C2 DEVIATION FROM SPEC §4.2's IN-MEMORY PENDING SET, deliberate: there is
3039
+ * none. Every C2 decision (the send fence, the receive resolver, the
3040
+ * lock-entry resolve, promotion) reads the CHANNEL's DURABLE record, one
3041
+ * store read each. A per-process set can be wrong in BOTH directions across
3042
+ * tabs and processes (MEDIUM-1): it misses a record another tab wrote, and
3043
+ * it keeps fencing a channel another tab already resolved. The durable read
3044
+ * is right in both, and the plan already accepted one store read per send.
3045
+ * Nothing reads `listPendingCommits()` either, so one unreadable record
3046
+ * (T2's SQLite `MlsStoreSealError` refuses the whole list) cannot fence
3047
+ * every channel: it fences only its own, whose per-channel read throws.
3048
+ */
3049
+ /** Memo for the provider-start re-run of owed follow-ups (MEDIUM-3). */
3050
+ owedRerun = null;
3051
+ /** Last `mls.pending_commit.unresolved` log per channel (rate limit). */
3052
+ unresolvedLoggedAt = /* @__PURE__ */ new Map();
3053
+ /** Channels whose legacy (pre-C2) staging slot was already discarded by
3054
+ * this process (spec §4.5 "Legacy staging residue"). */
3055
+ legacyStagingChecked = /* @__PURE__ */ new Set();
3056
+ /** C2 T3c (H1): last automatic resolve per channel (rate limit). */
3057
+ autoResolveAt = /* @__PURE__ */ new Map();
3058
+ /** C2 T3c (H1): automatic resolves in flight, awaited by drainInFlight. */
3059
+ backgroundResolves = /* @__PURE__ */ new Set();
3060
+ /** C2 T3c (H1): per channel, the run of consecutive pulls that left rows,
3061
+ * and when it started. Cleared by a pull that leaves none. */
3062
+ leftStreak = /* @__PURE__ */ new Map();
3063
+ /** C2 T3c (H2): last receive_resolve_failed log per channel. */
3064
+ receiveFailLoggedAt = /* @__PURE__ */ new Map();
2718
3065
  constructor(o) {
2719
3066
  this.o = o;
3067
+ if (typeof o.onPendingCommitResolved !== "function") {
3068
+ throw new TypeError("MlsGroupKeyProvider: onPendingCommitResolved is required and must be a function");
3069
+ }
2720
3070
  this.suite = o.suite ?? "xchacha20poly1305";
2721
3071
  this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
2722
3072
  this.quarantineThreshold = Math.max(1, o.quarantineThreshold ?? DEFAULT_QUARANTINE_THRESHOLD);
@@ -2847,7 +3197,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
2847
3197
  timer.unref?.();
2848
3198
  });
2849
3199
  const drain = (async () => {
2850
- while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0) {
3200
+ while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0 || this.backgroundResolves.size > 0) {
3201
+ await Promise.allSettled([...this.backgroundResolves]);
2851
3202
  await this.locks.idle();
2852
3203
  await this.publishLock.idle();
2853
3204
  }
@@ -3025,7 +3376,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3025
3376
  orderedDeviceIds
3026
3377
  });
3027
3378
  } else if (outcome !== "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
3028
- 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.`);
3379
+ writeServiceLog2("warn", "guest.swap_skipped_add_noop");
3029
3380
  }
3030
3381
  return outcome;
3031
3382
  }
@@ -3093,17 +3444,23 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3093
3444
  try {
3094
3445
  await this.locks.run(channelId, async () => {
3095
3446
  const mgr = await this.manager(channelId);
3096
- for (const c of commits) {
3097
- if (c.epoch <= Number(mgr.epoch))
3098
- continue;
3099
- await mgr.decrypt(hexToBytes(c.payload));
3100
- applied++;
3447
+ try {
3448
+ for (const c of commits) {
3449
+ if (c.epoch <= Number(mgr.epoch))
3450
+ continue;
3451
+ await mgr.decrypt(hexToBytes(c.payload));
3452
+ applied++;
3453
+ }
3454
+ if (applied > 0)
3455
+ await this.saveMain(channelId, mgr);
3456
+ } catch (e) {
3457
+ if (applied > 0)
3458
+ this.evictIfCached(channelId, mgr);
3459
+ throw e;
3101
3460
  }
3102
- if (applied > 0)
3103
- await this.o.store.save(channelId, mgr.exportState());
3104
3461
  });
3105
3462
  } catch (e) {
3106
- console.warn(`[mls] resyncByReplay ${channelId.slice(0, 8)}: commit failed to apply after ${applied} \u2014 state is forked, not behind:`, e?.message);
3463
+ writeServiceLog2("warn", "resync.replay_commit_failed", { applied }, e);
3107
3464
  return false;
3108
3465
  }
3109
3466
  if (applied > 0) {
@@ -3163,6 +3520,494 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3163
3520
  * delete (a re-delete is a no-op). On success the manager stays cached as
3164
3521
  * before — no eviction.
3165
3522
  */
3523
+ /**
3524
+ * Phase 3 C4: persist a manager that has ALREADY ADVANCED in memory by applying
3525
+ * a commit the SERVER can hand us again (a delivery row we have not acked, a
3526
+ * /sync replay). If the save throws, evict the cached manager before
3527
+ * rethrowing, so the next access reloads the persisted (pre-commit) state and
3528
+ * re-applies the still-queued commit.
3529
+ *
3530
+ * Why evict rather than keep the advanced cache: with the cache ahead of disk,
3531
+ * the redelivered commit no longer applies to it, the classifier sees a commit
3532
+ * at or below the live epoch and ACKS it as dead, and the disk keeps the old
3533
+ * epoch. The next restart loads that epoch while the server has moved on: a
3534
+ * silent fork (mls.evict-on-save-failure.test.ts).
3535
+ *
3536
+ * ⛔ ONLY for server-redeliverable commits. A committer's OWN commit that the
3537
+ * server already accepted cannot be re-applied from the wire (a member cannot
3538
+ * process its own commit), so evicting there throws away the only copy of the
3539
+ * true state. The committer paths keep their own handling.
3540
+ *
3541
+ * `MLSGroupManager.decrypt` assigns its new state only after processing
3542
+ * succeeds, so a decrypt that THROWS leaves the manager unchanged and needs no
3543
+ * eviction; only the advanced-but-unsaved case does.
3544
+ */
3545
+ async saveAdvancedOrEvict(channelId, mgr) {
3546
+ try {
3547
+ await this.saveMain(channelId, mgr);
3548
+ } catch (e) {
3549
+ this.evictIfCached(channelId, mgr);
3550
+ throw e;
3551
+ }
3552
+ }
3553
+ /**
3554
+ * C2 (spec §4.2, MEDIUM-1): the ONE writer of MAIN outside promotion. Every
3555
+ * MAIN save in this file goes through here (pinned by
3556
+ * `save-main-call-sites.arch.test.ts`), and here goes through the store's
3557
+ * `saveMainGuarded`, which refuses ATOMICALLY when the channel holds a
3558
+ * pending-commit record whose `prior` is not this manager's (group, epoch).
3559
+ * So while a commit's outcome is unknown, MAIN can move only within the
3560
+ * epoch the commit was built on, whether the write comes from this tab, a
3561
+ * second tab, or another process.
3562
+ *
3563
+ * On ANY throw the cached manager is evicted if it is still `mgr`, so the
3564
+ * cache never runs ahead of MAIN: a refusal (`PendingCommitUnresolvedError`)
3565
+ * and a raw store failure (SQLite `SQLITE_BUSY` after its busy timeout, an
3566
+ * IndexedDB abort) are the same fact here, "MAIN did not move". The error
3567
+ * propagates: a receive caller leaves the row un-acked (uncounted, for a
3568
+ * refusal), a committer caller fails.
3569
+ */
3570
+ async saveMain(channelId, mgr) {
3571
+ try {
3572
+ await this.o.store.saveMainGuarded(channelId, mgr.exportState(), tagOf(mgr));
3573
+ } catch (e) {
3574
+ this.evictIfCached(channelId, mgr);
3575
+ throw e;
3576
+ }
3577
+ }
3578
+ // ── C2: pending-commit records ────────────────────────────────────────────
3579
+ //
3580
+ // A commit is written to a durable record BEFORE /distribute, resolved only
3581
+ // by proof (a 2xx, a coded definite rejection, or a receive-side proof), and
3582
+ // RESENT byte-for-byte, never rebuilt. See spec §4 and the plan's Task T3.
3583
+ //
3584
+ // ⛔ IRREVERSIBILITY. Nothing below builds a commit, adds a member or removes
3585
+ // one. Recovery only resends `rec.request`, whose bytes were built, and
3586
+ // checked by the admission rule (duplicate-leaf) and the own-leaf guard
3587
+ // (C3), at the site that created the record, against the tree they apply
3588
+ // to. A resend that lands can land only on that tree: the server's
3589
+ // expected_epoch compare-and-set (one commit per (group, epoch), H4/T1a) and
3590
+ // the superseded refusal (§3.2) reject it anywhere else.
3591
+ nowMs() {
3592
+ return this.o.now?.() ?? Date.now();
3593
+ }
3594
+ /** The (group, epoch) of the DURABLE MAIN, or null when there is none. Read
3595
+ * from the store, not the cache: promotion compares against what is at
3596
+ * rest. */
3597
+ async durableMainTag(channelId) {
3598
+ const saved = await this.o.store.load(channelId);
3599
+ return saved ? tagOfState(saved) : null;
3600
+ }
3601
+ /** Start the provider-start re-run of owed follow-ups once per process,
3602
+ * detached (see {@link resumePendingCommits}). A failure is logged and the
3603
+ * memo clears, so the next call retries it. Synchronous: callers never wait
3604
+ * on the hook. */
3605
+ kickOwedRerun() {
3606
+ if (this.owedRerun)
3607
+ return;
3608
+ void this.resumePendingCommits().catch((e) => writeServiceLog2("warn", "mls.pending_commit.owed_rerun_failed", {}, e));
3609
+ }
3610
+ /**
3611
+ * C2 (MEDIUM-3): provider-start work, idempotent per process (a rejected
3612
+ * run clears its memo). Re-runs every owed follow-up (the post-commit work
3613
+ * of a commit that landed and was promoted, whose hook had not yet returned)
3614
+ * through `onPendingCommitResolved` with `via: "resolver"`, clearing each
3615
+ * slot only after its hook returns. The provider starts this itself,
3616
+ * detached, on the first send-fence check or receive pass; a host may call
3617
+ * it at startup to run the owed work before anything else. The hook is
3618
+ * at-least-once, hence required to be idempotent.
3619
+ */
3620
+ async resumePendingCommits() {
3621
+ this.owedRerun ??= (async () => {
3622
+ for (const { channelId, owed } of await this.o.store.listOwedFollowUps()) {
3623
+ await this.runFollowUpHook(channelId, owed.op, owed.followUp, "resolver", owed.createdAt);
3624
+ }
3625
+ })().catch((e) => {
3626
+ this.owedRerun = null;
3627
+ throw e;
3628
+ });
3629
+ await this.owedRerun;
3630
+ }
3631
+ /** Run the hook for a landed commit that owes a follow-up, then clear the
3632
+ * owed slot. A throwing hook KEEPS the slot (re-run at the next provider
3633
+ * start) and is logged, never propagated: the commit itself landed and
3634
+ * MAIN is promoted, so failing the op would misreport it.
3635
+ *
3636
+ * C2 T3c (M1): the clear is keyed on the slot's `createdAt`, so it removes
3637
+ * only the slot whose hook just ran. The provider-start re-run is detached
3638
+ * and holds no channel lock; a newer commit's slot written meanwhile (it
3639
+ * overwrites the channel's slot) is kept for its own hook. */
3640
+ async runFollowUpHook(channelId, op, followUp, via, slotCreatedAt) {
3641
+ try {
3642
+ await this.o.onPendingCommitResolved(channelId, { op, outcome: "landed", followUp, via });
3643
+ } catch (e) {
3644
+ writeServiceLog2("warn", "mls.pending_commit.hook_failed", {}, e);
3645
+ return;
3646
+ }
3647
+ await this.o.store.clearOwedFollowUp(channelId, slotCreatedAt);
3648
+ }
3649
+ /**
3650
+ * C2 (coordinator ruling on T2, MEDIUM-3): run and clear any follow-up still
3651
+ * OWED on this channel, and THROW if that fails. Called before a commit that
3652
+ * will itself carry a follow-up is built: every store's `commitPending`
3653
+ * overwrites the channel's owed slot, so promoting the new commit over an
3654
+ * undrained slot would silently drop the older owed work. Fail closed: a
3655
+ * hook that throws (or an unreadable slot) refuses the new commit and keeps
3656
+ * the old slot. Caller holds the channel lock.
3657
+ *
3658
+ * C2 T3c (M2): reads ONLY this channel's slot (`getOwedFollowUp`), so an
3659
+ * unreadable slot of another channel cannot refuse this channel's commit.
3660
+ * With `abandon` (the owner's explicit Reset), an UNREADABLE slot of this
3661
+ * channel is discarded (logged `mls.pending_commit.owed_abandoned`) rather
3662
+ * than blocking the owner's one way out; a hook that throws still refuses.
3663
+ */
3664
+ async drainOwedFollowUpUnlocked(channelId, abandon = false) {
3665
+ let owed;
3666
+ try {
3667
+ owed = await this.o.store.getOwedFollowUp(channelId);
3668
+ } catch (e) {
3669
+ if (!abandon)
3670
+ throw e;
3671
+ writeServiceLog2("warn", "mls.pending_commit.owed_abandoned", {}, e);
3672
+ await this.o.store.clearOwedFollowUp(channelId);
3673
+ return;
3674
+ }
3675
+ if (!owed)
3676
+ return;
3677
+ await this.o.onPendingCommitResolved(channelId, {
3678
+ op: owed.op,
3679
+ outcome: "landed",
3680
+ followUp: owed.followUp,
3681
+ via: "resolver"
3682
+ });
3683
+ await this.o.store.clearOwedFollowUp(channelId, owed.createdAt);
3684
+ }
3685
+ /** Tell the host how a record resolved (no follow-up owed). A throwing hook
3686
+ * is logged and swallowed for the same reason as above. */
3687
+ async notifyPendingResolved(channelId, ev) {
3688
+ try {
3689
+ await this.o.onPendingCommitResolved(channelId, ev);
3690
+ } catch (e) {
3691
+ writeServiceLog2("warn", "mls.pending_commit.hook_failed", {}, e);
3692
+ }
3693
+ }
3694
+ /** Persist the record BEFORE `/distribute`. A throw here propagates and the
3695
+ * commit is never sent (spec §4.1).
3696
+ *
3697
+ * C2 T3c: CREATE-ONLY, atomically in the store. This instance resolved the
3698
+ * channel's record at lock entry, but another tab or process holds its own
3699
+ * lock and may have staged a commit since. Overwriting that record would
3700
+ * orphan a commit that may land (the other instance could then never
3701
+ * promote it). So a present record refuses this commit with
3702
+ * `PendingCommitUnresolvedError` before anything is sent. */
3703
+ async stagePendingCommit(channelId, rec, replacing) {
3704
+ const opts = replacing ? { replacing } : { onlyIfAbsent: true };
3705
+ let wrote;
3706
+ try {
3707
+ wrote = await this.o.store.savePendingCommit(channelId, rec, opts);
3708
+ } catch (e) {
3709
+ if (replacing)
3710
+ await this.o.store.clearPendingCommit(channelId, replacing).catch(() => false);
3711
+ throw e;
3712
+ }
3713
+ if (!wrote)
3714
+ throw new PendingCommitUnresolvedError(channelId);
3715
+ }
3716
+ /** Send `rec.request` byte-for-byte, then up to `resends` more times while
3717
+ * the outcome stays ambiguous (spec §4.3). Never rebuilds the commit. */
3718
+ async sendPendingCommit(rec, resends) {
3719
+ const delays = this.o.pendingResendDelaysMs ?? DEFAULT_PENDING_RESEND_DELAYS_MS;
3720
+ let last;
3721
+ for (let attempt = 0; attempt <= resends; attempt++) {
3722
+ if (attempt > 0)
3723
+ await sleepMs(delays[Math.min(attempt - 1, delays.length - 1)] ?? 0);
3724
+ try {
3725
+ await this.o.backend.distribute(rec.groupId, rec.request);
3726
+ return { kind: "landed" };
3727
+ } catch (e) {
3728
+ if (classifyDistributeError(e) === "definite")
3729
+ return { kind: "definite", error: e };
3730
+ last = e;
3731
+ }
3732
+ }
3733
+ return { kind: "ambiguous", error: last };
3734
+ }
3735
+ /**
3736
+ * Apply a send outcome to the channel's record. Called under the channel lock.
3737
+ * - landed: custody of the epoch-N rows (C1b; skipped when there is no
3738
+ * prior MAIN), then conditional promotion.
3739
+ * - definite: the commit did not land. Clear the record, evict the cache,
3740
+ * tell the hook `not_landed`.
3741
+ * - ambiguous: KEEP the record, evict the cache (MAIN is still `prior`),
3742
+ * log the unresolved age (rate-limited). Nothing auto-discards on age.
3743
+ */
3744
+ async settlePendingCommit(channelId, rec, out, via, built) {
3745
+ if (out.kind === "landed") {
3746
+ if (rec.prior !== null)
3747
+ await this.custodyAppRowsBeforeOwnCommit(channelId, rec.prior.epoch);
3748
+ const p = await this.promotePendingUnlocked(channelId, via, rec, built);
3749
+ return p === "none" ? "vanished" : p;
3750
+ }
3751
+ if (out.kind === "definite") {
3752
+ await this.o.store.clearPendingCommit(channelId, pendingCommitKey(rec));
3753
+ this.managers.delete(channelId);
3754
+ await this.notifyPendingResolved(channelId, { op: rec.op, outcome: "not_landed", via });
3755
+ return "not_landed";
3756
+ }
3757
+ this.managers.delete(channelId);
3758
+ this.logPendingUnresolved(channelId, rec);
3759
+ return "ambiguous";
3760
+ }
3761
+ logPendingUnresolved(channelId, rec) {
3762
+ const now = this.nowMs();
3763
+ const last = this.unresolvedLoggedAt.get(channelId);
3764
+ if (last !== void 0 && now - last < PENDING_UNRESOLVED_LOG_INTERVAL_MS)
3765
+ return;
3766
+ this.unresolvedLoggedAt.set(channelId, now);
3767
+ writeServiceLog2("warn", "mls.pending_commit.unresolved", {
3768
+ age_s: Math.max(0, Math.floor((now - rec.createdAt) / 1e3))
3769
+ });
3770
+ }
3771
+ /**
3772
+ * C2 (spec §4.2, H2): promote the record `proven` (whose landing this caller
3773
+ * PROVED: a 2xx for its bytes, or a trial decrypt under its state) ONLY if MAIN
3774
+ * is still the state the commit was built on. Caller holds the channel lock
3775
+ * (the receive path calls this inside the lock it already holds: LOW-4, the
3776
+ * queue is not reentrant).
3777
+ *
3778
+ * `prior` is compared by STRICT equality, and `null` means "no MAIN" (ruling
3779
+ * 2026-09-25): MAIN absent → promote; MAIN present at any group or epoch →
3780
+ * the record is stale. Match → `commitPending` (MAIN := rec.state, record
3781
+ * deleted, owed follow-up slot written, one atomic store step), install the
3782
+ * promoted manager, run the hook. Mismatch → discard the record, keep MAIN,
3783
+ * log `mls.pending_commit.stale_prior`: MAIN moved out of band (a v8 store
3784
+ * write after a downgrade, say), and promoting would rewind it.
3785
+ *
3786
+ * ⛔ C2 T3c (security review C1): EVERY store step here names `proven`'s key
3787
+ * (group, commit epoch, commit bytes) and the store checks it INSIDE the
3788
+ * step, together with "MAIN is exactly the state this caller just checked
3789
+ * against `prior`". A store shared by two tabs or processes may by now hold
3790
+ * ANOTHER record (the other instance resolved ours and staged its own, still
3791
+ * in flight). Promoting that record would make MAIN a state no server
3792
+ * accepted (a fork); clearing it would strand a commit that then lands.
3793
+ *
3794
+ * When `proven` is no longer in the store: it was resolved by another
3795
+ * instance. If MAIN is at or past `proven` in its group, that instance
3796
+ * promoted it (`proven` landed, so no foreign commit can hold its epoch):
3797
+ * report `landed` and drop the cache so MAIN reloads. Otherwise `none`.
3798
+ *
3799
+ * A throw from the store propagates with MAIN and the record unchanged (the
3800
+ * store's all-or-nothing contract).
3801
+ *
3802
+ * `built`: the manager an INLINE commit site built the commit with. Its state
3803
+ * IS `rec.state` (exported from it when the record was written, not mutated
3804
+ * since), so it is installed as the cached manager instead of a re-import,
3805
+ * exactly as the pre-C2 sites cached it after their save.
3806
+ */
3807
+ async promotePendingUnlocked(channelId, via, proven, built) {
3808
+ const key = pendingCommitKey(proven);
3809
+ for (let attempt = 0; attempt < 3; attempt++) {
3810
+ const mainState = await this.o.store.load(channelId);
3811
+ const main2 = mainState ? tagOfState(mainState) : null;
3812
+ const matches = proven.prior === null ? main2 === null : main2 !== null && main2.groupId === proven.prior.groupId && main2.epoch === proven.prior.epoch;
3813
+ if (!matches) {
3814
+ if (await this.o.store.clearPendingCommit(channelId, key)) {
3815
+ writeServiceLog2("warn", "mls.pending_commit.stale_prior");
3816
+ await this.notifyPendingResolved(channelId, { op: proven.op, outcome: "stale_prior", via });
3817
+ return "stale_prior";
3818
+ }
3819
+ return this.provenRecordGone(channelId, proven);
3820
+ }
3821
+ const r = await this.o.store.commitPending(channelId, { ...key, main: mainState });
3822
+ if (r === "record_mismatch")
3823
+ return this.provenRecordGone(channelId, proven);
3824
+ if (r === "main_moved")
3825
+ continue;
3826
+ let mgr = built;
3827
+ if (!mgr) {
3828
+ mgr = new MLSGroupManager2(proven.state.ciphersuite);
3829
+ mgr.importState(proven.state);
3830
+ }
3831
+ this.managers.set(channelId, mgr);
3832
+ if (proven.followUp)
3833
+ await this.runFollowUpHook(channelId, proven.op, proven.followUp, via, proven.createdAt);
3834
+ else
3835
+ await this.notifyPendingResolved(channelId, { op: proven.op, outcome: "landed", via });
3836
+ return "landed";
3837
+ }
3838
+ throw new PendingCommitUnresolvedError(channelId);
3839
+ }
3840
+ /** C2 T3c (C1): the proven record is not in the store. See
3841
+ * {@link promotePendingUnlocked}. */
3842
+ async provenRecordGone(channelId, proven) {
3843
+ const mainState = await this.o.store.load(channelId);
3844
+ const main2 = mainState ? tagOfState(mainState) : null;
3845
+ if (main2 !== null && main2.groupId === proven.groupId && main2.epoch >= proven.commitEpoch) {
3846
+ this.managers.delete(channelId);
3847
+ writeServiceLog2("info", "mls.pending_commit.promoted_elsewhere");
3848
+ return "landed";
3849
+ }
3850
+ return "none";
3851
+ }
3852
+ /**
3853
+ * C2 (spec §4.4 "Commit", plan step 3.6): resolve the channel's record at
3854
+ * lock entry, before a committer op, `ensureSession`, a reset or a supersede
3855
+ * builds anything. Resends the stored bytes and settles the outcome with
3856
+ * `via: "resolver"`. Returns `none` when there was no record (after
3857
+ * discarding any legacy pre-C2 staging slot once per channel per process:
3858
+ * MAIN is authoritative, and a staged state with no request cannot be
3859
+ * resent). The CALLER decides what `ambiguous` means; most throw
3860
+ * `PendingCommitUnresolvedError`.
3861
+ */
3862
+ async resolvePendingCommitUnlocked(channelId, resends = PENDING_INLINE_RESENDS, saw) {
3863
+ const rec = await this.o.store.loadPendingCommit(channelId);
3864
+ saw?.(rec);
3865
+ if (!rec) {
3866
+ if (!this.legacyStagingChecked.has(channelId)) {
3867
+ await this.o.store.discardStaging(channelId);
3868
+ this.legacyStagingChecked.add(channelId);
3869
+ }
3870
+ return "none";
3871
+ }
3872
+ if (isPendingReservation(rec)) {
3873
+ this.logPendingUnresolved(channelId, rec);
3874
+ return "ambiguous";
3875
+ }
3876
+ const out = await this.sendPendingCommit(rec, resends);
3877
+ const r = await this.settlePendingCommit(channelId, rec, out, "resolver");
3878
+ return r === "vanished" ? "none" : r;
3879
+ }
3880
+ /** {@link resolvePendingCommitUnlocked}, then refuse to continue while the
3881
+ * record stays ambiguous. The standard lock-entry guard. */
3882
+ async requirePendingResolvedUnlocked(channelId) {
3883
+ const r = await this.resolvePendingCommitUnlocked(channelId);
3884
+ if (r === "ambiguous")
3885
+ throw new PendingCommitUnresolvedError(channelId);
3886
+ return r;
3887
+ }
3888
+ /**
3889
+ * C2 (OQ-C2-1 ruling): the owner's explicit abandon, reached ONLY through
3890
+ * `resetSessionSupersede(..., { abandonPending: true })`. Discards the
3891
+ * record without knowing whether its commit landed; the attested reset that
3892
+ * follows is the correct recovery either way. Logs
3893
+ * `mls.pending_commit.abandoned` and tells the hook `abandoned`.
3894
+ */
3895
+ /**
3896
+ * C2 T3d (MEDIUM-1): `seen` is the record the owner's resolve ACTUALLY saw
3897
+ * and found ambiguous, or "unreadable" when that load threw. A seen record
3898
+ * is cleared KEYED: if another instance meanwhile proved it landed, promoted
3899
+ * it and staged a newer, unproven record, that newer record is not the one
3900
+ * the owner abandoned, and clearing it would strand a commit that may land.
3901
+ * Then nothing is cleared and this returns false (the caller refuses; the
3902
+ * owner retries). Only an UNREADABLE record is cleared unkeyed: nothing can
3903
+ * read its key, and this is the owner's one way out of it.
3904
+ */
3905
+ async abandonPendingCommitUnlocked(channelId, seen) {
3906
+ if (seen === "unreadable") {
3907
+ writeServiceLog2("warn", "mls.pending_commit.abandon_unreadable");
3908
+ await this.o.store.clearPendingCommit(channelId);
3909
+ this.managers.delete(channelId);
3910
+ writeServiceLog2("warn", "mls.pending_commit.abandoned");
3911
+ return true;
3912
+ }
3913
+ if (!await this.o.store.clearPendingCommit(channelId, pendingCommitKey(seen))) {
3914
+ writeServiceLog2("warn", "mls.pending_commit.abandon_raced");
3915
+ return false;
3916
+ }
3917
+ this.managers.delete(channelId);
3918
+ writeServiceLog2("warn", "mls.pending_commit.abandoned");
3919
+ await this.notifyPendingResolved(channelId, { op: seen.op, outcome: "abandoned", via: "resolver" });
3920
+ return true;
3921
+ }
3922
+ /**
3923
+ * The commit tail shared by the six commit sites (spec §4.1): persist the
3924
+ * record, send, settle inline. Returns `{ landed: true }` after promotion,
3925
+ * or the definite rejection for the site's own existing path. Throws
3926
+ * `PendingCommitUnresolvedError` when the outcome stays ambiguous after the
3927
+ * inline resends (the record is kept and the channel fenced), and a plain
3928
+ * content-free `Error` when promotion found MAIN moved out of band.
3929
+ */
3930
+ async commitWithPendingRecord(channelId, rec, built, replacing) {
3931
+ await this.stagePendingCommit(channelId, rec, replacing);
3932
+ const out = await this.sendPendingCommit(rec, PENDING_INLINE_RESENDS);
3933
+ const r = await this.settlePendingCommit(channelId, rec, out, "inline", built);
3934
+ if (r === "landed")
3935
+ return { landed: true };
3936
+ if (r === "not_landed")
3937
+ return { landed: false, error: out.error };
3938
+ if (r === "ambiguous")
3939
+ throw new PendingCommitUnresolvedError(channelId);
3940
+ throw new Error(`mls.pending_commit.${r}: the commit's prior state moved before promotion; not applied locally`);
3941
+ }
3942
+ /** Build the record for a commit about to be sent. `prior` is the MAIN the
3943
+ * commit was built on (null: none). */
3944
+ pendingRecord(op, groupId, request, mgr, prior, followUp) {
3945
+ return {
3946
+ groupId,
3947
+ op,
3948
+ request,
3949
+ state: mgr.exportState(),
3950
+ commitEpoch: Number(mgr.epoch),
3951
+ prior,
3952
+ ...followUp ? { followUp } : {},
3953
+ createdAt: this.nowMs()
3954
+ };
3955
+ }
3956
+ /**
3957
+ * C2 (spec §4.4 "Send", HIGH-3, MEDIUM-1): the send fence. Throws
3958
+ * `PendingCommitUnresolvedError` while the channel holds a pending record,
3959
+ * so no key is derived for SEND (message, vault KEK seal, 1:1 voice) at an
3960
+ * epoch a landed Remove may already have rotated away from. Re-reads the
3961
+ * DURABLE record on every call, so a record another tab or process wrote is
3962
+ * seen the first time, and a record another tab resolved no longer fences.
3963
+ * Receive never calls this: it derives through {@link exporterKeyUnfenced}.
3964
+ */
3965
+ async assertNotPending(channelId) {
3966
+ this.kickOwedRerun();
3967
+ if (await this.o.store.loadPendingCommit(channelId)) {
3968
+ this.scheduleAutoResolve(channelId);
3969
+ throw new PendingCommitUnresolvedError(channelId);
3970
+ }
3971
+ }
3972
+ /**
3973
+ * C2 T3c (H1): resolve the channel's pending record in the background, at
3974
+ * most once per minute per channel. Deferred to a later tick and run under
3975
+ * the channel lock like any lock-entry resolve (never inside the receive
3976
+ * lock that noticed it; the queue is not reentrant). It only RESENDS the
3977
+ * stored bytes, exactly as a committer's lock entry does. Failures are
3978
+ * logged, rate-limited, and otherwise ignored: the record stays and the
3979
+ * next trigger tries again.
3980
+ */
3981
+ scheduleAutoResolve(channelId) {
3982
+ const now = this.nowMs();
3983
+ const last = this.autoResolveAt.get(channelId);
3984
+ if (last !== void 0 && now - last < PENDING_AUTO_RESOLVE_INTERVAL_MS)
3985
+ return;
3986
+ this.autoResolveAt.set(channelId, now);
3987
+ const job = (async () => {
3988
+ await Promise.resolve();
3989
+ try {
3990
+ await this.locks.run(channelId, () => this.resolvePendingCommitUnlocked(channelId));
3991
+ } catch (e) {
3992
+ writeServiceLog2("warn", "mls.pending_commit.auto_resolve_failed", {}, e);
3993
+ }
3994
+ })();
3995
+ this.backgroundResolves.add(job);
3996
+ void job.finally(() => this.backgroundResolves.delete(job));
3997
+ }
3998
+ /** C2 (for T6's stuck state): ms since the channel's pending record was
3999
+ * written, or null when it has none. A durable read; never throws on
4000
+ * absence. */
4001
+ async pendingCommitAgeMs(channelId) {
4002
+ const rec = await this.o.store.loadPendingCommit(channelId);
4003
+ return rec ? Math.max(0, this.nowMs() - rec.createdAt) : null;
4004
+ }
4005
+ /** Drop `channelId`'s cached manager only if it is still `mgr` (a concurrent
4006
+ * supersede may already have installed a different one, which must survive). */
4007
+ evictIfCached(channelId, mgr) {
4008
+ if (this.managers.get(channelId) === mgr)
4009
+ this.managers.delete(channelId);
4010
+ }
3166
4011
  async evictOnThrow(channelId, op) {
3167
4012
  try {
3168
4013
  return await op();
@@ -3171,7 +4016,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3171
4016
  throw e;
3172
4017
  }
3173
4018
  }
4019
+ /**
4020
+ * The public exporter key. C2 (HIGH-3): fenced. While the channel holds an
4021
+ * unresolved pending commit this throws `PendingCommitUnresolvedError`, so
4022
+ * nothing is sealed at an epoch a landed Remove may have rotated away from.
4023
+ *
4024
+ * ⚠️ C2 T3c (review M4) CORRECTED THIS COMMENT: not every external caller
4025
+ * SEALS. Its callers are the envelope builder and the fake's
4026
+ * `sendApplication` (seal), the vault KEK seal in connect-web (seal), AND
4027
+ * connect-web's vault OPEN (`useVault.ts` `openVaultKekset`, which peeks and
4028
+ * opens the sealed KEK set with it). The fence cannot tell a seal from an
4029
+ * open, so a pending record also fails the vault open closed (the accepted
4030
+ * HIGH-3 consequence; T6 maps it to the retryable not-ready outcome). The
4031
+ * same holds for {@link voiceMediaKey}: BOTH ends of a 1:1 call derive the
4032
+ * media key through it, so the callee's join fails closed too while a record
4033
+ * is pending. Receive-side derivations inside this file use
4034
+ * {@link exporterKeyUnfenced} and keep decrypting epoch N. A refused call
4035
+ * starts the automatic resolve (T3c, H1), so the fence lifts on its own
4036
+ * once the outcome is known.
4037
+ */
3174
4038
  async exporterKey(enclaveId, channelId, epoch) {
4039
+ await this.assertNotPending(channelId);
4040
+ return this.exporterKeyUnfenced(enclaveId, channelId, epoch);
4041
+ }
4042
+ /** C2: the unfenced exporter read, for RECEIVE derivations only
4043
+ * (`decryptApplicationRow`, `replayMissedApplicationMessages`). Reads MAIN,
4044
+ * which never moves past a pending record. */
4045
+ async exporterKeyUnfenced(enclaveId, channelId, epoch) {
3175
4046
  const mgr = await this.manager(channelId);
3176
4047
  return mgr.exportSecret(LABEL, buildExporterContext(enclaveId, channelId, epoch), 32);
3177
4048
  }
@@ -3192,6 +4063,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3192
4063
  * inside another `locks.run(channelId, ...)` for the same channel.
3193
4064
  */
3194
4065
  async voiceMediaKey(enclaveId, channelId, epoch, callId) {
4066
+ await this.assertNotPending(channelId);
3195
4067
  return this.locks.run(channelId, async () => {
3196
4068
  const mgr = await this.manager(channelId);
3197
4069
  return mgr.exportSecret(VOICE_MEDIA_EXPORTER_LABEL, buildVoiceExporterContext(enclaveId, channelId, epoch, callId), 32);
@@ -3348,13 +4220,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3348
4220
  const exp = kpId === "" ? null : await this.o.backend.fetchKeyPackageExpiry(kpId);
3349
4221
  const expired = exp !== null && isServerExpired(exp, this.o.now?.() ?? Date.now());
3350
4222
  if (!expired) {
3351
- console.warn("publishKeyPackage: pending bundle exists; skipping republish");
4223
+ writeServiceLog2("warn", "keypackage.publish_skipped_pending");
3352
4224
  return kpId;
3353
4225
  }
3354
- console.warn(`publishKeyPackage: server KeyPackage expired for device ${this.o.deviceId.slice(0, 8)}; rotating`);
4226
+ writeServiceLog2("warn", "keypackage.expired_rotation");
3355
4227
  await this.o.store.deletePendingBundle(this.o.deviceId);
3356
4228
  }
3357
- console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
4229
+ writeServiceLog2("warn", "keypackage.minting_fresh");
3358
4230
  const mgr = new MLSGroupManager2();
3359
4231
  const bundle = await mgr.generateKeyPackage(this.o.identity);
3360
4232
  const hex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
@@ -3369,7 +4241,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3369
4241
  return this.o.store.loadLastKpId(this.o.deviceId);
3370
4242
  }
3371
4243
  async ensureSession(channelId, opts) {
3372
- return this.locks.run(channelId, () => this.ensureSessionUnlocked(channelId, opts));
4244
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4245
+ await this.requirePendingResolvedUnlocked(channelId);
4246
+ return this.ensureSessionUnlocked(channelId, opts);
4247
+ }));
3373
4248
  }
3374
4249
  async ensureSessionUnlocked(channelId, opts) {
3375
4250
  if (!opts.forceReinit && await this.o.store.load(channelId))
@@ -3403,16 +4278,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3403
4278
  member_device_ids: [this.o.deviceId, ...others],
3404
4279
  welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
3405
4280
  });
3406
- await this.o.store.save(channelId, mgr.exportState());
4281
+ await this.saveMain(channelId, mgr);
3407
4282
  this.managers.set(channelId, mgr);
3408
4283
  return;
3409
4284
  }
3410
4285
  if (opts.bootstrap) {
3411
- const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
3412
- if (init.created === false)
3413
- throw new BootstrapRaceLostError();
3414
- const mgr = new MLSGroupManager2();
3415
- await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
3416
4286
  const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
3417
4287
  const kps = await this.o.backend.fetchKeyPackages(others);
3418
4288
  const pkgs = others.map((d) => {
@@ -3421,20 +4291,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3421
4291
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
3422
4292
  return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
3423
4293
  });
4294
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
4295
+ if (init.created === false) {
4296
+ if (!this.isAdoptableOrphan(init, others)) {
4297
+ if (this.isOrphanShape(init, others))
4298
+ throw new AttestedOrphanRefusedError(channelId);
4299
+ throw new BootstrapRaceLostError();
4300
+ }
4301
+ writeServiceLog2("warn", "session.bootstrap_orphan_adopted");
4302
+ }
4303
+ const mgr = new MLSGroupManager2();
4304
+ await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
3424
4305
  const { commit, welcome } = await mgr.addMembers(pkgs);
3425
- await this.o.backend.distribute(init.group_id, {
4306
+ const rec = this.pendingRecord("bootstrap", init.group_id, {
3426
4307
  device_id: this.o.deviceId,
3427
4308
  commit: bytesToHex2(commit),
3428
4309
  commit_epoch: Number(mgr.epoch),
3429
4310
  expected_epoch: init.current_epoch,
3430
4311
  member_device_ids: [this.o.deviceId, ...others],
3431
4312
  welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
3432
- });
3433
- this.managers.set(channelId, mgr);
3434
- await this.o.store.save(channelId, mgr.exportState());
4313
+ }, mgr, null);
4314
+ const res = await this.commitWithPendingRecord(channelId, rec, mgr);
4315
+ if (!res.landed) {
4316
+ const e = res.error;
4317
+ if (e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT")
4318
+ throw new BootstrapRaceLostError();
4319
+ throw e;
4320
+ }
3435
4321
  } else {
3436
4322
  const rows = await this.o.backend.pullDelivery(this.o.deviceId);
3437
- const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
4323
+ const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
3438
4324
  if (!w)
3439
4325
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
3440
4326
  const mgr = new MLSGroupManager2();
@@ -3442,15 +4328,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3442
4328
  await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
3443
4329
  } catch (err) {
3444
4330
  this.recordDecryptError(w, err);
3445
- console.warn(`ensureSession(join): discarding un-openable Welcome for channel ${channelId} (queue_id=${w.queue_id}); private KeyPackage key no longer held`);
4331
+ writeServiceLog2("warn", "session.unopenable_welcome_discarded", {}, err);
3446
4332
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
3447
4333
  return;
3448
4334
  }
3449
4335
  this.managers.set(channelId, mgr);
3450
- await this.o.store.save(channelId, mgr.exportState());
4336
+ await this.saveMain(channelId, mgr);
3451
4337
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
3452
4338
  }
3453
4339
  }
4340
+ /**
4341
+ * C5 (Phase 3): may a bootstrap ADOPT the existing server group `init`
4342
+ * returned (created=false)? Only an orphan of the right SHAPE
4343
+ * ({@link isOrphanShape}) that the server reports as NOT owner-attested:
4344
+ * `reset_attested === false`, strictly. C2 T3b: `true` and ABSENT both refuse
4345
+ * (fail closed; an older backend omits the field).
4346
+ */
4347
+ isAdoptableOrphan(init, others) {
4348
+ return this.isOrphanShape(init, others) && init.reset_attested === false;
4349
+ }
4350
+ /** C5: still at epoch 0 (no commit ever accepted, so nobody holds usable
4351
+ * state), with a roster that is exactly this device plus devices this
4352
+ * bootstrap Adds. Fails closed on a missing roster. Says nothing about
4353
+ * attestation (see {@link isAdoptableOrphan}). */
4354
+ isOrphanShape(init, others) {
4355
+ if (init.current_epoch !== 0)
4356
+ return false;
4357
+ if (!Array.isArray(init.member_device_ids))
4358
+ return false;
4359
+ return init.member_device_ids.every((d) => d === this.o.deviceId || others.includes(d));
4360
+ }
3454
4361
  /**
3455
4362
  * FileSafe vault owner bootstrap (Task 7, P4/P5 wiring): create the LOCAL
3456
4363
  * ts-mls group bound to a group_id the backend ALREADY minted, WITHOUT
@@ -3499,7 +4406,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3499
4406
  const mgr = new MLSGroupManager2();
3500
4407
  await mgr.createGroup(new TextEncoder().encode(groupId), bundle);
3501
4408
  this.managers.set(channelId, mgr);
3502
- await this.o.store.save(channelId, mgr.exportState());
4409
+ await this.saveMain(channelId, mgr);
3503
4410
  }
3504
4411
  /**
3505
4412
  * Committer-side: add a member to an ALREADY-bootstrapped (live) group.
@@ -3515,7 +4422,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3515
4422
  * parallel group_id-by-channel resolver.
3516
4423
  */
3517
4424
  async addMemberToLiveGroup(channelId, newDeviceId, opts) {
3518
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, opts)));
4425
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4426
+ await this.requirePendingResolvedUnlocked(channelId);
4427
+ return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, opts);
4428
+ }));
3519
4429
  }
3520
4430
  /**
3521
4431
  * Committer-side (owner): add a member to an already-bootstrapped VAULT
@@ -3536,7 +4446,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3536
4446
  if (!opts.expectedCredentialPubKey) {
3537
4447
  throw new VaultCredentialMismatchError(opts.expectedCredentialPubKey, "");
3538
4448
  }
3539
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, { vaultAdd: true, ...opts })));
4449
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4450
+ await this.requirePendingResolvedUnlocked(channelId);
4451
+ return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, { vaultAdd: true, ...opts });
4452
+ }));
3540
4453
  }
3541
4454
  async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
3542
4455
  const isVault = !!opts && "vaultAdd" in opts && opts.vaultAdd === true;
@@ -3591,13 +4504,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3591
4504
  deltaVouches
3592
4505
  });
3593
4506
  if (hasMalformedBinding(identityLeaves)) {
3594
- console.warn(`addMemberToLiveGroup: channel ${channelId} has a leaf with an unreadable device binding for identity ${claimedIdentity ?? "?"} \u2014 treated as UNBOUND, never as absent`);
4507
+ writeServiceLog2("warn", "admission.malformed_leaf_binding");
3595
4508
  }
3596
4509
  if (targetLeaves.length > 0 && deltaVouches) {
3597
- 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.`);
4510
+ writeServiceLog2("warn", "admission.possible_keypackage_substitution", {
4511
+ leaf_count: targetLeaves.length
4512
+ });
3598
4513
  }
3599
4514
  if (targetLeaves.length > 1) {
3600
- 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.`);
4515
+ writeServiceLog2("warn", "admission.duplicate_device_leaves", {
4516
+ leaf_count: targetLeaves.length
4517
+ });
3601
4518
  }
3602
4519
  switch (verdict.decision) {
3603
4520
  case "admit":
@@ -3624,21 +4541,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3624
4541
  groupId = init.group_id;
3625
4542
  members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
3626
4543
  }
4544
+ if (new TextDecoder().decode(mgr.groupId) !== groupId) {
4545
+ throw new Error("addMemberToLiveGroup: group binding mismatch");
4546
+ }
3627
4547
  const priorEpoch = Number(mgr.epoch);
4548
+ const prior = tagOf(mgr);
3628
4549
  const { commit, welcome } = await mgr.addMembers([pkg]);
3629
- try {
3630
- await this.o.backend.distribute(groupId, {
3631
- device_id: this.o.deviceId,
3632
- commit: bytesToHex2(commit),
3633
- commit_epoch: Number(mgr.epoch),
3634
- expected_epoch: priorEpoch,
3635
- member_device_ids: members,
3636
- op: "add",
3637
- welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex2(welcome) }] : []
3638
- });
3639
- } catch (e) {
3640
- this.managers.delete(channelId);
3641
- if (_attempt >= 2)
4550
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("add", groupId, {
4551
+ device_id: this.o.deviceId,
4552
+ commit: bytesToHex2(commit),
4553
+ commit_epoch: Number(mgr.epoch),
4554
+ expected_epoch: priorEpoch,
4555
+ member_device_ids: members,
4556
+ op: "add",
4557
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex2(welcome) }] : []
4558
+ }, mgr, prior), mgr);
4559
+ if (!res.landed) {
4560
+ const e = res.error;
4561
+ if (isSupersededRefusal(e))
4562
+ throw new GroupSupersededError(channelId, groupId);
4563
+ if (_attempt >= 2 || !isEpochConflict(e))
3642
4564
  throw e;
3643
4565
  try {
3644
4566
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
@@ -3646,7 +4568,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3646
4568
  }
3647
4569
  return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
3648
4570
  }
3649
- await this.o.store.save(channelId, mgr.exportState());
3650
4571
  return "committed";
3651
4572
  }
3652
4573
  /**
@@ -3669,36 +4590,43 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3669
4590
  * of this group.
3670
4591
  */
3671
4592
  async removeMemberFromLiveGroup(channelId, identityId) {
3672
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeMemberFromLiveGroupUnlocked(channelId, identityId, 0)));
4593
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4594
+ await this.requirePendingResolvedUnlocked(channelId);
4595
+ return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, 0);
4596
+ }));
3673
4597
  }
3674
4598
  async removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt) {
3675
4599
  const mgr = await this.manager(channelId);
3676
4600
  const indices = mgr.leafIndicesForIdentity(identityId);
3677
4601
  if (indices.length === 0)
3678
4602
  return;
4603
+ this.refuseOwnLeafRemove(mgr, indices, "removeMember");
3679
4604
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
4605
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
4606
+ throw new Error("removeMemberFromLiveGroup: group binding mismatch");
4607
+ }
3680
4608
  const priorEpoch = Number(mgr.epoch);
4609
+ const prior = tagOf(mgr);
3681
4610
  const { commit } = await mgr.removeMembers(indices);
3682
4611
  if (Number(mgr.epoch) !== priorEpoch + 1) {
3683
4612
  throw new Error("removeMemberFromLiveGroup: unexpected epoch drift before distribute");
3684
4613
  }
3685
- await this.o.store.saveStaging(channelId, mgr.exportState());
3686
4614
  const members = init.member_device_ids ?? [this.o.deviceId];
3687
- try {
3688
- await this.o.backend.distribute(init.group_id, {
3689
- device_id: this.o.deviceId,
3690
- commit: bytesToHex2(commit),
3691
- commit_epoch: Number(mgr.epoch),
3692
- expected_epoch: priorEpoch,
3693
- member_device_ids: members,
3694
- op: "remove",
3695
- removed_identity_id: identityId,
3696
- welcomes: []
3697
- });
3698
- } catch (e) {
3699
- this.managers.delete(channelId);
3700
- await this.o.store.discardStaging(channelId);
3701
- if (_attempt >= 2)
4615
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
4616
+ device_id: this.o.deviceId,
4617
+ commit: bytesToHex2(commit),
4618
+ commit_epoch: Number(mgr.epoch),
4619
+ expected_epoch: priorEpoch,
4620
+ member_device_ids: members,
4621
+ op: "remove",
4622
+ removed_identity_id: identityId,
4623
+ welcomes: []
4624
+ }, mgr, prior), mgr);
4625
+ if (!res.landed) {
4626
+ const e = res.error;
4627
+ if (isSupersededRefusal(e))
4628
+ throw new GroupSupersededError(channelId, init.group_id);
4629
+ if (_attempt >= 2 || !isEpochConflict(e))
3702
4630
  throw e;
3703
4631
  try {
3704
4632
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
@@ -3706,7 +4634,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3706
4634
  }
3707
4635
  return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt + 1);
3708
4636
  }
3709
- await this.o.store.promoteStaging(channelId);
3710
4637
  }
3711
4638
  /**
3712
4639
  * Committer-side: REMOVE a SINGLE device (one leaf) from a live group (MD-3
@@ -3745,25 +4672,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3745
4672
  * should delegate to it.
3746
4673
  *
3747
4674
  * Returns null rather than throwing so every caller must decide explicitly what
3748
- * an unknown own-leaf means.
3749
- *
3750
- * ⚠️ The one caller today does NOT fail closed on null. In
3751
- * {@link removeDeviceFromLiveGroupUnlocked} the branch is
3752
- * `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
3753
- * identities at all, so a null return sends an OWN-identity removal down the
3754
- * positional `leaves[pos]` path, which is exactly the add-order guess the
3755
- * self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
3756
- * permutes that mapping after any Remove). Null is therefore currently
3757
- * indistinguishable from "foreign identity", and only the cardinality guard
3758
- * stands between it and evicting the wrong device of the same human.
3759
- * Correcting that is Phase 1b, not this comment.
4675
+ * an unknown own-leaf means. Every remove path now decides the same way: null
4676
+ * FAILS CLOSED (Phase 3 C3, {@link refuseOwnLeafRemove}). Before C3,
4677
+ * {@link removeDeviceFromLiveGroupUnlocked} treated null as "foreign identity"
4678
+ * and sent an own-identity removal down the positional add-order guess.
3760
4679
  */
3761
4680
  ownLeafIndex(mgr) {
3762
4681
  const idx = mgr.state?.privatePath?.leafIndex;
3763
4682
  return typeof idx === "number" ? idx : null;
3764
4683
  }
4684
+ /**
4685
+ * Phase 3 C3: the shared own-leaf refusal for every remove path. Throws
4686
+ * {@link OwnLeafRemoveRefusedError} when this committer's own leaf index is
4687
+ * unknown (fail closed) or is among `targetLeaves`. Returns the own leaf index
4688
+ * so a caller that needs it (self-exclusion) reads it from the same check.
4689
+ * Synchronous and called before any commit is built: a refusal distributes
4690
+ * nothing and leaves the cached manager untouched.
4691
+ */
4692
+ refuseOwnLeafRemove(mgr, targetLeaves, op) {
4693
+ const ownLeaf = this.ownLeafIndex(mgr);
4694
+ if (ownLeaf === null)
4695
+ throw new OwnLeafRemoveRefusedError(op, "own_leaf_unknown");
4696
+ if (targetLeaves.includes(ownLeaf))
4697
+ throw new OwnLeafRemoveRefusedError(op, "own_leaf");
4698
+ return ownLeaf;
4699
+ }
3765
4700
  async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
3766
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
4701
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4702
+ await this.requirePendingResolvedUnlocked(channelId);
4703
+ return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0);
4704
+ }));
3767
4705
  }
3768
4706
  async removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt) {
3769
4707
  const mgr = await this.manager(channelId);
@@ -3829,9 +4767,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3829
4767
  if (leaves.length !== opts.orderedDeviceIds.length) {
3830
4768
  throw new LeafRosterMismatchError();
3831
4769
  }
4770
+ if (deviceId === this.o.deviceId)
4771
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
3832
4772
  let leafIndex;
3833
4773
  const ownLeaf = this.ownLeafIndex(mgr);
3834
- if (ownLeaf !== null && leaves.includes(ownLeaf)) {
4774
+ if (ownLeaf === null)
4775
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf_unknown");
4776
+ if (leaves.includes(ownLeaf)) {
3835
4777
  const others = leaves.filter((i) => i !== ownLeaf);
3836
4778
  if (others.length !== 1) {
3837
4779
  throw new AmbiguousOwnLeafError(deviceId, leaves.length, others.length);
@@ -3841,30 +4783,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3841
4783
  leafIndex = leaves[pos];
3842
4784
  }
3843
4785
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
4786
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
4787
+ throw new Error("removeDeviceFromLiveGroup: group binding mismatch");
4788
+ }
3844
4789
  const priorEpoch = Number(mgr.epoch);
4790
+ const prior = tagOf(mgr);
3845
4791
  const { commit } = await mgr.removeMembers([leafIndex]);
3846
4792
  if (Number(mgr.epoch) !== priorEpoch + 1) {
3847
4793
  throw new Error("removeDeviceFromLiveGroup: unexpected epoch drift before distribute");
3848
4794
  }
3849
- await this.o.store.saveStaging(channelId, mgr.exportState());
3850
4795
  const members = init.member_device_ids ?? [this.o.deviceId];
3851
4796
  const removedIdentity = typeof opts.identity === "string" ? opts.identity : bytesToHex2(opts.identity);
3852
- try {
3853
- await this.o.backend.distribute(init.group_id, {
3854
- device_id: this.o.deviceId,
3855
- commit: bytesToHex2(commit),
3856
- commit_epoch: Number(mgr.epoch),
3857
- expected_epoch: priorEpoch,
3858
- member_device_ids: members,
3859
- op: "remove",
3860
- removed_identity_id: removedIdentity,
3861
- removed_device_id: deviceId,
3862
- welcomes: []
3863
- });
3864
- } catch (e) {
3865
- this.managers.delete(channelId);
3866
- await this.o.store.discardStaging(channelId);
3867
- if (_attempt >= 2)
4797
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
4798
+ device_id: this.o.deviceId,
4799
+ commit: bytesToHex2(commit),
4800
+ commit_epoch: Number(mgr.epoch),
4801
+ expected_epoch: priorEpoch,
4802
+ member_device_ids: members,
4803
+ op: "remove",
4804
+ removed_identity_id: removedIdentity,
4805
+ removed_device_id: deviceId,
4806
+ welcomes: []
4807
+ }, mgr, prior), mgr);
4808
+ if (!res.landed) {
4809
+ const e = res.error;
4810
+ if (isSupersededRefusal(e))
4811
+ throw new GroupSupersededError(channelId, init.group_id);
4812
+ if (_attempt >= 2 || !isEpochConflict(e))
3868
4813
  throw e;
3869
4814
  try {
3870
4815
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
@@ -3872,7 +4817,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3872
4817
  }
3873
4818
  return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
3874
4819
  }
3875
- await this.o.store.promoteStaging(channelId);
3876
4820
  }
3877
4821
  /**
3878
4822
  * Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
@@ -3930,7 +4874,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3930
4874
  * re-runs on every attempt.
3931
4875
  */
3932
4876
  async removeVaultLeafFromLiveGroup(channelId, expectedLeafSignaturePubKeyHex, opts) {
3933
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeVaultLeafUnlocked(channelId, expectedLeafSignaturePubKeyHex, opts, 0)));
4877
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
4878
+ await this.requirePendingResolvedUnlocked(channelId);
4879
+ return this.removeVaultLeafUnlocked(channelId, expectedLeafSignaturePubKeyHex, opts, 0);
4880
+ }));
3934
4881
  }
3935
4882
  async removeVaultLeafUnlocked(channelId, expectedHex, opts, _attempt) {
3936
4883
  const mgr = await this.manager(channelId);
@@ -3956,33 +4903,37 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3956
4903
  const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
3957
4904
  if (claimed !== opts.expectedIdentityId)
3958
4905
  throw new VaultLeafTOCTOUError();
4906
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeVaultLeaf");
4907
+ const followUp = opts.deviceId && opts.enclaveId ? { kind: "vault-remove", enclaveId: opts.enclaveId, deviceId: opts.deviceId } : void 0;
4908
+ if (followUp)
4909
+ await this.drainOwedFollowUpUnlocked(channelId);
3959
4910
  const priorEpoch = Number(mgr.epoch);
4911
+ const prior = tagOf(mgr);
3960
4912
  const { commit } = await mgr.removeMembers([leafIndex]);
3961
4913
  if (Number(mgr.epoch) !== priorEpoch + 1) {
3962
4914
  throw new Error("removeVaultLeafFromLiveGroup: unexpected epoch drift before distribute");
3963
4915
  }
3964
4916
  const groupId = new TextDecoder().decode(mgr.groupId);
3965
- await this.o.store.saveStaging(channelId, mgr.exportState());
3966
- try {
3967
- await this.o.backend.distribute(groupId, {
3968
- device_id: this.o.deviceId,
3969
- commit: bytesToHex2(commit),
3970
- commit_epoch: Number(mgr.epoch),
3971
- expected_epoch: priorEpoch,
3972
- member_device_ids: opts.vaultContext.memberDeviceIds,
3973
- op: "remove",
3974
- removed_identity_id: opts.expectedIdentityId,
3975
- // Device-granular distribute (C1): when opts.deviceId is empty (no
3976
- // owner-local leafSigKey->deviceId binding), OMIT removed_device_id —
3977
- // the server falls back to identity-level eviction. The crypto
3978
- // removal above already stands regardless; only delivery degrades.
3979
- ...opts.deviceId ? { removed_device_id: opts.deviceId } : {},
3980
- welcomes: []
3981
- });
3982
- } catch (e) {
3983
- this.managers.delete(channelId);
3984
- await this.o.store.discardStaging(channelId);
3985
- if (_attempt >= 2)
4917
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", groupId, {
4918
+ device_id: this.o.deviceId,
4919
+ commit: bytesToHex2(commit),
4920
+ commit_epoch: Number(mgr.epoch),
4921
+ expected_epoch: priorEpoch,
4922
+ member_device_ids: opts.vaultContext.memberDeviceIds,
4923
+ op: "remove",
4924
+ removed_identity_id: opts.expectedIdentityId,
4925
+ // Device-granular distribute (C1): when opts.deviceId is empty (no
4926
+ // owner-local leafSigKey->deviceId binding), OMIT removed_device_id —
4927
+ // the server falls back to identity-level eviction. The crypto
4928
+ // removal above already stands regardless; only delivery degrades.
4929
+ ...opts.deviceId ? { removed_device_id: opts.deviceId } : {},
4930
+ welcomes: []
4931
+ }, mgr, prior, followUp), mgr);
4932
+ if (!res.landed) {
4933
+ const e = res.error;
4934
+ if (isSupersededRefusal(e))
4935
+ throw new GroupSupersededError(channelId, groupId);
4936
+ if (_attempt >= 2 || !isEpochConflict(e))
3986
4937
  throw e;
3987
4938
  try {
3988
4939
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
@@ -3990,7 +4941,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
3990
4941
  }
3991
4942
  return this.removeVaultLeafUnlocked(channelId, expectedHex, opts, _attempt + 1);
3992
4943
  }
3993
- await this.o.store.promoteStaging(channelId);
3994
4944
  }
3995
4945
  /**
3996
4946
  * Committer-side: HEAL a permanently-diverged member by Remove(stale leaf) then
@@ -4081,6 +5031,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4081
5031
  */
4082
5032
  async healDivergedMember(channelId, memberDeviceId, opts) {
4083
5033
  return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
5034
+ await this.requirePendingResolvedUnlocked(channelId);
4084
5035
  const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
4085
5036
  const hex = kps[memberDeviceId];
4086
5037
  if (!hex)
@@ -4107,7 +5058,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4107
5058
  }
4108
5059
  }
4109
5060
  if (leaves.length === 1) {
4110
- 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.`);
5061
+ writeServiceLog2("warn", "heal.single_leaf_device_unattributed");
4111
5062
  }
4112
5063
  let removeLanded = false;
4113
5064
  if (leaves.length === 1) {
@@ -4209,6 +5160,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4209
5160
  */
4210
5161
  async resetChannelForRejoin(channelId) {
4211
5162
  await this.locks.run(channelId, async () => {
5163
+ const r = await this.resolvePendingCommitUnlocked(channelId);
5164
+ if (r === "ambiguous")
5165
+ throw new PendingCommitUnresolvedError(channelId);
5166
+ if (r === "landed") {
5167
+ writeServiceLog2("info", "mls.pending_commit.resolved_pending");
5168
+ return;
5169
+ }
4212
5170
  this.evictChannelState(channelId);
4213
5171
  await this.o.store.delete(channelId);
4214
5172
  await this.o.store.discardStaging(channelId);
@@ -4233,7 +5191,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4233
5191
  * (required subset of supplied) is still satisfied even though it never
4234
5192
  * goes through addMembers. */
4235
5193
  async resetSessionSupersede(channelId, opts) {
4236
- return this.locks.run(channelId, async () => {
5194
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
5195
+ let resolved;
5196
+ let seen = "unreadable";
5197
+ try {
5198
+ resolved = await this.resolvePendingCommitUnlocked(channelId, PENDING_INLINE_RESENDS, (r) => {
5199
+ if (r)
5200
+ seen = r;
5201
+ });
5202
+ } catch (e) {
5203
+ if (opts.abandonPending !== true)
5204
+ throw e;
5205
+ resolved = "ambiguous";
5206
+ }
5207
+ if (resolved === "ambiguous") {
5208
+ if (opts.abandonPending !== true)
5209
+ throw new PendingCommitUnresolvedError(channelId);
5210
+ if (!await this.abandonPendingCommitUnlocked(channelId, seen))
5211
+ throw new PendingCommitUnresolvedError(channelId);
5212
+ }
5213
+ await this.drainOwedFollowUpUnlocked(channelId, opts.abandonPending === true);
4237
5214
  let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
4238
5215
  if (!bundle) {
4239
5216
  await this.publishKeyPackage();
@@ -4242,9 +5219,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4242
5219
  if (!bundle)
4243
5220
  throw new Error("resetSessionSupersede: call publishKeyPackage() first");
4244
5221
  const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
4245
- const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
4246
- const mgr = new MLSGroupManager2();
4247
- await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
4248
5222
  const kps = await this.o.backend.fetchKeyPackages(others);
4249
5223
  const pkgs = others.map((d) => {
4250
5224
  const hex = kps[d];
@@ -4252,19 +5226,62 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4252
5226
  throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
4253
5227
  return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
4254
5228
  });
4255
- const { commit, welcome } = await mgr.addMembers(pkgs);
4256
- await this.o.backend.distribute(reinit.group_id, {
5229
+ const mainState0 = await this.o.store.load(channelId);
5230
+ const prior0 = mainState0 ? tagOfState(mainState0) : null;
5231
+ const reservation = pendingReservation({
5232
+ groupId: opts.newGroupId,
5233
+ deviceId: this.o.deviceId,
5234
+ prior: prior0,
5235
+ placeholder: mainState0 ?? RESERVATION_PLACEHOLDER_STATE,
5236
+ createdAt: this.nowMs()
5237
+ });
5238
+ if (!await this.o.store.savePendingCommit(channelId, reservation, { onlyIfAbsent: true })) {
5239
+ throw new PendingCommitUnresolvedError(channelId);
5240
+ }
5241
+ const reservationKey = pendingCommitKey(reservation);
5242
+ const mainState1 = await this.o.store.load(channelId);
5243
+ const prior1 = mainState1 ? tagOfState(mainState1) : null;
5244
+ let prior = prior0;
5245
+ if (!sameMainTag(prior0, prior1)) {
5246
+ const corrected = { ...reservation, prior: prior1, state: mainState1 ?? RESERVATION_PLACEHOLDER_STATE };
5247
+ if (!await this.o.store.savePendingCommit(channelId, corrected, { replacing: reservationKey })) {
5248
+ await this.o.store.clearPendingCommit(channelId, reservationKey).catch(() => false);
5249
+ throw new PendingCommitUnresolvedError(channelId);
5250
+ }
5251
+ prior = prior1;
5252
+ }
5253
+ let reinit;
5254
+ try {
5255
+ reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
5256
+ } catch (e) {
5257
+ if (isDefiniteReinitRefusal(e))
5258
+ await this.o.store.clearPendingCommit(channelId, reservationKey);
5259
+ throw e;
5260
+ }
5261
+ let mgr;
5262
+ let commit;
5263
+ let welcome;
5264
+ try {
5265
+ mgr = new MLSGroupManager2();
5266
+ await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
5267
+ ({ commit, welcome } = await mgr.addMembers(pkgs));
5268
+ } catch (e) {
5269
+ await this.o.store.clearPendingCommit(channelId, reservationKey);
5270
+ throw e;
5271
+ }
5272
+ const staleMemberDeviceIds = reinit.stale_member_device_ids ?? [];
5273
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("supersede", reinit.group_id, {
4257
5274
  device_id: this.o.deviceId,
4258
5275
  commit: bytesToHex2(commit),
4259
5276
  commit_epoch: Number(mgr.epoch),
4260
5277
  expected_epoch: reinit.current_epoch,
4261
5278
  member_device_ids: [this.o.deviceId, ...others],
4262
5279
  welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
4263
- });
4264
- await this.o.store.save(channelId, mgr.exportState());
4265
- this.managers.set(channelId, mgr);
4266
- return { staleMemberDeviceIds: reinit.stale_member_device_ids ?? [] };
4267
- });
5280
+ }, mgr, prior, { kind: "supersede", staleMemberDeviceIds }), mgr, reservationKey);
5281
+ if (!res.landed)
5282
+ throw res.error;
5283
+ return { staleMemberDeviceIds };
5284
+ }));
4268
5285
  }
4269
5286
  /** Returns true when an in-memory MLS manager is live for the channel.
4270
5287
  * Cheap synchronous check; does NOT consult the store. */
@@ -4329,7 +5346,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4329
5346
  const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(m.payload)));
4330
5347
  if (envelope.channel_id !== channelId)
4331
5348
  continue;
4332
- const key = await this.locks.run(channelId, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
5349
+ const key = await this.locks.run(channelId, () => this.exporterKeyUnfenced(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
4333
5350
  const aad = await buildAssociatedData({
4334
5351
  message_id: envelope.message_id,
4335
5352
  channel_id: envelope.channel_id,
@@ -4526,7 +5543,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4526
5543
  } catch (err) {
4527
5544
  this.recordDecryptError(row, err);
4528
5545
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
4529
- 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`);
5546
+ writeServiceLog2("debug", "supersede.welcome_open_failed", {}, err);
4530
5547
  }
4531
5548
  let requested = false;
4532
5549
  try {
@@ -4544,6 +5561,23 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4544
5561
  if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
4545
5562
  return refuse("supersede refused: post-join group id != attested new_group_id");
4546
5563
  }
5564
+ let dead = null;
5565
+ try {
5566
+ dead = await this.o.store.loadPendingCommit(channelId);
5567
+ await this.resolvePendingCommitUnlocked(channelId, 0);
5568
+ } catch (e) {
5569
+ writeServiceLog2("warn", "mls.pending_commit.supersede_resolve_failed", {}, e);
5570
+ }
5571
+ if (dead)
5572
+ await this.o.store.clearPendingCommit(channelId, pendingCommitKey(dead));
5573
+ try {
5574
+ const after = await this.o.store.loadPendingCommit(channelId);
5575
+ if (after && after.groupId !== row.group_id) {
5576
+ await this.o.store.clearPendingCommit(channelId, pendingCommitKey(after));
5577
+ }
5578
+ } catch (e) {
5579
+ writeServiceLog2("warn", "mls.pending_commit.supersede_resolve_failed", {}, e);
5580
+ }
4547
5581
  this.evictChannelState(channelId);
4548
5582
  await this.o.store.delete(channelId);
4549
5583
  await this.o.store.discardStaging(channelId);
@@ -4551,13 +5585,21 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4551
5585
  this.errors.delete(channelId);
4552
5586
  this.supersedeRefusals.delete(`${channelId}:${row.group_id}`);
4553
5587
  this.managers.set(channelId, mgr);
4554
- await this.o.store.save(channelId, mgr.exportState());
5588
+ await this.saveMain(channelId, mgr);
4555
5589
  await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
4556
5590
  return true;
4557
5591
  });
4558
5592
  }
4559
5593
  async receiveOnce(deviceId) {
5594
+ this.kickOwedRerun();
4560
5595
  const rows = await this.o.backend.pullDelivery(deviceId);
5596
+ if (deviceId === this.o.deviceId && this.custody.size && rows.length < DELIVERY_PULL_PAGE) {
5597
+ const pulledIds = new Set(rows.map((r) => r.queue_id));
5598
+ for (const qid of this.custody.keys()) {
5599
+ if (!pulledIds.has(qid))
5600
+ this.custody.delete(qid);
5601
+ }
5602
+ }
4561
5603
  const appEntries = [];
4562
5604
  const handshakeAcked = [];
4563
5605
  const nackIds = [];
@@ -4566,7 +5608,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4566
5608
  const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
4567
5609
  const ordered = [...welcomes, ...nonWelcomes];
4568
5610
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
4569
- 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(", "));
5611
+ writeServiceLog2("debug", "delivery.rows_pulled", { count: ordered.length });
4570
5612
  }
4571
5613
  const classCtx = /* @__PURE__ */ new Map();
4572
5614
  const classCtxFor = async (cid) => {
@@ -4593,10 +5635,58 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4593
5635
  }
4594
5636
  return ctx;
4595
5637
  };
5638
+ const pendingAtPull = /* @__PURE__ */ new Map();
5639
+ const leftThisPull = /* @__PURE__ */ new Map();
5640
+ const pullNow = this.nowMs();
5641
+ const escalatedAtPull = (cid) => {
5642
+ const st = this.leftStreak.get(cid);
5643
+ return st !== void 0 && st.pulls >= PENDING_LEFT_ESCALATION_PULLS && pullNow - st.since >= PENDING_LEFT_ESCALATION_MS;
5644
+ };
5645
+ const hadPendingAtPull = async (cid) => {
5646
+ let v = pendingAtPull.get(cid);
5647
+ if (v === void 0) {
5648
+ try {
5649
+ v = await this.o.store.loadPendingCommit(cid) !== null;
5650
+ } catch {
5651
+ v = true;
5652
+ }
5653
+ pendingAtPull.set(cid, v);
5654
+ }
5655
+ return v;
5656
+ };
4596
5657
  for (const row of ordered) {
4597
5658
  try {
4598
5659
  if (row.message_type !== "welcome" && row.connect_channel_id) {
4599
5660
  await classCtxFor(row.connect_channel_id);
5661
+ if (await hadPendingAtPull(row.connect_channel_id)) {
5662
+ const pcid = row.connect_channel_id;
5663
+ let verdict;
5664
+ try {
5665
+ verdict = await this.resolveRowAgainstPending(pcid, row, classCtx.get(pcid));
5666
+ } catch (e) {
5667
+ const now = this.nowMs();
5668
+ const last = this.receiveFailLoggedAt.get(pcid);
5669
+ if (last === void 0 || now - last >= PENDING_RECEIVE_FAIL_LOG_INTERVAL_MS) {
5670
+ this.receiveFailLoggedAt.set(pcid, now);
5671
+ writeServiceLog2("warn", "mls.pending_commit.receive_resolve_failed", {}, e);
5672
+ }
5673
+ verdict = { kind: "leave" };
5674
+ }
5675
+ if (verdict.kind === "leave") {
5676
+ leftThisPull.set(pcid, (leftThisPull.get(pcid) ?? 0) + 1);
5677
+ if (escalatedAtPull(pcid))
5678
+ throw new PendingRowEscalatedError();
5679
+ continue;
5680
+ }
5681
+ if (verdict.kind === "acked") {
5682
+ handshakeAcked.push(row.queue_id);
5683
+ continue;
5684
+ }
5685
+ if (verdict.kind === "entry") {
5686
+ appEntries.push(verdict.entry);
5687
+ continue;
5688
+ }
5689
+ }
4600
5690
  }
4601
5691
  if (row.message_type === "welcome") {
4602
5692
  const wcid = row.connect_channel_id;
@@ -4611,14 +5701,19 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4611
5701
  heldGroupId = null;
4612
5702
  }
4613
5703
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
4614
- 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"}`);
5704
+ if (heldGroupId === null)
5705
+ writeServiceLog2("debug", "delivery.welcome_manager_unloadable");
5706
+ else if (heldGroupId === row.group_id)
5707
+ writeServiceLog2("debug", "delivery.welcome_stray");
5708
+ else
5709
+ writeServiceLog2("debug", "delivery.welcome_supersede_candidate");
4615
5710
  }
4616
5711
  if (heldGroupId !== null && heldGroupId === row.group_id) {
4617
5712
  handshakeAcked.push(row.queue_id);
4618
5713
  } else if (heldGroupId !== null && heldGroupId !== row.group_id) {
4619
5714
  if (this.supersedeBackoffActive(wcid, row.group_id)) {
4620
5715
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
4621
- console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} g=${row.group_id.slice(0, 8)} -> SKIP(refusal back-off)`);
5716
+ writeServiceLog2("debug", "delivery.welcome_refusal_backoff");
4622
5717
  }
4623
5718
  } else {
4624
5719
  const ok = await this.trySupersede(wcid, row, heldGroupId);
@@ -4627,7 +5722,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4627
5722
  }
4628
5723
  }
4629
5724
  } else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
4630
- 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)`);
5725
+ writeServiceLog2("debug", "delivery.welcome_no_local_state");
4631
5726
  }
4632
5727
  }
4633
5728
  } else if (row.message_type === "commit") {
@@ -4638,7 +5733,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4638
5733
  await this.locks.run(cid, async () => {
4639
5734
  const mgr = await this.manager(cid);
4640
5735
  await mgr.decrypt(hexToBytes(row.payload));
4641
- await this.o.store.save(cid, mgr.exportState());
5736
+ await this.saveAdvancedOrEvict(cid, mgr);
4642
5737
  advancedEpoch = Number(mgr.epoch);
4643
5738
  });
4644
5739
  this.recordDecryptSuccess(cid);
@@ -4649,36 +5744,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4649
5744
  }
4650
5745
  handshakeAcked.push(row.queue_id);
4651
5746
  } else {
4652
- const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
4653
- if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
4654
- throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
4655
- }
4656
- const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
4657
- const aad = await buildAssociatedData({
4658
- message_id: envelope.message_id,
4659
- channel_id: envelope.channel_id,
4660
- sender_id: envelope.sender_id,
4661
- mls_group_epoch: envelope.mls_group_epoch,
4662
- replay_nonce: envelope.replay_nonce,
4663
- policy_binding: envelope.policy_binding
4664
- });
4665
- const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
4666
- const appChannelId = row.connect_channel_id ?? envelope.channel_id;
4667
- this.recordDecryptSuccess(appChannelId);
4668
- appEntries.push({
4669
- queueId: row.queue_id,
4670
- messageId: envelope.message_id,
4671
- channelId: appChannelId,
4672
- plaintext,
4673
- envelope,
4674
- receivedAt: Date.now(),
4675
- retryCount: 0,
4676
- expires_at: row.expires_at,
4677
- voiceMode: row.voice_mode ?? void 0,
4678
- voiceDefaultResponder: row.voice_default_responder ?? void 0
4679
- });
5747
+ const held = this.custody.get(row.queue_id);
5748
+ appEntries.push(held ? { ...held } : await this.decryptApplicationRow(row, { lockHeld: false }));
4680
5749
  }
4681
5750
  } catch (err) {
5751
+ const heldAfterWait = row.message_type === "application" ? this.custody.get(row.queue_id) : void 0;
5752
+ if (heldAfterWait) {
5753
+ appEntries.push({ ...heldAfterWait });
5754
+ continue;
5755
+ }
5756
+ if (err instanceof PendingCommitUnresolvedError) {
5757
+ continue;
5758
+ }
4682
5759
  this.recordDecryptError(row, err);
4683
5760
  const cid = row.connect_channel_id;
4684
5761
  let drained = false;
@@ -4723,6 +5800,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4723
5800
  }
4724
5801
  }
4725
5802
  }
5803
+ for (const [cid, had] of pendingAtPull) {
5804
+ if (!had)
5805
+ continue;
5806
+ if ((leftThisPull.get(cid) ?? 0) === 0) {
5807
+ this.leftStreak.delete(cid);
5808
+ continue;
5809
+ }
5810
+ const st = this.leftStreak.get(cid);
5811
+ if (st)
5812
+ st.pulls += 1;
5813
+ else
5814
+ this.leftStreak.set(cid, { pulls: 1, since: pullNow });
5815
+ this.scheduleAutoResolve(cid);
5816
+ }
4726
5817
  if (handshakeAcked.length)
4727
5818
  await this.o.backend.ackDelivery(deviceId, handshakeAcked);
4728
5819
  if (nackIds.length) {
@@ -4736,6 +5827,195 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4736
5827
  }
4737
5828
  return appEntries;
4738
5829
  }
5830
+ /**
5831
+ * C2 (spec §4.4 "Receive resolves the record from the queue"; plan 3.8):
5832
+ * decide one delivery row of a channel that holds a pending record. Runs
5833
+ * under the channel lock and RE-READS the record inside it (a row earlier in
5834
+ * the same pull may have resolved it).
5835
+ *
5836
+ * Device-wide pulls arrive in `seq` order, and C1c puts every epoch-N row
5837
+ * before the commit that moves to N+1, so the first row for this channel past
5838
+ * epoch N is decisive:
5839
+ * 1. A row of the record's group at or below `prior.epoch` (an epoch-N app
5840
+ * row, an old duplicate) → `normal`: decrypt at MAIN and ack as usual.
5841
+ * Nothing at or below N can move MAIN.
5842
+ * 2. A COMMIT at `commitEpoch` in the record's group is someone else's (the
5843
+ * server never fans a commit back to its sender). If it applies at MAIN,
5844
+ * that proves ours was lost: clear the record, THEN save MAIN (a crash
5845
+ * between them redelivers the commit), hook `not_landed`.
5846
+ * 3. A row framed at `commitEpoch` (an app row at `commitEpoch`, or a commit
5847
+ * at `commitEpoch + 1`): trial it on a throwaway manager imported from
5848
+ * `rec.state`. If it applies, the group is provably on OUR epoch (a
5849
+ * cryptographic proof, stronger than a 2xx): promote with the UNLOCKED
5850
+ * promotion inside this held lock (LOW-4: the queue is not reentrant),
5851
+ * then process the row normally.
5852
+ * 4. Anything else of the record's group (fits neither state, or is past
5853
+ * both) → `leave`: un-acked, no nack, no error count, no dead-letter.
5854
+ *
5855
+ * LOW-5: cases 2 and 3 key on `row.group_id === rec.groupId`. A supersede
5856
+ * record targets the NEW group and a bootstrap record has no MAIN, so no row
5857
+ * of ANOTHER group ever resolves either; case 2 needs MAIN in the record's
5858
+ * group, so it applies to add and remove records only, and a supersede or
5859
+ * bootstrap record resolves by case 3 or by resend. Rows of another group
5860
+ * (the supersede's old group, a retired group) take the `normal` paths:
5861
+ * `saveMainGuarded` keeps any of them from moving MAIN past the record.
5862
+ *
5863
+ * Throws propagate to the caller, which leaves the row un-acked and
5864
+ * uncounted (a failed promotion included).
5865
+ */
5866
+ async resolveRowAgainstPending(channelId, row, ctx) {
5867
+ return this.locks.run(channelId, async () => {
5868
+ let rec;
5869
+ try {
5870
+ rec = await this.o.store.loadPendingCommit(channelId);
5871
+ } catch (e) {
5872
+ if (row.message_type === "application")
5873
+ return { kind: "normal" };
5874
+ throw e;
5875
+ }
5876
+ if (!rec) {
5877
+ return { kind: "normal" };
5878
+ }
5879
+ if (row.group_id !== rec.groupId)
5880
+ return { kind: "normal" };
5881
+ if (isPendingReservation(rec))
5882
+ return { kind: "leave" };
5883
+ const rowEpoch = typeof row.epoch === "number" ? row.epoch : void 0;
5884
+ if (rowEpoch === void 0)
5885
+ return { kind: "leave" };
5886
+ const mainInRecGroup = rec.prior !== null && rec.prior.groupId === rec.groupId;
5887
+ if (row.message_type === "commit" && rowEpoch === rec.commitEpoch) {
5888
+ if (!mainInRecGroup)
5889
+ return { kind: "leave" };
5890
+ const mgr = await this.manager(channelId);
5891
+ const at = tagOf(mgr);
5892
+ if (at.groupId !== rec.prior.groupId || at.epoch !== rec.prior.epoch)
5893
+ return { kind: "leave" };
5894
+ try {
5895
+ await mgr.decrypt(hexToBytes(row.payload));
5896
+ } catch {
5897
+ return { kind: "leave" };
5898
+ }
5899
+ if (!await this.o.store.clearPendingCommit(channelId, pendingCommitKey(rec))) {
5900
+ this.managers.delete(channelId);
5901
+ return { kind: "leave" };
5902
+ }
5903
+ await this.saveAdvancedOrEvict(channelId, mgr);
5904
+ this.recordDecryptSuccess(channelId);
5905
+ if (ctx)
5906
+ ctx.liveCommitEpoch = Number(mgr.epoch);
5907
+ await this.notifyPendingResolved(channelId, { op: rec.op, outcome: "not_landed", via: "resolver" });
5908
+ return { kind: "acked" };
5909
+ }
5910
+ const framedAtOurs = row.message_type === "application" && rowEpoch === rec.commitEpoch || row.message_type === "commit" && rowEpoch === rec.commitEpoch + 1;
5911
+ if (framedAtOurs) {
5912
+ const trial = new MLSGroupManager2(rec.state.ciphersuite);
5913
+ trial.importState(rec.state);
5914
+ let applies;
5915
+ if (row.message_type === "application") {
5916
+ applies = await this.trialOpenApplication(row, trial);
5917
+ } else {
5918
+ try {
5919
+ await trial.decrypt(hexToBytes(row.payload));
5920
+ applies = true;
5921
+ } catch {
5922
+ applies = false;
5923
+ }
5924
+ }
5925
+ if (!applies)
5926
+ return { kind: "leave" };
5927
+ const p = await this.promotePendingUnlocked(channelId, "resolver", rec);
5928
+ if (p !== "landed")
5929
+ return { kind: "normal" };
5930
+ if (row.message_type === "application") {
5931
+ return { kind: "entry", entry: await this.decryptApplicationRow(row, { lockHeld: true }) };
5932
+ }
5933
+ const mgr = await this.manager(channelId);
5934
+ await mgr.decrypt(hexToBytes(row.payload));
5935
+ await this.saveAdvancedOrEvict(channelId, mgr);
5936
+ this.recordDecryptSuccess(channelId);
5937
+ if (ctx)
5938
+ ctx.liveCommitEpoch = Number(mgr.epoch);
5939
+ return { kind: "acked" };
5940
+ }
5941
+ if (mainInRecGroup && rowEpoch <= rec.prior.epoch)
5942
+ return { kind: "normal" };
5943
+ return { kind: "leave" };
5944
+ });
5945
+ }
5946
+ /** C2 case 3: would this application row open under `mgr`'s exporter? A
5947
+ * pure trial: no custody, no success record, no ack. The SDK-5 binding
5948
+ * (envelope channel == server-attributed channel) is checked first. */
5949
+ async trialOpenApplication(row, mgr) {
5950
+ try {
5951
+ const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
5952
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id)
5953
+ return false;
5954
+ const key = mgr.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32);
5955
+ const aad = await buildAssociatedData({
5956
+ message_id: envelope.message_id,
5957
+ channel_id: envelope.channel_id,
5958
+ sender_id: envelope.sender_id,
5959
+ mls_group_epoch: envelope.mls_group_epoch,
5960
+ replay_nonce: envelope.replay_nonce,
5961
+ policy_binding: envelope.policy_binding
5962
+ });
5963
+ openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key: await key, associatedData: aad, suite: this.suite });
5964
+ return true;
5965
+ } catch {
5966
+ return false;
5967
+ }
5968
+ }
5969
+ /**
5970
+ * Decrypt one application delivery row into an {@link InboxEntry} and take
5971
+ * {@link custody} of it. Throws on any parse / binding / AEAD failure; the
5972
+ * caller decides what a failure means.
5973
+ *
5974
+ * `lockHeld`: the committer 409 drain and the own-commit drain run INSIDE the
5975
+ * channel lock already (the queue is not reentrant), so they derive the key
5976
+ * without re-taking it. The SDK-5 binding check below guarantees
5977
+ * envelope.channel_id equals the row's server-attributed channel, which is
5978
+ * the channel whose lock is held.
5979
+ *
5980
+ * `keyFrom`: derive the key from THIS manager instead of the cached live one.
5981
+ * Only {@link custodyAppRowsBeforeOwnCommit} passes it: a transient manager
5982
+ * rebuilt from the store's committed (pre-commit, epoch N) state while the
5983
+ * cached manager is already advanced to N+1.
5984
+ */
5985
+ async decryptApplicationRow(row, opts) {
5986
+ const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
5987
+ if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
5988
+ throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
5989
+ }
5990
+ const keyFrom = opts.keyFrom;
5991
+ 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);
5992
+ const key = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
5993
+ const aad = await buildAssociatedData({
5994
+ message_id: envelope.message_id,
5995
+ channel_id: envelope.channel_id,
5996
+ sender_id: envelope.sender_id,
5997
+ mls_group_epoch: envelope.mls_group_epoch,
5998
+ replay_nonce: envelope.replay_nonce,
5999
+ policy_binding: envelope.policy_binding
6000
+ });
6001
+ const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
6002
+ const appChannelId = row.connect_channel_id ?? envelope.channel_id;
6003
+ this.recordDecryptSuccess(appChannelId);
6004
+ const entry = {
6005
+ queueId: row.queue_id,
6006
+ messageId: envelope.message_id,
6007
+ channelId: appChannelId,
6008
+ plaintext,
6009
+ envelope,
6010
+ receivedAt: Date.now(),
6011
+ retryCount: 0,
6012
+ expires_at: row.expires_at,
6013
+ voiceMode: row.voice_mode ?? void 0,
6014
+ voiceDefaultResponder: row.voice_default_responder ?? void 0
6015
+ };
6016
+ this.custody.set(row.queue_id, entry);
6017
+ return entry;
6018
+ }
4739
6019
  /**
4740
6020
  * Channel-scoped, UNLOCKED commit drain for committer conflict-recovery.
4741
6021
  *
@@ -4745,22 +6025,39 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4745
6025
  * (which would re-acquire the same channel lock for that channel's commit rows
4746
6026
  * and self-deadlock — the queue is not reentrant). This helper does the commit
4747
6027
  * half of receiveOnce for ONLY the committer's own channel, without taking the
4748
- * lock (the caller already holds it). Application rows are left untouched — the
4749
- * committer only needs to converge its handshake epoch — and welcome rows are
4750
- * left for the join path, exactly as receiveOnce leaves them.
6028
+ * lock (the caller already holds it). Welcome rows are left for the join path,
6029
+ * exactly as receiveOnce leaves them. Rows of OTHER channels are untouched:
6030
+ * this drain never moves their epoch, so they stay decryptable.
6031
+ *
6032
+ * C1: application rows of THIS channel are decrypted in seq order, before
6033
+ * any commit behind them applies, which puts them in {@link custody} (never
6034
+ * acked here); the next receiveOnce serves them. Walking past them, as this
6035
+ * drain used to, then applying the commit behind them destroyed their key:
6036
+ * the redelivered row failed AEAD and was acked as permanentlySkipped. A row
6037
+ * that fails to decrypt here is left exactly as before (un-acked, not
6038
+ * counted), for receiveOnce to classify.
4751
6039
  */
4752
6040
  async drainChannelCommitsUnlocked(deviceId, channelId) {
4753
6041
  const rows = await this.o.backend.pullDelivery(deviceId);
4754
6042
  const acked = [];
4755
6043
  for (const row of rows) {
4756
- if (row.message_type !== "commit")
4757
- continue;
4758
6044
  if (row.connect_channel_id !== channelId)
4759
6045
  continue;
6046
+ if (row.message_type === "application") {
6047
+ if (this.custody.has(row.queue_id))
6048
+ continue;
6049
+ try {
6050
+ await this.decryptApplicationRow(row, { lockHeld: true });
6051
+ } catch {
6052
+ }
6053
+ continue;
6054
+ }
6055
+ if (row.message_type !== "commit")
6056
+ continue;
4760
6057
  try {
4761
6058
  const mgr = await this.manager(channelId);
4762
6059
  await mgr.decrypt(hexToBytes(row.payload));
4763
- await this.o.store.save(channelId, mgr.exportState());
6060
+ await this.saveAdvancedOrEvict(channelId, mgr);
4764
6061
  this.recordDecryptSuccess(channelId);
4765
6062
  acked.push(row.queue_id);
4766
6063
  } catch (err) {
@@ -4770,6 +6067,60 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
4770
6067
  if (acked.length)
4771
6068
  await this.o.backend.ackDelivery(deviceId, acked);
4772
6069
  }
6070
+ /**
6071
+ * C1b: take custody of this channel's still-queued application rows at the
6072
+ * COMMITTED epoch, before a committer's OWN commit is persisted locally.
6073
+ *
6074
+ * The message key comes from the live exporter secret only. A committer that
6075
+ * persists its own commit N->N+1 while a peer's epoch-N application row sits
6076
+ * un-pulled in its queue can never decrypt that row again: its next
6077
+ * receiveOnce fails AEAD, classifies it permanentlySkipped and acks it.
6078
+ *
6079
+ * ORDERING (the committer-safety rule, B11): called only AFTER distribute has
6080
+ * succeeded and BEFORE the local save/promote, still inside the channel lock.
6081
+ * - After distribute: the server's group epoch is N+1, and the send path
6082
+ * rejects any application message claiming epoch < N+1
6083
+ * (validate_application_epoch, connect_messages.py step 6). So the set of
6084
+ * epoch-N rows is closed; one pull sees all of them (residual: a send whose
6085
+ * request read epoch N before our commit landed but commits after this pull;
6086
+ * see the C1 report).
6087
+ * - It is NOT a receiveOnce and applies NO commit: it only reads the queue
6088
+ * and derives exporter keys. The in-flight commit is already accepted, so
6089
+ * nothing here can race it or move the tree between resolve and
6090
+ * distribute; the cached (advanced) manager is never touched.
6091
+ * - The key comes from a TRANSIENT manager rebuilt from the store's MAIN
6092
+ * slot, which still holds the pre-commit epoch-N state (add: saved only
6093
+ * after this; removes: load() never returns staging). It is dropped when
6094
+ * this returns; no epoch-N secret is retained beyond the state that is
6095
+ * already at rest until the save/promote below.
6096
+ *
6097
+ * Best effort: a failed pull is retried once, then the commit is persisted
6098
+ * anyway (not persisting an accepted commit would diverge this device). Rows
6099
+ * that do not decrypt at N are left untouched for receiveOnce.
6100
+ */
6101
+ async custodyAppRowsBeforeOwnCommit(channelId, committedEpoch) {
6102
+ try {
6103
+ const saved = await this.o.store.load(channelId);
6104
+ if (!saved)
6105
+ return;
6106
+ const atCommitted = new MLSGroupManager2(saved.ciphersuite);
6107
+ atCommitted.importState(saved);
6108
+ if (Number(atCommitted.epoch) !== committedEpoch)
6109
+ return;
6110
+ let rows;
6111
+ for (let attempt = 0; attempt < 2 && !rows; attempt++) {
6112
+ rows = await this.o.backend.pullDelivery(this.o.deviceId).catch(() => void 0);
6113
+ }
6114
+ for (const row of rows ?? []) {
6115
+ if (row.message_type !== "application" || row.connect_channel_id !== channelId)
6116
+ continue;
6117
+ if (this.custody.has(row.queue_id))
6118
+ continue;
6119
+ await this.decryptApplicationRow(row, { lockHeld: true, keyFrom: atCommitted }).catch(() => void 0);
6120
+ }
6121
+ } catch {
6122
+ }
6123
+ }
4773
6124
  /** Delegate so the receive pump can ack without reaching the private backend. */
4774
6125
  async ackDelivery(deviceId, queueIds) {
4775
6126
  await this.o.backend.ackDelivery(deviceId, queueIds);
@@ -5293,6 +6644,12 @@ function describeType(v) {
5293
6644
  return "array";
5294
6645
  return typeof v;
5295
6646
  }
6647
+ function isAbsence404(err) {
6648
+ return err instanceof MlsBackendHttpError && err.status === 404;
6649
+ }
6650
+ function failureFields(err) {
6651
+ return err instanceof MlsBackendHttpError ? { status: err.status } : {};
6652
+ }
5296
6653
  function parseGroupRoster(endpoint, channelId, value) {
5297
6654
  if (!Array.isArray(value)) {
5298
6655
  throw new MalformedGroupRosterError(endpoint, channelId, describeType(value));
@@ -5328,7 +6685,7 @@ var RestMlsBackend = class {
5328
6685
  async get(path) {
5329
6686
  const res = await this.f(this.opts.baseUrl + path, { headers: this.headers() });
5330
6687
  if (!res.ok)
5331
- throw new Error(`${path} failed: ${res.status}`);
6688
+ throw new MlsBackendHttpError(path, res.status);
5332
6689
  return await res.json();
5333
6690
  }
5334
6691
  async getRegisterChallenge() {
@@ -5383,25 +6740,68 @@ var RestMlsBackend = class {
5383
6740
  member_device_ids: parseGroupRoster("init", channelId, j.member_device_ids),
5384
6741
  // Back-compat: servers that predate the created field return nothing;
5385
6742
  // treat absence as true (they always created a fresh group).
5386
- created: j.created === void 0 ? true : Boolean(j.created)
6743
+ created: j.created === void 0 ? true : Boolean(j.created),
6744
+ // C2 T3b: passed through ONLY as a real boolean. Absent (a backend that
6745
+ // predates the field) or any other type stays undefined, which the
6746
+ // provider reads as ATTESTED (fail closed). Never coerced: `Boolean("false")`
6747
+ // would read a malformed value as attested-true, and a default of `false`
6748
+ // would read a missing one as adoptable.
6749
+ ...typeof j.reset_attested === "boolean" ? { reset_attested: j.reset_attested } : {}
5387
6750
  };
5388
6751
  }
5389
6752
  async reinitConnectChannelGroup() {
5390
6753
  throw new Error("reinitConnectChannelGroup was removed (it minted unattested MLS groups that orphaned channel members permanently). Use resetReinitConnectChannelGroup with an owner attestation.");
5391
6754
  }
6755
+ /**
6756
+ * C2 T3e (item 1): does its own fetch, mirroring {@link distribute}, so a
6757
+ * non-2xx becomes a typed {@link MlsBackendHttpError} instead of a plain
6758
+ * `Error` whose status lives only in a `failed: <status>` message. Before
6759
+ * this, `resetSessionSupersede`'s `isDefiniteReinitRefusal` classified the
6760
+ * outcome by regex-parsing that text, which is safe only as long as
6761
+ * neither `post()` nor the web's `makeAuthedFetch` ever retries this call —
6762
+ * a retry would replay the request and the classifier has no way to know
6763
+ * that happened. Typing the error lets the classifier read `instanceof
6764
+ * MlsBackendHttpError` + `.status` directly, matching every other C2
6765
+ * definite-refusal site (`isSupersededRefusal`, `isEpochConflict`). The
6766
+ * message is `<path> failed: <status>` — no body text (ids stay out of
6767
+ * logs) — which is SHORTER than what `post()` used to throw (that included
6768
+ * the raw response body). PR #681 review: the web's `friendlyResetError`
6769
+ * used to substring-match `surviving_device_exists` /
6770
+ * `reset_member_set_incomplete` / `bad_reset_attestation` out of that body
6771
+ * text; it now reads them off this error's typed `.code` field instead
6772
+ * (with the old substring match kept as a fallback for other error shapes).
6773
+ */
5392
6774
  async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
5393
6775
  const committer = memberDeviceIds[0];
5394
6776
  if (!committer)
5395
6777
  throw new Error("resetReinitConnectChannelGroup: empty member set");
5396
- const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
5397
- member_device_ids: memberDeviceIds,
5398
- committer_device_id: committer,
5399
- mode: "reset",
5400
- new_group_id: att.newGroupId,
5401
- reset_attestation_signature: att.signatureHex,
5402
- reset_attestation_nonce: att.nonceHex,
5403
- reset_attestation_content: att.signedContentHex
6778
+ const path = `/api/v1/mls/connect-channels/${channelId}/reinit`;
6779
+ const res = await this.f(this.opts.baseUrl + path, {
6780
+ method: "POST",
6781
+ headers: this.headers(),
6782
+ body: JSON.stringify({
6783
+ member_device_ids: memberDeviceIds,
6784
+ committer_device_id: committer,
6785
+ mode: "reset",
6786
+ new_group_id: att.newGroupId,
6787
+ reset_attestation_signature: att.signatureHex,
6788
+ reset_attestation_nonce: att.nonceHex,
6789
+ reset_attestation_content: att.signedContentHex
6790
+ })
5404
6791
  });
6792
+ if (!res.ok) {
6793
+ let code;
6794
+ let reason;
6795
+ try {
6796
+ const body = JSON.parse(await res.text());
6797
+ code = parseErrorCode(body);
6798
+ const r = body !== null && typeof body === "object" ? body.reason : void 0;
6799
+ reason = typeof r === "string" ? r : void 0;
6800
+ } catch {
6801
+ }
6802
+ throw new MlsBackendHttpError(path, res.status, code, reason);
6803
+ }
6804
+ const j = res.status === 204 ? {} : await res.json();
5405
6805
  return {
5406
6806
  group_id: String(j.group_id),
5407
6807
  current_epoch: Number(j.current_epoch),
@@ -5424,7 +6824,10 @@ var RestMlsBackend = class {
5424
6824
  try {
5425
6825
  const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
5426
6826
  return String(j.key_package);
5427
- } catch {
6827
+ } catch (err) {
6828
+ if (!isAbsence404(err)) {
6829
+ writeServiceLog2("warn", "keypackage.by_id_fetch_failed", failureFields(err), err);
6830
+ }
5428
6831
  return null;
5429
6832
  }
5430
6833
  }
@@ -5440,15 +6843,44 @@ var RestMlsBackend = class {
5440
6843
  if (Number.isNaN(ms))
5441
6844
  return null;
5442
6845
  return new Date(ms);
5443
- } catch {
6846
+ } catch (err) {
6847
+ if (!isAbsence404(err)) {
6848
+ writeServiceLog2("warn", "keypackage.expiry_fetch_failed", failureFields(err), err);
6849
+ }
5444
6850
  return null;
5445
6851
  }
5446
6852
  }
6853
+ /**
6854
+ * C2 T3 (spec §4.3): `/distribute` does its own fetch so a non-2xx becomes a
6855
+ * typed {@link MlsBackendHttpError} carrying the body's error `code` (and a
6856
+ * 409's `reason`), which the pending-commit outcome classifier reads. `post()`
6857
+ * is left alone: its other callers keep their current plain-`Error` shape.
6858
+ *
6859
+ * The message is `<path> failed: <status>` with NO body text (ids stay out of
6860
+ * logs; `friendlyResetError` matches the status substring). A network failure
6861
+ * or abort propagates as the fetch threw it, which the classifier reads as
6862
+ * ambiguous. A 2xx resolves whatever its body is (`replayed: true` included):
6863
+ * 2xx means landed, and the SDK never reads the success body.
6864
+ */
5447
6865
  async distribute(groupId, req) {
5448
- await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
5449
- ...req,
5450
- group_id: groupId
6866
+ const path = `/api/v1/mls/groups/${groupId}/distribute`;
6867
+ const res = await this.f(this.opts.baseUrl + path, {
6868
+ method: "POST",
6869
+ headers: this.headers(),
6870
+ body: JSON.stringify({ ...req, group_id: groupId })
5451
6871
  });
6872
+ if (res.ok)
6873
+ return;
6874
+ let code;
6875
+ let reason;
6876
+ try {
6877
+ const body = JSON.parse(await res.text());
6878
+ code = parseErrorCode(body);
6879
+ const r = body !== null && typeof body === "object" ? body.reason : void 0;
6880
+ reason = typeof r === "string" ? r : void 0;
6881
+ } catch {
6882
+ }
6883
+ throw new MlsBackendHttpError(path, res.status, code, reason);
5452
6884
  }
5453
6885
  async pullDelivery(deviceId) {
5454
6886
  const j = await this.get(`/api/v1/mls/delivery?device_id=${deviceId}`);
@@ -5500,7 +6932,8 @@ var RestMlsBackend = class {
5500
6932
  return String(j.token);
5501
6933
  }
5502
6934
  async releaseCommitLease(channelId, token) {
5503
- 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(() => {
6935
+ 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) => {
6936
+ writeServiceLog2("warn", "committer.lease_release_failed", {}, err);
5504
6937
  });
5505
6938
  }
5506
6939
  async fetchResetAttestation(groupId) {
@@ -5511,7 +6944,10 @@ var RestMlsBackend = class {
5511
6944
  signatureHex: String(j.signature_hex),
5512
6945
  signerIdentityId: String(j.signer_identity_id)
5513
6946
  };
5514
- } catch {
6947
+ } catch (err) {
6948
+ if (!isAbsence404(err)) {
6949
+ writeServiceLog2("warn", "supersede.attestation_fetch_failed", failureFields(err), err);
6950
+ }
5515
6951
  return null;
5516
6952
  }
5517
6953
  }
@@ -5519,6 +6955,7 @@ var RestMlsBackend = class {
5519
6955
 
5520
6956
  // ../connect-sdk-ts/dist/mls/fake-backend.js
5521
6957
  import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
6958
+ var DELIVERY_PULL_LIMIT = 100;
5522
6959
  var FakeConnectBackend = class _FakeConnectBackend {
5523
6960
  // Key-package store: deviceId → hex-encoded KeyPackage
5524
6961
  kps = /* @__PURE__ */ new Map();
@@ -5649,7 +7086,10 @@ var FakeConnectBackend = class _FakeConnectBackend {
5649
7086
  // Real semantics: created=true on first call (group was just created),
5650
7087
  // created=false on idempotent return (group already existed). Without
5651
7088
  // this, race tests silently pass wrong (fake is laxer than the server).
5652
- created: !alreadyExists
7089
+ created: !alreadyExists,
7090
+ // C2 T3b: mirrors T1b's `reset_attested` (the group carries an owner
7091
+ // reset attestation). Always present, as T1b's field has no default.
7092
+ reset_attested: this.resetAttestations.has(groupId)
5653
7093
  };
5654
7094
  }
5655
7095
  async reinitConnectChannelGroup(channelId, memberDeviceIds) {
@@ -5717,16 +7157,58 @@ var FakeConnectBackend = class _FakeConnectBackend {
5717
7157
  }))
5718
7158
  };
5719
7159
  }
7160
+ /** C2 T3: every `/distribute` request, in order, including resends and
7161
+ * injected failures, so a test can assert exactly what was sent. */
7162
+ distributeCalls = [];
7163
+ /** C2 T3: one fault per upcoming `distribute` call, FIFO. See
7164
+ * {@link failNextDistribute}. */
7165
+ distributeFaults = [];
7166
+ /**
7167
+ * C2 T3 test seam: make the NEXT `distribute` call fail once.
7168
+ * - `{status, code?, reason?}`: a typed `MlsBackendHttpError`, exactly what
7169
+ * `RestMlsBackend.distribute` throws for that response body.
7170
+ * - `{network: true}`: a `TypeError`, what `fetch` throws on a network
7171
+ * failure.
7172
+ * - `afterApply: true` on either: the commit is APPLIED first (it landed)
7173
+ * and the failure is what the client sees, i.e. a lost 2xx.
7174
+ * Call it several times to queue several faults.
7175
+ */
7176
+ failNextDistribute(fault) {
7177
+ this.distributeFaults.push(fault);
7178
+ }
7179
+ static distributePath(groupId) {
7180
+ return `/api/v1/mls/groups/${groupId}/distribute`;
7181
+ }
5720
7182
  async distribute(groupId, req) {
7183
+ this.distributeCalls.push({ groupId, req: structuredClone(req) });
7184
+ const fault = this.distributeFaults.shift();
7185
+ if (fault && !fault.afterApply)
7186
+ throw _FakeConnectBackend.faultError(groupId, fault);
7187
+ await this.distributeApply(groupId, req);
7188
+ if (fault)
7189
+ throw _FakeConnectBackend.faultError(groupId, fault);
7190
+ }
7191
+ static faultError(groupId, fault) {
7192
+ if ("network" in fault)
7193
+ return new TypeError("fetch failed");
7194
+ return new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), fault.status, fault.code, fault.reason);
7195
+ }
7196
+ async distributeApply(groupId, req) {
5721
7197
  const channelId = this.channelByGroup.get(groupId);
5722
7198
  if (!channelId)
5723
7199
  throw new Error(`FakeConnectBackend.distribute: unknown group ${groupId}`);
7200
+ if (req.commit) {
7201
+ 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));
7202
+ if (hit)
7203
+ return;
7204
+ }
7205
+ if (req.commit && this.groupIdByChannel.get(channelId) !== groupId) {
7206
+ throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "superseded");
7207
+ }
5724
7208
  if (req.commit && req.expected_epoch != null) {
5725
7209
  const current = this.epochByChannel.get(channelId) ?? 0;
5726
7210
  if (req.expected_epoch !== current) {
5727
- const e = new Error(`connect backend post failed: 409 EPOCH_CONFLICT (expected ${req.expected_epoch}, current ${current})`);
5728
- e.status = 409;
5729
- throw e;
7211
+ throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
5730
7212
  }
5731
7213
  }
5732
7214
  if (req.commit) {
@@ -5789,7 +7271,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
5789
7271
  }
5790
7272
  }
5791
7273
  async pullDelivery(deviceId) {
5792
- return this.queues.get(deviceId) ?? [];
7274
+ return (this.queues.get(deviceId) ?? []).slice(0, DELIVERY_PULL_LIMIT);
5793
7275
  }
5794
7276
  async ackDelivery(deviceId, queueIds) {
5795
7277
  const queue = this.queues.get(deviceId);
@@ -5972,8 +7454,10 @@ import { DatabaseSync } from "node:sqlite";
5972
7454
  import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
5973
7455
 
5974
7456
  // ../connect-mls-store/dist/schema.js
5975
- var SCHEMA_VERSION = 7;
7457
+ var SCHEMA_VERSION = 9;
7458
+ var BUSY_TIMEOUT_MS = 5e3;
5976
7459
  function openSchema(db) {
7460
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
5977
7461
  db.exec("PRAGMA journal_mode=WAL");
5978
7462
  const row = db.prepare("PRAGMA user_version").get();
5979
7463
  if (row.user_version === 0) {
@@ -6075,8 +7559,58 @@ function openSchema(db) {
6075
7559
  );
6076
7560
  `);
6077
7561
  }
7562
+ if (row.user_version < 8) {
7563
+ db.exec(`
7564
+ CREATE TABLE IF NOT EXISTS runtime_inbox (
7565
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
7566
+ identity_id TEXT NOT NULL,
7567
+ message_id TEXT NOT NULL,
7568
+ sealed_message BLOB NOT NULL,
7569
+ received_at INTEGER NOT NULL,
7570
+ UNIQUE (identity_id, message_id)
7571
+ );
7572
+ `);
7573
+ db.exec(`
7574
+ CREATE TABLE IF NOT EXISTS runtime_reply_receipt (
7575
+ identity_id TEXT NOT NULL,
7576
+ in_reply_to TEXT NOT NULL,
7577
+ channel_id TEXT NOT NULL,
7578
+ reply_message_id TEXT NOT NULL,
7579
+ replied_at INTEGER NOT NULL,
7580
+ PRIMARY KEY (identity_id, in_reply_to)
7581
+ );
7582
+ `);
7583
+ }
7584
+ if (row.user_version < 9) {
7585
+ db.exec(`
7586
+ CREATE TABLE IF NOT EXISTS mls_pending_commit (
7587
+ identity_id TEXT NOT NULL,
7588
+ channel_id TEXT NOT NULL,
7589
+ sealed_state BLOB NOT NULL,
7590
+ sealed_request BLOB NOT NULL,
7591
+ created_at INTEGER NOT NULL,
7592
+ PRIMARY KEY (identity_id, channel_id)
7593
+ );
7594
+ `);
7595
+ db.exec(`
7596
+ CREATE TABLE IF NOT EXISTS mls_owed_followup (
7597
+ identity_id TEXT NOT NULL,
7598
+ channel_id TEXT NOT NULL,
7599
+ sealed_followup BLOB NOT NULL,
7600
+ created_at INTEGER NOT NULL,
7601
+ PRIMARY KEY (identity_id, channel_id)
7602
+ );
7603
+ `);
7604
+ }
7605
+ ensureRuntimeInboxDeliveryAttempts(db);
6078
7606
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
6079
7607
  }
7608
+ function ensureRuntimeInboxDeliveryAttempts(db) {
7609
+ const cols = db.prepare("PRAGMA table_info(runtime_inbox)").all();
7610
+ if (cols.length === 0 || cols.some((c) => c.name === "delivery_attempts"))
7611
+ return;
7612
+ db.exec("ALTER TABLE runtime_inbox ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0");
7613
+ }
6080
7614
 
6081
7615
  // ../connect-mls-store/dist/errors.js
6082
7616
  var MlsStoreCorruptError = class extends Error {
@@ -6114,10 +7648,24 @@ function decodeBundle(bytes) {
6114
7648
  }
6115
7649
  };
6116
7650
  }
7651
+ function sameRow(a, b) {
7652
+ if (!a || !b)
7653
+ return a === b;
7654
+ 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);
7655
+ }
7656
+ function sameBytes(a, b) {
7657
+ if (!a || !b)
7658
+ return a === b;
7659
+ return Buffer.compare(a, b) === 0;
7660
+ }
7661
+ function sameTag(prior, tag) {
7662
+ return prior !== null && prior.groupId === tag.groupId && prior.epoch === tag.epoch;
7663
+ }
6117
7664
  var DAY_MS = 24 * 60 * 60 * 1e3;
6118
7665
  var DEFAULT_MEMORY_RETENTION_MS = 30 * DAY_MS;
6119
7666
  var DEFAULT_MEMORY_FACT_RETENTION_K = 200;
6120
7667
  var DEFAULT_MEMORY_FACT_RETENTION_MS = 90 * DAY_MS;
7668
+ var RUNTIME_REPLY_RECEIPT_MAX = 5e3;
6121
7669
  var SqliteMlsStateStore = class {
6122
7670
  db;
6123
7671
  id;
@@ -6182,6 +7730,9 @@ var SqliteMlsStateStore = class {
6182
7730
  return this.cipher.seal(new TextEncoder().encode(JSON.stringify(state)));
6183
7731
  }
6184
7732
  async unseal(blob) {
7733
+ return this.unsealJson(blob);
7734
+ }
7735
+ async unsealJson(blob) {
6185
7736
  let bytes;
6186
7737
  try {
6187
7738
  bytes = await this.cipher.open(blob);
@@ -6251,7 +7802,7 @@ var SqliteMlsStateStore = class {
6251
7802
  try {
6252
7803
  bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
6253
7804
  } catch (e) {
6254
- console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
7805
+ writeServiceLog2("warn", "store.pending_bundle_open_failed", {}, e);
6255
7806
  return null;
6256
7807
  }
6257
7808
  this.bundles.set(ref, bundle);
@@ -6366,6 +7917,96 @@ var SqliteMlsStateStore = class {
6366
7917
  const rows = this.db.prepare("SELECT DISTINCT enclave_id FROM channel_meta WHERE identity_id=? AND enclave_id IS NOT NULL").all(this.id);
6367
7918
  return rows.map((r) => r.enclave_id);
6368
7919
  }
7920
+ /**
7921
+ * Commit one decrypted daemon→runtime frame before the backend delivery row
7922
+ * is acknowledged. The payload is sealed at rest. A redelivery is opened and
7923
+ * compared so a same-id/different-payload collision becomes a loud refusal
7924
+ * without persisting a plaintext-derived digest.
7925
+ */
7926
+ async stageRuntimeInbox(messageId, payload, receivedAt = Date.now()) {
7927
+ const prior = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
7928
+ if (prior) {
7929
+ const priorPayload = await this.openRuntimeInboxPayload(prior.sealed_message);
7930
+ if (!Buffer.from(priorPayload).equals(Buffer.from(payload))) {
7931
+ throw new Error("runtime inbox message-id payload conflict");
7932
+ }
7933
+ return false;
7934
+ }
7935
+ const sealed = await this.cipher.seal(payload);
7936
+ const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_inbox
7937
+ (identity_id, message_id, sealed_message, received_at)
7938
+ VALUES (?, ?, ?, ?)`).run(this.id, messageId, sealed, receivedAt);
7939
+ if (info.changes === 0) {
7940
+ const raced = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
7941
+ const racedPayload = await this.openRuntimeInboxPayload(raced.sealed_message);
7942
+ if (!Buffer.from(racedPayload).equals(Buffer.from(payload))) {
7943
+ throw new Error("runtime inbox message-id payload conflict");
7944
+ }
7945
+ return false;
7946
+ }
7947
+ return true;
7948
+ }
7949
+ async loadRuntimeInbox() {
7950
+ const rows = this.db.prepare(`SELECT message_id, sealed_message, received_at, delivery_attempts
7951
+ FROM runtime_inbox WHERE identity_id=? ORDER BY seq ASC`).all(this.id);
7952
+ const out = [];
7953
+ for (const row of rows) {
7954
+ const payload = await this.openRuntimeInboxPayload(row.sealed_message);
7955
+ out.push({
7956
+ messageId: row.message_id,
7957
+ payload,
7958
+ receivedAt: row.received_at,
7959
+ deliveryAttempts: Number(row.delivery_attempts)
7960
+ });
7961
+ }
7962
+ return out;
7963
+ }
7964
+ /**
7965
+ * Count one delivery of a staged runtime frame and return the new total.
7966
+ * Returns 0 when the message is not staged (already acknowledged, or never
7967
+ * durably handed off), so the caller can fall back to a process-local count.
7968
+ * Content-free: only the counter changes, never the sealed payload.
7969
+ */
7970
+ noteRuntimeInboxDelivery(messageId) {
7971
+ const row = this.db.prepare(`UPDATE runtime_inbox SET delivery_attempts = delivery_attempts + 1
7972
+ WHERE identity_id=? AND message_id=?
7973
+ RETURNING delivery_attempts`).get(this.id, messageId);
7974
+ return row ? Number(row.delivery_attempts) : 0;
7975
+ }
7976
+ async openRuntimeInboxPayload(sealed) {
7977
+ try {
7978
+ return await this.cipher.open(sealed);
7979
+ } catch (error) {
7980
+ throw new MlsStoreSealError(String(error?.message ?? error));
7981
+ }
7982
+ }
7983
+ ackRuntimeInbox(messageId) {
7984
+ return this.db.prepare("DELETE FROM runtime_inbox WHERE identity_id=? AND message_id=?").run(this.id, messageId).changes === 1;
7985
+ }
7986
+ recordRuntimeReplyReceipt(inReplyTo, channelId, replyMessageId, repliedAt = Date.now()) {
7987
+ this.db.prepare(`INSERT INTO runtime_reply_receipt
7988
+ (identity_id, in_reply_to, channel_id, reply_message_id, replied_at)
7989
+ VALUES (?, ?, ?, ?, ?)
7990
+ ON CONFLICT(identity_id, in_reply_to) DO UPDATE SET
7991
+ channel_id=excluded.channel_id,
7992
+ reply_message_id=excluded.reply_message_id,
7993
+ replied_at=excluded.replied_at`).run(this.id, inReplyTo, channelId, replyMessageId, repliedAt);
7994
+ const receiptCount = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_receipt WHERE identity_id=?").get(this.id);
7995
+ if (receiptCount.n > RUNTIME_REPLY_RECEIPT_MAX) {
7996
+ this.db.prepare(`DELETE FROM runtime_reply_receipt
7997
+ WHERE identity_id=? AND rowid IN (
7998
+ SELECT rowid FROM runtime_reply_receipt
7999
+ WHERE identity_id=?
8000
+ ORDER BY replied_at DESC, rowid DESC
8001
+ LIMIT -1 OFFSET ?
8002
+ )`).run(this.id, this.id, RUNTIME_REPLY_RECEIPT_MAX);
8003
+ }
8004
+ return { in_reply_to: inReplyTo, channel_id: channelId, reply_message_id: replyMessageId, replied_at: repliedAt };
8005
+ }
8006
+ runtimeReplyReceipt(inReplyTo) {
8007
+ return this.db.prepare(`SELECT in_reply_to, channel_id, reply_message_id, replied_at
8008
+ FROM runtime_reply_receipt WHERE identity_id=? AND in_reply_to=?`).get(this.id, inReplyTo) ?? null;
8009
+ }
6369
8010
  async recordMemory(input) {
6370
8011
  const dedupKey = input.messageId ?? input.idempotencyKey ?? null;
6371
8012
  if (!dedupKey)
@@ -6491,6 +8132,231 @@ var SqliteMlsStateStore = class {
6491
8132
  }
6492
8133
  }
6493
8134
  }
8135
+ // ── C2 pending commit (schema v9) ─────────────────────────────────────
8136
+ // One sealed record per (identity, channel) in mls_pending_commit, and the
8137
+ // owed follow-up slot of a landed commit in mls_owed_followup. The
8138
+ // reference semantics are InMemoryMlsStateStore (connect-sdk-ts
8139
+ // src/mls/store.ts); the shared conformance suite pins them.
8140
+ //
8141
+ // THE AWAIT TRAP. seal/open are async. An await inside an open
8142
+ // DatabaseSync transaction lets another async call on this connection run
8143
+ // statements inside it, and another process can write between any read
8144
+ // and BEGIN. So every guarded method reads the record bytes, does ALL of
8145
+ // its crypto, and only then runs BEGIN IMMEDIATE, re-reads the record and
8146
+ // compares BYTES with what it read before the await (a reseal of the same
8147
+ // content has new bytes, so any rewrite is seen). Mismatch → ROLLBACK and
8148
+ // retry the whole method once; a second mismatch refuses. Inside the
8149
+ // transaction there is no await, so the decision and the write are one
8150
+ // step against every writer (MEDIUM-1).
8151
+ async sealJson(v) {
8152
+ return this.cipher.seal(new TextEncoder().encode(JSON.stringify(v)));
8153
+ }
8154
+ readPendingRow(channelId) {
8155
+ 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);
8156
+ }
8157
+ async openPendingRow(row) {
8158
+ const meta = await this.unsealJson(row.sealed_request);
8159
+ const state = await this.unseal(row.sealed_state);
8160
+ const rec = {
8161
+ groupId: meta.groupId,
8162
+ op: meta.op,
8163
+ request: meta.request,
8164
+ state,
8165
+ commitEpoch: meta.commitEpoch,
8166
+ prior: meta.prior,
8167
+ createdAt: Number(row.created_at)
8168
+ };
8169
+ if (meta.followUp !== void 0)
8170
+ rec.followUp = meta.followUp;
8171
+ return rec;
8172
+ }
8173
+ /** Run `fn` inside BEGIN IMMEDIATE. `fn` is synchronous by type: nothing
8174
+ * can interleave with it. Any throw rolls back and propagates. */
8175
+ immediate(fn) {
8176
+ this.db.exec("BEGIN IMMEDIATE");
8177
+ try {
8178
+ const result = fn();
8179
+ this.db.exec("COMMIT");
8180
+ return result;
8181
+ } catch (e) {
8182
+ this.db.exec("ROLLBACK");
8183
+ throw e;
8184
+ }
8185
+ }
8186
+ async savePendingCommit(channelId, rec, opts) {
8187
+ assertPendingCommitShape(rec);
8188
+ const meta = {
8189
+ groupId: rec.groupId,
8190
+ op: rec.op,
8191
+ request: rec.request,
8192
+ commitEpoch: rec.commitEpoch,
8193
+ prior: rec.prior,
8194
+ followUp: rec.followUp
8195
+ };
8196
+ const sealedState = await this.seal(rec.state);
8197
+ const sealedRequest = await this.sealJson(meta);
8198
+ if (opts?.replacing) {
8199
+ for (let attempt = 0; attempt < 2; attempt++) {
8200
+ const row = this.readPendingRow(channelId);
8201
+ if (!row)
8202
+ return false;
8203
+ if (!matchesPendingCommitKey(await this.openPendingRow(row), opts.replacing))
8204
+ return false;
8205
+ const outcome = this.immediate(() => {
8206
+ if (!sameRow(row, this.readPendingRow(channelId)))
8207
+ return "retry";
8208
+ this.db.prepare(`UPDATE mls_pending_commit SET sealed_state=?, sealed_request=?, created_at=?
8209
+ WHERE identity_id=? AND channel_id=?`).run(sealedState, sealedRequest, rec.createdAt, this.id, channelId);
8210
+ return "written";
8211
+ });
8212
+ if (outcome === "written")
8213
+ return true;
8214
+ }
8215
+ throw new PendingCommitUnresolvedError(channelId);
8216
+ }
8217
+ const res = this.db.prepare(opts?.onlyIfAbsent ? `INSERT INTO mls_pending_commit (identity_id, channel_id, sealed_state, sealed_request, created_at)
8218
+ VALUES (?,?,?,?,?)
8219
+ ON CONFLICT(identity_id, channel_id) DO NOTHING` : `INSERT INTO mls_pending_commit (identity_id, channel_id, sealed_state, sealed_request, created_at)
8220
+ VALUES (?,?,?,?,?)
8221
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET
8222
+ sealed_state=excluded.sealed_state, sealed_request=excluded.sealed_request, created_at=excluded.created_at`).run(this.id, channelId, sealedState, sealedRequest, rec.createdAt);
8223
+ return Number(res.changes) > 0;
8224
+ }
8225
+ async loadPendingCommit(channelId) {
8226
+ const row = this.readPendingRow(channelId);
8227
+ return row ? this.openPendingRow(row) : null;
8228
+ }
8229
+ async listPendingCommits() {
8230
+ const rows = this.db.prepare(`SELECT channel_id, sealed_state, sealed_request, created_at FROM mls_pending_commit
8231
+ WHERE identity_id=? ORDER BY channel_id`).all(this.id);
8232
+ const out = [];
8233
+ for (const row of rows)
8234
+ out.push({ channelId: row.channel_id, rec: await this.openPendingRow(row) });
8235
+ return out;
8236
+ }
8237
+ readMainBytes(channelId) {
8238
+ const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
8239
+ return row?.sealed_main ?? void 0;
8240
+ }
8241
+ /**
8242
+ * C2 T3c (security review C1): promote ONLY the expected record, and only
8243
+ * while MAIN is exactly the state the caller checked against its `prior`.
8244
+ * Both checks are decided on bytes read BEFORE the await (the record opened
8245
+ * and matched on its key; MAIN unsealed and compared to `expect.main`) and
8246
+ * then re-proven INSIDE `BEGIN IMMEDIATE` by byte-comparing both rows, so
8247
+ * no writer in this or another process can slip between check and write.
8248
+ */
8249
+ async commitPending(channelId, expect) {
8250
+ for (let attempt = 0; attempt < 2; attempt++) {
8251
+ const row = this.readPendingRow(channelId);
8252
+ if (!row)
8253
+ return "record_mismatch";
8254
+ const rec = await this.openPendingRow(row);
8255
+ if (!matchesPendingCommitKey(rec, expect))
8256
+ return "record_mismatch";
8257
+ const mainBytes = this.readMainBytes(channelId);
8258
+ const main2 = mainBytes ? await this.unseal(mainBytes) : null;
8259
+ if (!sameMainState(main2, expect.main))
8260
+ return "main_moved";
8261
+ const sealedOwed = rec.followUp ? await this.sealJson({ op: rec.op, followUp: rec.followUp }) : null;
8262
+ const done = this.immediate(() => {
8263
+ if (!sameRow(row, this.readPendingRow(channelId)))
8264
+ return false;
8265
+ if (!sameBytes(mainBytes, this.readMainBytes(channelId)))
8266
+ return false;
8267
+ this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
8268
+ VALUES (?,?,?,?)
8269
+ 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());
8270
+ this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
8271
+ if (sealedOwed) {
8272
+ this.db.prepare(`INSERT INTO mls_owed_followup (identity_id, channel_id, sealed_followup, created_at)
8273
+ VALUES (?,?,?,?)
8274
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET
8275
+ sealed_followup=excluded.sealed_followup, created_at=excluded.created_at`).run(this.id, channelId, sealedOwed, rec.createdAt);
8276
+ }
8277
+ return true;
8278
+ });
8279
+ if (done)
8280
+ return "committed";
8281
+ }
8282
+ throw new PendingCommitUnresolvedError(channelId);
8283
+ }
8284
+ /** C2 T3c (C1): with `expect`, delete only the record carrying that key
8285
+ * (matched on the opened record, re-proven by byte-compare inside
8286
+ * `BEGIN IMMEDIATE`); without it, delete whatever is there (the owner's
8287
+ * abandon, which must work even when the record does not unseal). */
8288
+ async clearPendingCommit(channelId, expect) {
8289
+ if (!expect) {
8290
+ const res = this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
8291
+ return Number(res.changes) > 0;
8292
+ }
8293
+ for (let attempt = 0; attempt < 2; attempt++) {
8294
+ const row = this.readPendingRow(channelId);
8295
+ if (!row)
8296
+ return false;
8297
+ const rec = await this.openPendingRow(row);
8298
+ if (!matchesPendingCommitKey(rec, expect))
8299
+ return false;
8300
+ const outcome = this.immediate(() => {
8301
+ if (!sameRow(row, this.readPendingRow(channelId)))
8302
+ return "retry";
8303
+ this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
8304
+ return "deleted";
8305
+ });
8306
+ if (outcome === "deleted")
8307
+ return true;
8308
+ }
8309
+ throw new PendingCommitUnresolvedError(channelId);
8310
+ }
8311
+ async saveMainGuarded(channelId, state, tag) {
8312
+ let row = this.readPendingRow(channelId);
8313
+ const sealedMain = await this.seal(state);
8314
+ for (let attempt = 0; attempt < 2; attempt++) {
8315
+ if (attempt > 0)
8316
+ row = this.readPendingRow(channelId);
8317
+ const prior = row ? (await this.unsealJson(row.sealed_request)).prior : void 0;
8318
+ const outcome = this.immediate(() => {
8319
+ if (!sameRow(row, this.readPendingRow(channelId)))
8320
+ return "retry";
8321
+ if (row && !sameTag(prior ?? null, tag))
8322
+ return "refused";
8323
+ this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
8324
+ VALUES (?,?,?,?)
8325
+ 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());
8326
+ return "written";
8327
+ });
8328
+ if (outcome === "written")
8329
+ return;
8330
+ if (outcome === "refused")
8331
+ throw new PendingCommitUnresolvedError(channelId);
8332
+ }
8333
+ throw new PendingCommitUnresolvedError(channelId);
8334
+ }
8335
+ async listOwedFollowUps() {
8336
+ const rows = this.db.prepare(`SELECT channel_id, sealed_followup, created_at FROM mls_owed_followup
8337
+ WHERE identity_id=? ORDER BY channel_id`).all(this.id);
8338
+ const out = [];
8339
+ for (const row of rows) {
8340
+ const o = await this.unsealJson(row.sealed_followup);
8341
+ out.push({ channelId: row.channel_id, owed: { op: o.op, followUp: o.followUp, createdAt: Number(row.created_at) } });
8342
+ }
8343
+ return out;
8344
+ }
8345
+ /** C2 T3c (M2): one channel's slot, so another channel's unreadable slot
8346
+ * cannot fail this read. */
8347
+ async getOwedFollowUp(channelId) {
8348
+ const row = this.db.prepare("SELECT sealed_followup, created_at FROM mls_owed_followup WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
8349
+ if (!row)
8350
+ return null;
8351
+ const o = await this.unsealJson(row.sealed_followup);
8352
+ return { op: o.op, followUp: o.followUp, createdAt: Number(row.created_at) };
8353
+ }
8354
+ /** C2 T3c (M1): with `expectCreatedAt`, delete only that slot, in ONE
8355
+ * statement (a newer slot written meanwhile has another created_at). */
8356
+ async clearOwedFollowUp(channelId, expectCreatedAt) {
8357
+ 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);
8358
+ return Number(res.changes) > 0;
8359
+ }
6494
8360
  /** @internal test-only */
6495
8361
  _rawMainForTest(identityId, channelId) {
6496
8362
  const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(identityId, channelId);
@@ -6628,12 +8494,13 @@ var LatencyRecorder = class {
6628
8494
  };
6629
8495
 
6630
8496
  // src/coordination.ts
8497
+ var TRAILING_PUNCT = /[^\w]+$/;
6631
8498
  function parseMentions(text) {
6632
8499
  const mentions = [];
6633
- const re = /@(\w[\w]*)/gi;
8500
+ const re = /@(\w[\w.-]*)/g;
6634
8501
  let match;
6635
8502
  while ((match = re.exec(text)) !== null) {
6636
- mentions.push(match[1].toLowerCase());
8503
+ mentions.push(match[1].replace(TRAILING_PUNCT, "").toLowerCase());
6637
8504
  }
6638
8505
  return mentions;
6639
8506
  }
@@ -6643,21 +8510,84 @@ function ownRoomNames(agentName, accountId, roomDisplayName) {
6643
8510
  if (!n) return;
6644
8511
  const lower = n.toLowerCase();
6645
8512
  names.add(lower);
6646
- names.add(lower.split(/[\s(]/)[0]);
8513
+ const first = lower.split(/[\s(]/)[0];
8514
+ names.add(first);
8515
+ const trimmed = first.replace(TRAILING_PUNCT, "");
8516
+ if (trimmed) names.add(trimmed);
6647
8517
  };
6648
8518
  add(agentName);
6649
8519
  add(accountId);
6650
8520
  add(roomDisplayName);
6651
8521
  return names;
6652
8522
  }
8523
+ var DEFAULT_MAX_AGENT_HOPS = 3;
8524
+ function agentHopAllowed(agentHops, maxAgentHops = DEFAULT_MAX_AGENT_HOPS) {
8525
+ return agentHops < maxAgentHops;
8526
+ }
6653
8527
  function shouldProcessRoomMessage(plaintext, agentName, accountId, opts) {
6654
8528
  const mentions = parseMentions(plaintext);
8529
+ const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
8530
+ const mentionsMe = mentions.some((m) => ownNames.has(m));
8531
+ if (opts?.senderIsAgent === true) {
8532
+ if (!mentionsMe) return false;
8533
+ return agentHopAllowed(opts.agentHops ?? 1, opts.maxAgentHops);
8534
+ }
6655
8535
  if (mentions.length === 0) return true;
6656
8536
  if (mentions.includes("all") || mentions.includes("everyone")) return true;
6657
- const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
6658
- if (mentions.some((m) => ownNames.has(m))) return true;
8537
+ if (mentionsMe) return true;
6659
8538
  return false;
6660
8539
  }
8540
+ var AGENT_HOP_QUIET_MS = 10 * 6e4;
8541
+ var AgentHopTracker = class {
8542
+ constructor(maxChannels = 1e4, opts = {}) {
8543
+ this.maxChannels = maxChannels;
8544
+ this.now = opts.now ?? (() => Date.now());
8545
+ this.quietMs = opts.quietMs ?? AGENT_HOP_QUIET_MS;
8546
+ }
8547
+ hops = /* @__PURE__ */ new Map();
8548
+ now;
8549
+ quietMs;
8550
+ /** Record an inbound message; returns the consecutive agent hops including it. */
8551
+ noteInbound(channelId, senderIsAgent) {
8552
+ if (senderIsAgent !== true) {
8553
+ this.set(channelId, { count: 0, lastAgentAt: 0 });
8554
+ return 0;
8555
+ }
8556
+ return this.bump(channelId);
8557
+ }
8558
+ /**
8559
+ * Record one of this agent's own SUBSTANTIVE sends (an agent turn in the
8560
+ * channel). Callers do not report status frames, daily-limit or dead-letter
8561
+ * notices, or continuation parts of one multi-part reply.
8562
+ */
8563
+ noteOwnSend(channelId) {
8564
+ this.bump(channelId);
8565
+ }
8566
+ size() {
8567
+ return this.hops.size;
8568
+ }
8569
+ bump(channelId) {
8570
+ const now = this.now();
8571
+ const prev = this.hops.get(channelId);
8572
+ const base = prev && prev.count > 0 && now - prev.lastAgentAt < this.quietMs ? prev.count : 0;
8573
+ const count = base + 1;
8574
+ this.set(channelId, { count, lastAgentAt: now });
8575
+ return count;
8576
+ }
8577
+ set(channelId, value) {
8578
+ this.hops.delete(channelId);
8579
+ this.hops.set(channelId, value);
8580
+ if (this.hops.size > this.maxChannels) {
8581
+ const oldest = this.hops.keys().next();
8582
+ if (!oldest.done) this.hops.delete(oldest.value);
8583
+ }
8584
+ }
8585
+ };
8586
+ function isAgentTurn(receipts, frame) {
8587
+ if (frame.notice === true) return false;
8588
+ if (typeof frame.in_reply_to === "string" && receipts.runtimeReplyReceipt(frame.in_reply_to)) return false;
8589
+ return true;
8590
+ }
6661
8591
 
6662
8592
  // src/rate-limit.ts
6663
8593
  var TokenBucketFloor = class {
@@ -6721,9 +8651,6 @@ var TokenBucketFloor = class {
6721
8651
  // src/decrypt-error-log-limiter.ts
6722
8652
  var DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 6e4;
6723
8653
  var MAX_DISTINCT_PER_WINDOW = 4;
6724
- function normalizeMessage(m) {
6725
- 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>");
6726
- }
6727
8654
  var DecryptErrorLogLimiter = class {
6728
8655
  windowMs;
6729
8656
  now;
@@ -6732,63 +8659,56 @@ var DecryptErrorLogLimiter = class {
6732
8659
  constructor(opts = {}) {
6733
8660
  this.windowMs = opts.windowMs ?? DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS;
6734
8661
  this.now = opts.now ?? Date.now;
6735
- this.log = opts.log ?? ((line) => console.warn(line));
8662
+ this.log = opts.log ?? writeServiceLog;
6736
8663
  }
6737
- /**
6738
- * Record one decrypt error for `channelId`. Logs immediately if this is
6739
- * the channel's first error since its window opened (or its prior window
6740
- * had already elapsed, in which case a summary for that just-closed
6741
- * window is flushed first); otherwise the error is counted silently
6742
- * toward the window's eventual summary line.
6743
- */
6744
8664
  record(channelId, info) {
6745
8665
  const t = this.now();
8666
+ const kind = errorClass(info.error);
6746
8667
  const w = this.windows.get(channelId);
6747
8668
  if (!w) {
6748
- this.logFirst(channelId, info);
6749
- this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
8669
+ this.logFirst(info);
8670
+ this.windows.set(channelId, {
8671
+ windowStart: t,
8672
+ count: 1,
8673
+ lastEpoch: info.epoch,
8674
+ loggedClasses: /* @__PURE__ */ new Set([kind])
8675
+ });
6750
8676
  return;
6751
8677
  }
6752
8678
  if (t - w.windowStart >= this.windowMs) {
6753
- this.flushSummary(channelId, w);
6754
- this.logFirst(channelId, info);
6755
- this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
8679
+ this.flushSummary(w);
8680
+ this.logFirst(info);
8681
+ this.windows.set(channelId, {
8682
+ windowStart: t,
8683
+ count: 1,
8684
+ lastEpoch: info.epoch,
8685
+ loggedClasses: /* @__PURE__ */ new Set([kind])
8686
+ });
6756
8687
  return;
6757
8688
  }
6758
8689
  w.count += 1;
6759
8690
  w.lastEpoch = info.epoch;
6760
- w.lastMessage = info.message;
6761
- const key = normalizeMessage(info.message);
6762
- if (!w.loggedKeys.has(key) && w.loggedKeys.size < MAX_DISTINCT_PER_WINDOW) {
6763
- this.logFirst(channelId, info);
6764
- w.loggedKeys.add(key);
8691
+ if (!w.loggedClasses.has(kind) && w.loggedClasses.size < MAX_DISTINCT_PER_WINDOW) {
8692
+ this.logFirst(info);
8693
+ w.loggedClasses.add(kind);
6765
8694
  }
6766
8695
  }
6767
- /**
6768
- * Clear a channel's window (e.g. once the caller learns the channel
6769
- * cleared/healed) so its NEXT error logs immediately as a fresh "first"
6770
- * rather than being folded into a stale window's summary. There is
6771
- * currently no onDecryptSuccess-style hook from the SDK to wire this to
6772
- * automatically (see bridge-agent.ts's onDecryptError wiring comment) —
6773
- * exposed here so a future success signal (or a caller with its own
6774
- * channel-cleared signal) can reset explicitly. In its absence, the
6775
- * window simply rolls over on interval elapse, which is an accepted
6776
- * simplification (see report).
6777
- */
6778
8696
  reset(channelId) {
6779
8697
  this.windows.delete(channelId);
6780
8698
  }
6781
- logFirst(channelId, info) {
6782
- this.log(
6783
- `[bridge] decrypt error on channel ${channelId} (queue ${info.queueId ?? "?"}, epoch ${info.epoch ?? "?"}): ${info.message}`
6784
- );
8699
+ logFirst(info) {
8700
+ const fields = {};
8701
+ if (typeof info.epoch === "number" && Number.isFinite(info.epoch)) fields.epoch = info.epoch;
8702
+ this.log("warn", "decrypt.failed", fields, info.error);
6785
8703
  }
6786
- flushSummary(channelId, w) {
8704
+ flushSummary(w) {
6787
8705
  if (w.count <= 1) return;
6788
- const seconds = Math.round(this.windowMs / 1e3);
6789
- this.log(
6790
- `[bridge] decrypt errors on channel ${channelId}: ${w.count} in last ${seconds}s (epoch ${w.lastEpoch ?? "?"}, ${w.lastMessage})`
6791
- );
8706
+ const fields = {
8707
+ count: w.count,
8708
+ window_seconds: Math.round(this.windowMs / 1e3)
8709
+ };
8710
+ if (typeof w.lastEpoch === "number" && Number.isFinite(w.lastEpoch)) fields.epoch = w.lastEpoch;
8711
+ this.log("warn", "decrypt.suppressed", fields);
6792
8712
  }
6793
8713
  };
6794
8714
 
@@ -6992,8 +8912,7 @@ var HermesMemoryProvider = class {
6992
8912
  try {
6993
8913
  this.store.probeWrite();
6994
8914
  } catch (e) {
6995
- process.stderr.write(`[bridge] HERMES MEMORY WRITE-BACK NON-FUNCTIONAL (schema probe failed): ${String(e)}
6996
- `);
8915
+ writeServiceLog("error", "memory.hermes_writeback_unavailable", {}, e);
6997
8916
  }
6998
8917
  }
6999
8918
  store;
@@ -7510,9 +9429,7 @@ function parseSummarizeBody(parsed, selfIdentityId, log = () => {
7510
9429
  return null;
7511
9430
  }
7512
9431
  if (typeof body.requested_by !== "string" || body.requested_by !== transcript.host_identity_id) {
7513
- log(
7514
- `summarize_not_host: ${String(body.requested_by)} is not the host of ${body.transcript_message_id}`
7515
- );
9432
+ log("warn", "summarize.requester_not_host");
7516
9433
  return null;
7517
9434
  }
7518
9435
  return {
@@ -7567,9 +9484,8 @@ async function runSummarize(opts) {
7567
9484
  );
7568
9485
  } catch (e) {
7569
9486
  const aborted = controller.signal.aborted;
7570
- opts.log(
7571
- aborted ? `summarize_timeout after ${timeoutMs}ms for ${opts.request.transcriptMessageId}` : `summarize_failed for ${opts.request.transcriptMessageId}: ${String(e)}`
7572
- );
9487
+ if (aborted) opts.log("warn", "summarize.timeout", { timeout_ms: timeoutMs });
9488
+ else opts.log("warn", "summarize.failed", {}, e);
7573
9489
  return null;
7574
9490
  } finally {
7575
9491
  clearTimeout(timer);
@@ -7843,51 +9759,24 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
7843
9759
 
7844
9760
  // src/bridge-agent.ts
7845
9761
  import { homedir as homedir2 } from "node:os";
7846
-
7847
- // src/reply-receipts.ts
7848
- var DEFAULT_TTL_MS = 15 * 60 * 1e3;
7849
- var DEFAULT_CAPACITY2 = 256;
7850
- var RuntimeReplyReceiptLedger = class {
7851
- receipts = /* @__PURE__ */ new Map();
7852
- ttlMs;
7853
- capacity;
7854
- now;
7855
- constructor(opts = {}) {
7856
- this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
7857
- this.capacity = opts.capacity ?? DEFAULT_CAPACITY2;
7858
- this.now = opts.now ?? Date.now;
7859
- if (!Number.isFinite(this.ttlMs) || this.ttlMs <= 0) throw new Error("reply receipt ttlMs must be positive");
7860
- if (!Number.isInteger(this.capacity) || this.capacity <= 0) throw new Error("reply receipt capacity must be positive");
7861
- }
7862
- record(inReplyTo, channelId, replyMessageId) {
7863
- const receipt = Object.freeze({
7864
- in_reply_to: inReplyTo,
7865
- reply_message_id: replyMessageId,
7866
- channel_id: channelId,
7867
- replied_at: this.now()
7868
- });
7869
- this.prune(receipt.replied_at);
7870
- this.receipts.delete(inReplyTo);
7871
- this.receipts.set(inReplyTo, receipt);
7872
- while (this.receipts.size > this.capacity) {
7873
- const oldest = this.receipts.keys().next().value;
7874
- if (oldest === void 0) break;
7875
- this.receipts.delete(oldest);
7876
- }
7877
- return receipt;
7878
- }
7879
- get(inReplyTo) {
7880
- this.prune(this.now());
7881
- return this.receipts.get(inReplyTo) ?? null;
7882
- }
7883
- prune(now) {
7884
- for (const [key, receipt] of this.receipts) {
7885
- if (now - receipt.replied_at >= this.ttlMs) this.receipts.delete(key);
7886
- }
9762
+ function logPendingCommitResolved(outcome) {
9763
+ switch (outcome) {
9764
+ case "landed":
9765
+ writeServiceLog("info", "mls.pending_commit.resolved.landed");
9766
+ return;
9767
+ case "not_landed":
9768
+ writeServiceLog("info", "mls.pending_commit.resolved.not_landed");
9769
+ return;
9770
+ case "stale_prior":
9771
+ writeServiceLog("info", "mls.pending_commit.resolved.stale_prior");
9772
+ return;
9773
+ case "abandoned":
9774
+ writeServiceLog("info", "mls.pending_commit.resolved.abandoned");
9775
+ return;
9776
+ default:
9777
+ writeServiceLog("info", "mls.pending_commit.resolved.unknown");
7887
9778
  }
7888
- };
7889
-
7890
- // src/bridge-agent.ts
9779
+ }
7891
9780
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
7892
9781
  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.";
7893
9782
  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.";
@@ -7899,6 +9788,14 @@ function selectVoiceGuidance(isDm, voiceDefaultResponder) {
7899
9788
  return VOICE_MODE_GUIDANCE;
7900
9789
  }
7901
9790
  var DEFAULT_MAX_BUFFER = 1e3;
9791
+ var DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
9792
+ var REDELIVERY_BACKOFF_BASE_MS = 5e3;
9793
+ function redeliveryDelayMs(attempts) {
9794
+ return 2 ** Math.min(Math.max(attempts, 0), 16) * REDELIVERY_BACKOFF_BASE_MS;
9795
+ }
9796
+ var DEAD_LETTER_NOTICE = "I couldn't process a message. Please resend it.";
9797
+ var DEAD_LETTER_NOTICE_INTERVAL_MS = 60 * 6e4;
9798
+ var PAUSE_RECHECK_MS = 3e4;
7902
9799
  var CHANNEL_META_TTL_MS = 5 * 6e4;
7903
9800
  var CHANNEL_ROSTER_TTL_MS = 5 * 6e4;
7904
9801
  var ZERO_TRACEPARENT = "00-" + "0".repeat(32) + "-" + "0".repeat(16) + "-01";
@@ -7978,6 +9875,13 @@ var AgentPausedSendError = class extends Error {
7978
9875
  this.name = "AgentPausedSendError";
7979
9876
  }
7980
9877
  };
9878
+ var SendForbiddenError = class extends Error {
9879
+ constructor(channelId) {
9880
+ super(`send refused for channel ${channelId} (credential revoked or not a member); no retry`);
9881
+ this.channelId = channelId;
9882
+ this.name = "SendForbiddenError";
9883
+ }
9884
+ };
7981
9885
  var UnknownActionRunError = class extends Error {
7982
9886
  constructor(runId) {
7983
9887
  super(`unknown or already-settled agent action run: ${runId}`);
@@ -8008,8 +9912,13 @@ var BridgeAgent = class _BridgeAgent {
8008
9912
  // half-open + wake + auth war-cap). On by default for the daemon;
8009
9913
  // SECURECOMS_WS_HARDENED=false reverts to the legacy 4001-terminal path.
8010
9914
  hardenedReconnect: process.env.SECURECOMS_WS_HARDENED !== "false",
8011
- onWsLifecycle: (event, detail) => process.stderr.write(`[bridge] delivery-ping ${event}${detail ? ` (${detail})` : ""}
8012
- `)
9915
+ onWsLifecycle: (event) => {
9916
+ if (event === "wake") writeServiceLog("warn", "transport.delivery_ping_wake");
9917
+ else if (event === "halfopen") writeServiceLog("warn", "transport.delivery_ping_halfopen");
9918
+ else if (event === "terminal") writeServiceLog("error", "transport.delivery_ping_terminal");
9919
+ else if (event === "reconnect") writeServiceLog("warn", "transport.delivery_ping_reconnect");
9920
+ else writeServiceLog("warn", "transport.delivery_ping_lifecycle_unknown");
9921
+ }
8013
9922
  });
8014
9923
  this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
8015
9924
  this.store = new SqliteMlsStateStore({
@@ -8026,11 +9935,17 @@ var BridgeAgent = class _BridgeAgent {
8026
9935
  backend: this.backend,
8027
9936
  deviceId: cfg.deviceId,
8028
9937
  identity: new TextEncoder().encode(cfg.identityId),
9938
+ // C2 T0: required by the SDK. This host never commits (no committer
9939
+ // entry is reachable from its src/), so the hook only records the
9940
+ // outcome (see logPendingCommitResolved).
9941
+ onPendingCommitResolved: (_channelId, ev) => {
9942
+ logPendingCommitResolved(ev.outcome);
9943
+ },
8029
9944
  // ADR-0002: count + log agent-side decrypt failures. Without this hook the
8030
9945
  // daemon was 100% blind to a wedged channel (it "looked idle"); the
8031
9946
  // provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
8032
9947
  // P0b (daemon-stability): logging is routed through decryptErrorLogLimiter
8033
- // rather than a raw console.warn per failure — a wedged channel used to
9948
+ // rather than a raw diagnostic per failure — a wedged channel used to
8034
9949
  // redeliver the same undecryptable row on every pullDelivery cycle and
8035
9950
  // produced a 114 MB / 765,000-line daemon log in a single day. The
8036
9951
  // limiter still logs the first failure immediately and collapses every
@@ -8040,8 +9955,7 @@ var BridgeAgent = class _BridgeAgent {
8040
9955
  const cid = row.connect_channel_id ?? "(unmapped)";
8041
9956
  this.decryptErrorLogLimiter.record(cid, {
8042
9957
  epoch: row.epoch,
8043
- queueId: row.queue_id,
8044
- message: err instanceof Error ? err.message : String(err)
9958
+ error: err
8045
9959
  });
8046
9960
  },
8047
9961
  // P0b (daemon-stability): fired EXACTLY ONCE by the SDK (P0a) when a
@@ -8052,9 +9966,7 @@ var BridgeAgent = class _BridgeAgent {
8052
9966
  // healWedgedChannel below) — never throws back into the SDK caller.
8053
9967
  onWedged: (channelId) => {
8054
9968
  try {
8055
- console.warn(
8056
- `[bridge] channel ${channelId} quarantined after persistent decrypt failures \u2014 signalling re-Welcome`
8057
- );
9969
+ writeServiceLog("warn", "decrypt.channel_quarantined");
8058
9970
  void this.healWedgedChannel(channelId);
8059
9971
  } catch {
8060
9972
  }
@@ -8079,6 +9991,8 @@ var BridgeAgent = class _BridgeAgent {
8079
9991
  this.voiceSettingsSource = cfg.voice ? "file" : "none";
8080
9992
  this.latency = deps.latency ?? new LatencyRecorder();
8081
9993
  this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
9994
+ this.maxDeliveryAttempts = deps.maxDeliveryAttempts ?? DEFAULT_MAX_DELIVERY_ATTEMPTS;
9995
+ this.maxAgentHops = deps.maxAgentHops ?? DEFAULT_MAX_AGENT_HOPS;
8082
9996
  this.rateFloor = new TokenBucketFloor({
8083
9997
  capacity: cfg.rateLimitCapacity ?? 20,
8084
9998
  refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
@@ -8146,6 +10060,15 @@ var BridgeAgent = class _BridgeAgent {
8146
10060
  * per channel on a miss.
8147
10061
  */
8148
10062
  channelRoster = /* @__PURE__ */ new Map();
10063
+ /**
10064
+ * 2026-09-25: this agent's own display_name (`ConnectIdentity.display_name`,
10065
+ * identity-level, so the same in every channel) from the MOST RECENT roster
10066
+ * refresh that listed it. Read by the room @mention filter as its
10067
+ * `roomDisplayName`. Kept across a later failed refresh, so a transient
10068
+ * members-fetch error does not make the agent deaf to its own name; a
10069
+ * successful refresh after a rename replaces it.
10070
+ */
10071
+ lastOwnDisplayName;
8149
10072
  /**
8150
10073
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
8151
10074
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -8156,13 +10079,37 @@ var BridgeAgent = class _BridgeAgent {
8156
10079
  */
8157
10080
  channelOwner = /* @__PURE__ */ new Map();
8158
10081
  buffer = [];
10082
+ /** Message ids durably staged for the attached runtime but not yet acked. */
10083
+ runtimePendingIds = /* @__PURE__ */ new Set();
8159
10084
  maxBuffer;
10085
+ /** Phase 3 F1: deliveries so far per pending runtime message (seeded from the store). */
10086
+ deliveryAttempts = /* @__PURE__ */ new Map();
10087
+ maxDeliveryAttempts;
10088
+ /** Phase 3 F1: cumulative messages dead-lettered at the delivery cap. */
10089
+ deadLetteredMessages = 0;
10090
+ /** Final-wave I1(a): message id → earliest redelivery time (epoch ms). */
10091
+ redeliverNotBefore = /* @__PURE__ */ new Map();
10092
+ /** Final-wave I1(a): one timer poking consumers when the earliest backoff ends. */
10093
+ redeliveryTimer = null;
10094
+ redeliveryTimerAt = Number.POSITIVE_INFINITY;
10095
+ /** Final-wave I1(c): channel → when its last dead-letter notice was posted. */
10096
+ deadLetterNoticeAt = /* @__PURE__ */ new Map();
10097
+ /** Phase 3 F1: channel → when a pause was last confirmed (send 403 or probe). */
10098
+ pausedChannels = /* @__PURE__ */ new Map();
8160
10099
  /**
8161
- * Short-lived, content-free runtime reply correlations. The attached runtime
8162
- * supplies the inbound message id only when an outbound send is its reply;
8163
- * ws-server/http-server record the receipt after send() publishes.
10100
+ * Phase 3 F1: runtime messages retired by a TERMINAL reply failure reported
10101
+ * outside the /v1/ws socket that holds them in flight (HTTP /v1/send). The
10102
+ * socket consults this so it neither redelivers them nor stays busy on them.
10103
+ * Bounded; oldest forgotten first.
8164
10104
  */
8165
- runtimeReplyReceipts = new RuntimeReplyReceiptLedger();
10105
+ retiredRuntimeIds = /* @__PURE__ */ new Set();
10106
+ /** Phase 3 F1: one pending membership recheck per paused channel holding work. */
10107
+ pauseRecheckTimers = /* @__PURE__ */ new Map();
10108
+ /** Phase 3 F3: consecutive agent turns per channel. */
10109
+ agentHops = new AgentHopTracker();
10110
+ maxAgentHops;
10111
+ /** Phase 3 F3: cumulative agent-authored messages not answered (no explicit mention / hop limit). */
10112
+ agentLoopSuppressed = 0;
8166
10113
  /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
8167
10114
  droppedMessages = 0;
8168
10115
  /**
@@ -8300,7 +10247,7 @@ var BridgeAgent = class _BridgeAgent {
8300
10247
  // H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
8301
10248
  static GRACEFUL_DRAIN_MS = 5e3;
8302
10249
  /**
8303
- * P0b (daemon-stability): collapses the onDecryptError console.warn from
10250
+ * P0b (daemon-stability): collapses the onDecryptError diagnostic from
8304
10251
  * "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
8305
10252
  * most one line immediately + one summary line per 60s per channel. LOG
8306
10253
  * OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
@@ -8464,16 +10411,13 @@ var BridgeAgent = class _BridgeAgent {
8464
10411
  if (!this.injectedFastLlm) {
8465
10412
  this.fastLlm = new FastLlmClient(this.cfg.voice.fastPath, this.fetchImpl);
8466
10413
  }
8467
- process.stderr.write(
8468
- `[bridge] voice settings v${this.voiceSettingsVersion} loaded (source: ${this.voiceSettingsSource})
8469
- `
8470
- );
10414
+ writeServiceLog("info", "voice.settings_loaded", {
10415
+ version: this.voiceSettingsVersion,
10416
+ source_backend: this.voiceSettingsSource === "backend"
10417
+ });
8471
10418
  } catch (e) {
8472
10419
  if (this.voiceSettingsSource === "backend") return;
8473
- process.stderr.write(
8474
- `[bridge] voice settings unavailable (${e.message}); using ${this.voiceSettingsSource}
8475
- `
8476
- );
10420
+ writeServiceLog("warn", "voice.settings_unavailable", {}, e);
8477
10421
  }
8478
10422
  }
8479
10423
  /**
@@ -8499,23 +10443,17 @@ var BridgeAgent = class _BridgeAgent {
8499
10443
  if (!res.ok) throw new Error(`runtime-config ${res.status}`);
8500
10444
  const payload = await res.json();
8501
10445
  if (!payload) {
8502
- process.stderr.write("[bridge] runtime config not applied (empty response)\n");
10446
+ writeServiceLog("warn", "runtime_config.empty_response");
8503
10447
  return;
8504
10448
  }
8505
10449
  if (typeof payload.version !== "number") {
8506
- process.stderr.write(
8507
- `[bridge] runtime config not applied (version ${JSON.stringify(payload.version ?? null)} \u2014 invalid response)
8508
- `
8509
- );
10450
+ writeServiceLog("warn", "runtime_config.invalid_version");
8510
10451
  return;
8511
10452
  }
8512
10453
  const version = payload.version;
8513
10454
  if (!version) {
8514
10455
  writeManagedFiles(this.home, this.cfg.identityId, {});
8515
- process.stderr.write(
8516
- `[bridge] runtime config not applied (version ${JSON.stringify(payload?.version ?? null)} \u2014 no saved config)
8517
- `
8518
- );
10456
+ writeServiceLog("info", "runtime_config.not_saved");
8519
10457
  }
8520
10458
  const skillsJson = payload.skills_json ?? {};
8521
10459
  if (version) {
@@ -8548,10 +10486,10 @@ var BridgeAgent = class _BridgeAgent {
8548
10486
  }
8549
10487
  );
8550
10488
  if (result.sourcesDeleted > 0) {
8551
- process.stderr.write(
8552
- `[bridge] legacy-memory disposition applied (sources=${result.sourcesDeleted}, assigned=${result.factsAssigned})
8553
- `
8554
- );
10489
+ writeServiceLog("info", "runtime_config.legacy_memory_disposition_applied", {
10490
+ assigned: result.factsAssigned,
10491
+ sources: result.sourcesDeleted
10492
+ });
8555
10493
  }
8556
10494
  }
8557
10495
  const local = readLocalReport(this.home, this.cfg.identityId);
@@ -8590,22 +10528,17 @@ var BridgeAgent = class _BridgeAgent {
8590
10528
  body: JSON.stringify(report)
8591
10529
  });
8592
10530
  if (!posted.ok) {
8593
- process.stderr.write(
8594
- 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
8595
- ` : `[bridge] legacy-memory inventory report failed (${posted.status}); no saved runtime config was applied
8596
- `
8597
- );
10531
+ if (version) {
10532
+ writeServiceLog("warn", "runtime_config.report_failed", { status: posted.status, version });
10533
+ } else {
10534
+ writeServiceLog("warn", "runtime_config.legacy_memory_report_failed", { status: posted.status });
10535
+ }
8598
10536
  return;
8599
10537
  }
8600
- process.stderr.write(
8601
- version ? `[bridge] runtime config v${version} applied
8602
- ` : "[bridge] legacy-memory inventory reported; no saved runtime config was applied\n"
8603
- );
10538
+ if (version) writeServiceLog("info", "runtime_config.applied", { version });
10539
+ else writeServiceLog("info", "runtime_config.legacy_memory_reported");
8604
10540
  } catch (e) {
8605
- process.stderr.write(
8606
- `[bridge] runtime config refetch failed: ${e?.message ?? e}
8607
- `
8608
- );
10541
+ writeServiceLog("warn", "runtime_config.refetch_failed", {}, e);
8609
10542
  }
8610
10543
  }
8611
10544
  /**
@@ -8623,12 +10556,13 @@ var BridgeAgent = class _BridgeAgent {
8623
10556
  if (this.maxBuffer <= 0) return;
8624
10557
  const overflow = this.buffer.length - this.maxBuffer;
8625
10558
  if (overflow > 0) {
8626
- this.buffer.splice(0, overflow);
10559
+ for (const shed of this.buffer.splice(0, overflow)) this.deliveryAttempts.delete(shed.message_id);
8627
10560
  this.droppedMessages += overflow;
8628
10561
  }
8629
10562
  }
8630
10563
  async start() {
8631
10564
  this.startedAt = Date.now();
10565
+ await this.restoreRuntimeInbox();
8632
10566
  try {
8633
10567
  await this.ingestOnce();
8634
10568
  } catch {
@@ -8647,7 +10581,7 @@ var BridgeAgent = class _BridgeAgent {
8647
10581
  // See the safetyTimer/selfHealTimer comment near close(): swallow the
8648
10582
  // expected post-close store-access rejection, log anything else.
8649
10583
  void this.selfHealSweep().catch((e) => {
8650
- if (!this.closingDown) console.error("[bridge] self-heal sweep failed:", e);
10584
+ if (!this.closingDown) writeServiceLog("error", "self_heal.sweep_failed", {}, e);
8651
10585
  })
8652
10586
  ),
8653
10587
  this.sweepJitter()
@@ -8674,8 +10608,7 @@ var BridgeAgent = class _BridgeAgent {
8674
10608
  if (mapped) return mapped;
8675
10609
  if (!this.enclaveFallbackWarned.has(channelId)) {
8676
10610
  this.enclaveFallbackWarned.add(channelId);
8677
- process.stderr.write(`[bridge] send.enclave_fallback channel=${channelId} stamped=home
8678
- `);
10611
+ writeServiceLog("warn", "send.enclave_fallback");
8679
10612
  }
8680
10613
  return this.cfg.enclaveId;
8681
10614
  }
@@ -8686,7 +10619,7 @@ var BridgeAgent = class _BridgeAgent {
8686
10619
  this.joinState.set(channelId, "JOINED");
8687
10620
  return true;
8688
10621
  } catch (e) {
8689
- console.warn(`[bridge] reconcileJoin ${channelId} failed:`, e);
10622
+ writeServiceLog("warn", "join.reconcile_failed", {}, e);
8690
10623
  this.joinState.set(channelId, "PENDING");
8691
10624
  return false;
8692
10625
  }
@@ -8759,15 +10692,14 @@ var BridgeAgent = class _BridgeAgent {
8759
10692
  }
8760
10693
  this.lastWipe.set(channelId, now);
8761
10694
  } else {
8762
- console.warn(
8763
- `[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`
8764
- );
10695
+ writeServiceLog("warn", "self_heal.wipe_suppressed", {
10696
+ proven_forked: provenForked,
10697
+ remaining_seconds: Math.round((floor - (now - lastWipe)) / 1e3)
10698
+ });
8765
10699
  }
8766
10700
  this.joinState.delete(channelId);
8767
10701
  if (!repudiated && await this.provider.hasPersistedChannelState(channelId)) {
8768
- console.warn(
8769
- `[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)`
8770
- );
10702
+ writeServiceLog("warn", "self_heal.request_suppressed_state_held");
8771
10703
  return false;
8772
10704
  }
8773
10705
  const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
@@ -8776,7 +10708,7 @@ var BridgeAgent = class _BridgeAgent {
8776
10708
  await this.signalHeldKpWelcome(g.group_id);
8777
10709
  return repudiated;
8778
10710
  } catch (e) {
8779
- console.warn(`[bridge] healWedgedChannel ${channelId} failed:`, e);
10711
+ writeServiceLog("warn", "self_heal.channel_failed", {}, e);
8780
10712
  return false;
8781
10713
  }
8782
10714
  }
@@ -8803,7 +10735,8 @@ var BridgeAgent = class _BridgeAgent {
8803
10735
  this.lastWelcomeRequest.set(channelId, now);
8804
10736
  await this.signalHeldKpWelcome(groupId);
8805
10737
  return true;
8806
- } catch {
10738
+ } catch (e) {
10739
+ writeServiceLog("warn", "self_heal.supersede_signal_failed", {}, e);
8807
10740
  return false;
8808
10741
  }
8809
10742
  }
@@ -8875,40 +10808,50 @@ var BridgeAgent = class _BridgeAgent {
8875
10808
  * reads `null` and therefore degraded.
8876
10809
  */
8877
10810
  keypackageExpired = null;
10811
+ /** #335: dedupes the expiry-measurement warning to once per failure STREAK —
10812
+ * any non-throwing resolution (including the known-unknown `null` returns,
10813
+ * which are answers, not failures) re-arms it. Mirrors `enclaveListWarned`. */
10814
+ keypackageExpiryUnknownWarned = false;
8878
10815
  /** Resolve the flag from the SAME two inputs the guard uses, with every unknown
8879
10816
  * mapped to `null`. Never throws: a failure to measure is itself unknown. */
8880
10817
  async resolveKeypackageExpired() {
8881
10818
  try {
8882
10819
  const kpId = await this.provider.getLastPublishedKpId();
8883
- if (kpId === null || kpId === "") return null;
10820
+ if (kpId === null || kpId === "") return this.kpExpiryResolved(null);
8884
10821
  const exp = await this.backend.fetchKeyPackageExpiry(kpId);
8885
- if (exp === null) return null;
8886
- return isServerExpired(exp, Date.now());
8887
- } catch {
10822
+ if (exp === null) return this.kpExpiryResolved(null);
10823
+ return this.kpExpiryResolved(isServerExpired(exp, Date.now()));
10824
+ } catch (e) {
10825
+ if (!this.keypackageExpiryUnknownWarned) {
10826
+ this.keypackageExpiryUnknownWarned = true;
10827
+ writeServiceLog("warn", "self_heal.keypackage_expiry_unknown", {}, e);
10828
+ }
8888
10829
  return null;
8889
10830
  }
8890
10831
  }
10832
+ /** Every non-throwing resolution ends the failure streak (the next throw is
10833
+ * a NEW streak and warns again) and passes the value through unchanged. */
10834
+ kpExpiryResolved(value) {
10835
+ this.keypackageExpiryUnknownWarned = false;
10836
+ return value;
10837
+ }
8891
10838
  async rotateDeviceKeyPackage() {
8892
10839
  try {
8893
10840
  await this.provider.publishKeyPackage();
8894
10841
  } catch (e) {
8895
- console.error("[bridge] selfHealSweep: device KeyPackage rotation failed (continuing):", e);
10842
+ writeServiceLog("error", "self_heal.keypackage_rotation_failed", {}, e);
8896
10843
  }
8897
10844
  }
8898
10845
  async selfHealSweep() {
8899
10846
  if (this.closingDown) return;
8900
10847
  if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
8901
10848
  this.providerCapabilityMissing = true;
8902
- console.error(
8903
- "[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."
8904
- );
10849
+ writeServiceLog("error", "self_heal.provider_capability_missing");
8905
10850
  return;
8906
10851
  }
8907
10852
  if (typeof this.backend?.fetchKeyPackageExpiry !== "function") {
8908
10853
  this.providerCapabilityMissing = true;
8909
- console.error(
8910
- "[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."
8911
- );
10854
+ writeServiceLog("error", "self_heal.backend_capability_missing");
8912
10855
  return;
8913
10856
  }
8914
10857
  this.providerCapabilityMissing = false;
@@ -8935,13 +10878,13 @@ var BridgeAgent = class _BridgeAgent {
8935
10878
  failed.push(encId);
8936
10879
  if (!this.enclaveListWarned.has(encId)) {
8937
10880
  this.enclaveListWarned.add(encId);
8938
- console.warn(`[bridge] selfHealSweep: listChannels failed for enclave ${encId}; skipping (warn-once per streak):`, e);
10881
+ writeServiceLog("warn", "self_heal.enclave_listing_failed", {}, e);
8939
10882
  }
8940
10883
  }
8941
10884
  }
8942
10885
  this.sweepEnclavesFailed = failed;
8943
10886
  if (!listedAny) {
8944
- console.warn("[bridge] selfHealSweep aborted: every enclave listing failed");
10887
+ writeServiceLog("warn", "self_heal.all_enclave_listings_failed", { count: failed.length });
8945
10888
  return;
8946
10889
  }
8947
10890
  for (const c of chans) {
@@ -8992,26 +10935,20 @@ var BridgeAgent = class _BridgeAgent {
8992
10935
  }
8993
10936
  obs.state = "healthy";
8994
10937
  obs.diverged = false;
8995
- console.warn(
8996
- `[bridge] selfHealSweep: ${c.channel_id} caught up by REPLAY to epoch ${obs.held_epoch} (no re-admit, no epoch churn)`
8997
- );
10938
+ writeServiceLog("info", "self_heal.replay_succeeded", { epoch: obs.held_epoch });
8998
10939
  continue;
8999
10940
  }
9000
10941
  const repudiated = await this.healWedgedChannel(c.channel_id, true);
9001
- console.warn(
9002
- `[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"}`
9003
- );
10942
+ writeServiceLog("warn", "self_heal.stuck_channel", { repudiated });
9004
10943
  }
9005
10944
  continue;
9006
10945
  }
9007
- console.debug(
9008
- `[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`
9009
- );
10946
+ writeServiceLog("debug", "self_heal.channel_healthy");
9010
10947
  continue;
9011
10948
  }
9012
10949
  obs.state = "diverged";
9013
10950
  obs.diverged = true;
9014
- console.warn(`[bridge] selfHealSweep: ${c.channel_id} holds STALE group state \u2014 signalling re-Welcome`);
10951
+ writeServiceLog("warn", "self_heal.stale_group_state");
9015
10952
  } else {
9016
10953
  obs.state = "no_local_state";
9017
10954
  obs.diverged = false;
@@ -9034,7 +10971,7 @@ var BridgeAgent = class _BridgeAgent {
9034
10971
  this.lastWelcomeRequest.set(c.channel_id, now);
9035
10972
  await this.signalHeldKpWelcome(g.group_id);
9036
10973
  } catch (e) {
9037
- console.warn(`[bridge] selfHealSweep: channel ${c.channel_id} failed:`, e);
10974
+ writeServiceLog("warn", "self_heal.channel_probe_failed", {}, e);
9038
10975
  } finally {
9039
10976
  this.observations.set(c.channel_id, obs);
9040
10977
  }
@@ -9077,16 +11014,28 @@ var BridgeAgent = class _BridgeAgent {
9077
11014
  async sendWithSelfHealTerminal(...args2) {
9078
11015
  const [, channelId] = args2;
9079
11016
  try {
9080
- return await sendWithSelfHeal(...args2);
11017
+ const result = await sendWithSelfHeal(...args2);
11018
+ if (this.pausedChannels.has(channelId)) this.clearPause(channelId);
11019
+ return result;
9081
11020
  } catch (e) {
9082
11021
  if (e instanceof AgentPausedError) {
9083
- console.warn(`[bridge] send suppressed: agent paused in channel ${channelId}`);
11022
+ writeServiceLog("warn", "send.agent_paused");
11023
+ this.pausedChannels.set(channelId, Date.now());
9084
11024
  throw new AgentPausedSendError(channelId);
9085
11025
  }
11026
+ if (e instanceof Error && /^publish failed: 40[13]$/.test(e.message)) {
11027
+ writeServiceLog("warn", "send.forbidden");
11028
+ throw new SendForbiddenError(channelId);
11029
+ }
9086
11030
  throw e;
9087
11031
  }
9088
11032
  }
9089
- async send(channelId, text, messageId) {
11033
+ /**
11034
+ * `opts.agentTurn` (default true): whether this send is a SUBSTANTIVE agent
11035
+ * turn for the Phase 3 F3 hop count. Notices (daily limit, dead letter) and
11036
+ * continuation parts of one multi-part reply pass false (final-wave I2).
11037
+ */
11038
+ async send(channelId, text, messageId, opts = {}) {
9090
11039
  const meta = this.freshMeta(channelId);
9091
11040
  if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
9092
11041
  const joined = await this.reconcileJoin(channelId);
@@ -9121,10 +11070,11 @@ var BridgeAgent = class _BridgeAgent {
9121
11070
  (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
9122
11071
  );
9123
11072
  this.latency.record("send.total", performance.now() - sendStart);
11073
+ if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
9124
11074
  void recordOutboundMemory(this.memory, channelId, text, id);
9125
11075
  return { message_id: id, status: "sent" };
9126
11076
  }
9127
- async sendA2A(channelId, a2a, messageId) {
11077
+ async sendA2A(channelId, a2a, messageId, opts = {}) {
9128
11078
  this.enforceRateFloor("a2a", channelId);
9129
11079
  const joined = await this.reconcileJoin(channelId);
9130
11080
  if (!joined) throw new ChannelPendingError(channelId);
@@ -9158,6 +11108,7 @@ var BridgeAgent = class _BridgeAgent {
9158
11108
  (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
9159
11109
  );
9160
11110
  this.latency.record("send.total", performance.now() - sendStart);
11111
+ if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
9161
11112
  return { message_id: id, status: "sent" };
9162
11113
  }
9163
11114
  sendDeps() {
@@ -9223,6 +11174,7 @@ var BridgeAgent = class _BridgeAgent {
9223
11174
  (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
9224
11175
  );
9225
11176
  this.latency.record("send.total", performance.now() - sendStart);
11177
+ this.agentHops.noteOwnSend(channelId);
9226
11178
  return { message_id: id, status: "sent" };
9227
11179
  }
9228
11180
  // ── B7: FileSafe vault commands (loopback boundary) ──────────────────────
@@ -9551,10 +11503,7 @@ var BridgeAgent = class _BridgeAgent {
9551
11503
  return defer ? "deferred" : "answered";
9552
11504
  } catch (e) {
9553
11505
  this.fastPathFailed++;
9554
- process.stderr.write(
9555
- `[bridge] voice fast path failed for ${m.channelId}/${m.messageId}: ${e.message ?? "unknown error"}
9556
- `
9557
- );
11506
+ writeServiceLog("warn", "voice.fast_path_failed", {}, e);
9558
11507
  return "failed";
9559
11508
  }
9560
11509
  }
@@ -9743,10 +11692,7 @@ var BridgeAgent = class _BridgeAgent {
9743
11692
  } catch (e) {
9744
11693
  if (!this.enclaveEnumWarned) {
9745
11694
  this.enclaveEnumWarned = true;
9746
- console.warn(
9747
- "[bridge] enclave enumeration failed; sweep coverage degraded to home+learned enclaves:",
9748
- e
9749
- );
11695
+ writeServiceLog("warn", "self_heal.enclave_enumeration_failed", {}, e);
9750
11696
  }
9751
11697
  }
9752
11698
  }
@@ -9794,6 +11740,8 @@ var BridgeAgent = class _BridgeAgent {
9794
11740
  });
9795
11741
  }
9796
11742
  this.channelRoster.set(channelId, { members, at: Date.now() });
11743
+ const self = members.get(this.cfg.identityId);
11744
+ if (self?.name) this.lastOwnDisplayName = self.name;
9797
11745
  await this.resolveAndPinOwner(channelId, rows);
9798
11746
  } catch {
9799
11747
  }
@@ -9854,9 +11802,7 @@ var BridgeAgent = class _BridgeAgent {
9854
11802
  keyAlgo: owner.key_algo ?? "ed25519"
9855
11803
  });
9856
11804
  } else if (existing.publicKeyHex !== publicKeyHex) {
9857
- console.warn(
9858
- `[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
9859
- );
11805
+ writeServiceLog("warn", "identity.owner_pin_mismatch");
9860
11806
  this.channelOwner.set(channelId, "anomalous");
9861
11807
  }
9862
11808
  } catch {
@@ -9874,19 +11820,20 @@ var BridgeAgent = class _BridgeAgent {
9874
11820
  const cached = this.channelOwner.get(channelId);
9875
11821
  const TRACE = typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY;
9876
11822
  if (!cached || cached === "anomalous") {
9877
- if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: cache ${!cached ? "MISSING (cold)" : "anomalous"}`);
11823
+ if (TRACE && !cached) writeServiceLog("debug", "identity.owner_cache_missing");
11824
+ else if (TRACE) writeServiceLog("debug", "identity.owner_cache_anomalous");
9878
11825
  return null;
9879
11826
  }
9880
11827
  const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
9881
11828
  if (!pin) {
9882
- 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)})`);
11829
+ if (TRACE) writeServiceLog("debug", "identity.owner_pin_missing");
9883
11830
  return null;
9884
11831
  }
9885
11832
  if (pin.publicKeyHex !== cached.publicKeyHex) {
9886
- 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)}`);
11833
+ if (TRACE) writeServiceLog("debug", "identity.owner_pin_mismatch");
9887
11834
  return null;
9888
11835
  }
9889
- if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> OK owner=${cached.identityId.slice(0, 8)}`);
11836
+ if (TRACE) writeServiceLog("debug", "identity.owner_pin_resolved");
9890
11837
  return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
9891
11838
  }
9892
11839
  async reportStatus(channelId, state, detail) {
@@ -9977,13 +11924,13 @@ var BridgeAgent = class _BridgeAgent {
9977
11924
  const body = await runSummarize({
9978
11925
  request,
9979
11926
  complete: (prompt, o) => this.completeForSummary(prompt, o.signal),
9980
- log: (message) => this.log(message)
11927
+ log: writeServiceLog
9981
11928
  });
9982
11929
  if (body === null) return;
9983
11930
  try {
9984
11931
  await this.sendSummary(channelId, body);
9985
11932
  } catch (e) {
9986
- this.log(`summarize_send_failed: ${String(e)}`);
11933
+ writeServiceLog("warn", "summarize.send_failed", {}, e);
9987
11934
  }
9988
11935
  }
9989
11936
  /**
@@ -10000,7 +11947,7 @@ var BridgeAgent = class _BridgeAgent {
10000
11947
  */
10001
11948
  async completeForSummary(prompt, signal) {
10002
11949
  if (!this.fastLlm) {
10003
- this.log("summarize_no_runtime: no fast-path LLM configured for this agent");
11950
+ writeServiceLog("warn", "summarize.runtime_unavailable");
10004
11951
  throw new Error("summarize_no_runtime");
10005
11952
  }
10006
11953
  const messages = [{ role: "user", content: prompt }];
@@ -10049,12 +11996,6 @@ var BridgeAgent = class _BridgeAgent {
10049
11996
  (envelope) => this.transport.publish(envelope)
10050
11997
  );
10051
11998
  }
10052
- /** Minimal stderr logger for non-fatal daemon events (mirrors the ad hoc
10053
- * `process.stderr.write` calls elsewhere in this file). */
10054
- log(message) {
10055
- process.stderr.write(`[bridge] ${message}
10056
- `);
10057
- }
10058
11999
  /**
10059
12000
  * The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
10060
12001
  * If the run has no dirty nodes, no-op. If the per-run bucket has no token,
@@ -10196,7 +12137,7 @@ var BridgeAgent = class _BridgeAgent {
10196
12137
  for await (const ping of sub.pings) {
10197
12138
  this.pingsReceived++;
10198
12139
  if (ping?.reconnected) {
10199
- process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
12140
+ writeServiceLog("info", "transport.delivery_ping_connected");
10200
12141
  await this.refetchRuntimeConfig();
10201
12142
  }
10202
12143
  if (ping?.voiceSettingsChanged) {
@@ -10212,21 +12153,19 @@ var BridgeAgent = class _BridgeAgent {
10212
12153
  } catch {
10213
12154
  }
10214
12155
  if (!this.closingDown) {
10215
- process.stderr.write(
10216
- "[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"
10217
- );
12156
+ writeServiceLog("error", "transport.delivery_ping_stream_ended");
10218
12157
  }
10219
12158
  })();
10220
12159
  this.safetyTimer = setInterval(() => {
10221
12160
  void this.ingestOnce().catch((e) => {
10222
- if (!this.closingDown) console.error("[bridge] ingestOnce failed:", e);
12161
+ if (!this.closingDown) writeServiceLog("error", "receive.ingest_failed", {}, e);
10223
12162
  });
10224
12163
  }, 25e3);
10225
12164
  if (this.selfHealSweepEnabled) {
10226
12165
  const scheduleSweep = () => {
10227
12166
  this.selfHealTimer = setTimeout(() => {
10228
12167
  void this.selfHealSweep().catch((e) => {
10229
- if (!this.closingDown) console.error("[bridge] self-heal sweep failed:", e);
12168
+ if (!this.closingDown) writeServiceLog("error", "self_heal.sweep_failed", {}, e);
10230
12169
  });
10231
12170
  scheduleSweep();
10232
12171
  }, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + this.sweepJitter());
@@ -10266,7 +12205,7 @@ var BridgeAgent = class _BridgeAgent {
10266
12205
  try {
10267
12206
  await this.signalHeldKpWelcome(gid);
10268
12207
  } catch (e) {
10269
- console.warn(`[bridge] ingestOnce: signalHeldKpWelcome for ${cid} failed:`, e);
12208
+ writeServiceLog("warn", "self_heal.welcome_signal_failed", {}, e);
10270
12209
  }
10271
12210
  }
10272
12211
  } catch {
@@ -10307,11 +12246,7 @@ var BridgeAgent = class _BridgeAgent {
10307
12246
  );
10308
12247
  if (parsed.type && parsed.type !== "text") {
10309
12248
  if (parsed.type === "summarize") {
10310
- const request = parseSummarizeBody(
10311
- parsed,
10312
- this.cfg.identityId,
10313
- (msg) => this.log(msg)
10314
- );
12249
+ const request = parseSummarizeBody(parsed, this.cfg.identityId, writeServiceLog);
10315
12250
  if (request !== null) {
10316
12251
  void this.handleSummarize(m.channelId, request);
10317
12252
  }
@@ -10324,12 +12259,38 @@ var BridgeAgent = class _BridgeAgent {
10324
12259
  const senderType = m.envelope.sender_type;
10325
12260
  const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
10326
12261
  const chanMeta = this.freshMeta(m.channelId);
12262
+ const loopSenderIsAgent = this.loopSenderIsAgent(
12263
+ m.channelId,
12264
+ m.envelope.sender_id,
12265
+ senderIsAgent,
12266
+ false
12267
+ );
12268
+ const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent);
12269
+ if (loopSenderIsAgent === true) {
12270
+ const shareText = typeof parsed.text === "string" ? parsed.text : "";
12271
+ const answerable = chanMeta && chanMeta.is_dm === false ? shouldProcessRoomMessage(
12272
+ shareText,
12273
+ this.cfg.agentName ?? this.cfg.identityId,
12274
+ this.cfg.identityId,
12275
+ {
12276
+ senderIsAgent: true,
12277
+ roomDisplayName: this.ownRoomDisplayName(),
12278
+ agentHops,
12279
+ maxAgentHops: this.maxAgentHops
12280
+ }
12281
+ ) : agentHopAllowed(agentHops, this.maxAgentHops);
12282
+ if (!answerable) {
12283
+ this.noteAgentTurnSuppressed(agentHops);
12284
+ acked.push(m.queueId);
12285
+ continue;
12286
+ }
12287
+ }
10327
12288
  const attachment = await materializeAttachment(
10328
12289
  parsed,
10329
12290
  { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
10330
12291
  this.receiveDeps()
10331
12292
  );
10332
- this.buffer.push({
12293
+ const runtimeMessage = {
10333
12294
  channel_id: m.channelId,
10334
12295
  message_id: m.messageId,
10335
12296
  sender_id: m.envelope.sender_id,
@@ -10343,14 +12304,11 @@ var BridgeAgent = class _BridgeAgent {
10343
12304
  voice: true,
10344
12305
  guidance: selectVoiceGuidance(chanMeta?.is_dm, m.voiceDefaultResponder)
10345
12306
  } : {}
10346
- });
10347
- this.capBuffer();
12307
+ };
12308
+ if (!await this.stageRuntimeMessage(runtimeMessage)) continue;
10348
12309
  } catch (e) {
10349
12310
  this.failedAttachments++;
10350
- process.stderr.write(
10351
- `[bridge] attachment failed on ${m.messageId}: ${e.message}
10352
- `
10353
- );
12311
+ writeServiceLog("warn", "attachment.materialize_failed", {}, e);
10354
12312
  }
10355
12313
  }
10356
12314
  acked.push(m.queueId);
@@ -10360,16 +12318,35 @@ var BridgeAgent = class _BridgeAgent {
10360
12318
  const meta = this.freshMeta(m.channelId);
10361
12319
  const senderType = m.envelope.sender_type;
10362
12320
  const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
12321
+ const speaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string" ? {
12322
+ identityId: parsed.speaker_identity_id,
12323
+ displayName: parsed.speaker_display_name
12324
+ } : void 0;
12325
+ const loopSenderIsAgent = this.loopSenderIsAgent(
12326
+ m.channelId,
12327
+ m.envelope.sender_id,
12328
+ senderIsAgent,
12329
+ speaker !== void 0
12330
+ );
12331
+ const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent);
10363
12332
  let shouldBuffer = true;
10364
12333
  if (meta && meta.is_dm === false) {
10365
12334
  shouldBuffer = shouldProcessRoomMessage(
10366
12335
  parsed.text,
10367
12336
  this.cfg.agentName ?? this.cfg.identityId,
10368
12337
  this.cfg.identityId,
10369
- { senderIsAgent, roomDisplayName: meta.name ?? void 0 }
12338
+ {
12339
+ senderIsAgent: loopSenderIsAgent,
12340
+ roomDisplayName: this.ownRoomDisplayName(),
12341
+ agentHops,
12342
+ maxAgentHops: this.maxAgentHops
12343
+ }
10370
12344
  );
10371
12345
  if (!shouldBuffer) this.filteredRoomMessages++;
12346
+ } else if (loopSenderIsAgent === true && !agentHopAllowed(agentHops, this.maxAgentHops)) {
12347
+ shouldBuffer = false;
10372
12348
  }
12349
+ if (!shouldBuffer && loopSenderIsAgent === true) this.noteAgentTurnSuppressed(agentHops);
10373
12350
  if (shouldBuffer) {
10374
12351
  const senderName = this.freshRoster(m.channelId)?.get(
10375
12352
  m.envelope.sender_id
@@ -10379,10 +12356,6 @@ var BridgeAgent = class _BridgeAgent {
10379
12356
  m.envelope.sender_id
10380
12357
  );
10381
12358
  const runtimeMemory = this.memoryForTurn(m.channelId, turnAuthorization);
10382
- const speaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string" ? {
10383
- identityId: parsed.speaker_identity_id,
10384
- displayName: parsed.speaker_display_name
10385
- } : void 0;
10386
12359
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
10387
12360
  const humanTurn = senderIsAgent !== true || speaker !== void 0;
10388
12361
  const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
@@ -10391,7 +12364,7 @@ var BridgeAgent = class _BridgeAgent {
10391
12364
  if (this.rateFloor.available(`room:${m.channelId}`) < 2) fastPathRoomBlocked = true;
10392
12365
  }
10393
12366
  if (fastPathRoomBlocked) this.fastPathSkipped++;
10394
- const fastPathFiring = fastPathEligible && !fastPathRoomBlocked;
12367
+ const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedChannels.has(m.channelId);
10395
12368
  const turnText = parsed.text;
10396
12369
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
10397
12370
  const fastGreeting = isCallStart && fastPathFiring;
@@ -10415,7 +12388,7 @@ var BridgeAgent = class _BridgeAgent {
10415
12388
  });
10416
12389
  if (fastPathFiring) {
10417
12390
  const fastSpeaker = speaker ?? (senderName ? { identityId: m.envelope.sender_id, displayName: senderName } : void 0);
10418
- void this.runFastPath(
12391
+ const outcome = await this.runFastPath(
10419
12392
  {
10420
12393
  channelId: m.channelId,
10421
12394
  messageId: m.messageId,
@@ -10425,16 +12398,15 @@ var BridgeAgent = class _BridgeAgent {
10425
12398
  fastGreeting ? greetingTurnFor(this.voiceSettings ?? DEFAULT_VOICE_SETTINGS, fastSpeaker?.displayName) : parsed.text,
10426
12399
  voiceGuidance,
10427
12400
  fastSpeaker
10428
- ).then(async (outcome) => {
10429
- if (outcome === "answered") return;
10430
- this.buffer.push(runtimeEntry(outcome === "deferred", await runtimeMemory));
10431
- this.capBuffer();
10432
- this.wakeListeners();
10433
- });
12401
+ );
12402
+ if (outcome !== "answered") {
12403
+ if (!await this.stageRuntimeMessage(runtimeEntry(outcome === "deferred", await runtimeMemory))) {
12404
+ continue;
12405
+ }
12406
+ }
10434
12407
  } else {
10435
- this.buffer.push(runtimeEntry(false, await runtimeMemory));
12408
+ if (!await this.stageRuntimeMessage(runtimeEntry(false, await runtimeMemory))) continue;
10436
12409
  }
10437
- this.capBuffer();
10438
12410
  void recordInboundMemory(
10439
12411
  this.memory,
10440
12412
  m.channelId,
@@ -10465,7 +12437,10 @@ var BridgeAgent = class _BridgeAgent {
10465
12437
  }
10466
12438
  async drain(channelId, waitSeconds) {
10467
12439
  let got = this.takeBuffered(channelId);
10468
- if (got.length || waitSeconds <= 0) return got;
12440
+ if (got.length || waitSeconds <= 0) {
12441
+ this.ackRuntimeMessages(got.map((message) => message.message_id));
12442
+ return got;
12443
+ }
10469
12444
  const cap = Math.min(waitSeconds, 50) * 1e3;
10470
12445
  await new Promise((resolve3) => {
10471
12446
  let fired = false;
@@ -10481,7 +12456,9 @@ var BridgeAgent = class _BridgeAgent {
10481
12456
  timer = setTimeout(wake, cap);
10482
12457
  this.waiters.push(wake);
10483
12458
  });
10484
- return this.takeBuffered(channelId);
12459
+ got = this.takeBuffered(channelId);
12460
+ this.ackRuntimeMessages(got.map((message) => message.message_id));
12461
+ return got;
10485
12462
  }
10486
12463
  /** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
10487
12464
  subscribe(listener) {
@@ -10490,19 +12467,319 @@ var BridgeAgent = class _BridgeAgent {
10490
12467
  }
10491
12468
  /** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
10492
12469
  takeBuffered(channelId) {
10493
- if (channelId === void 0) return this.buffer.splice(0);
12470
+ if (channelId === void 0 && this.pausedChannels.size === 0) return this.buffer.splice(0);
10494
12471
  const keep = [];
10495
12472
  const out = [];
10496
- for (const m of this.buffer) (m.channel_id === channelId ? out : keep).push(m);
12473
+ for (const m of this.buffer) {
12474
+ const wanted = channelId === void 0 || m.channel_id === channelId;
12475
+ (wanted && !this.heldForPause(m.channel_id) ? out : keep).push(m);
12476
+ }
10497
12477
  this.buffer.length = 0;
10498
12478
  this.buffer.push(...keep);
10499
12479
  return out;
10500
12480
  }
12481
+ /**
12482
+ * Phase 3 F2: per-channel ordered delivery for the /v1/ws runtime consumer.
12483
+ * Splices out, oldest first, at most ONE buffered message per channel that
12484
+ * is not in `busyChannels` (the channels the consumer still holds a message
12485
+ * in flight for); everything else stays buffered in order. A runtime is
12486
+ * therefore never handed a second message for a channel it is still
12487
+ * working on — which it used to refuse with a "busy" reply and ack — and a
12488
+ * backlog replays one message per channel at a time after a restart.
12489
+ */
12490
+ takeDeliverable(busyChannels) {
12491
+ const picked = /* @__PURE__ */ new Set();
12492
+ const out = [];
12493
+ const keep = [];
12494
+ const backingOff = /* @__PURE__ */ new Set();
12495
+ const now = Date.now();
12496
+ let wakeAt = Number.POSITIVE_INFINITY;
12497
+ for (const m of this.buffer) {
12498
+ const ch = m.channel_id;
12499
+ if (busyChannels.has(ch) || picked.has(ch) || backingOff.has(ch) || this.heldForPause(ch)) {
12500
+ keep.push(m);
12501
+ continue;
12502
+ }
12503
+ const notBefore = this.redeliverNotBefore.get(m.message_id);
12504
+ if (notBefore !== void 0 && notBefore > now) {
12505
+ backingOff.add(ch);
12506
+ wakeAt = Math.min(wakeAt, notBefore);
12507
+ keep.push(m);
12508
+ continue;
12509
+ }
12510
+ picked.add(ch);
12511
+ out.push(m);
12512
+ }
12513
+ if (wakeAt !== Number.POSITIVE_INFINITY) this.armRedeliveryWake(wakeAt);
12514
+ if (out.length) {
12515
+ this.buffer.length = 0;
12516
+ this.buffer.push(...keep);
12517
+ }
12518
+ return out;
12519
+ }
12520
+ /**
12521
+ * Phase 3 F1: gate ONE delivery of `message` to the runtime consumer. Returns
12522
+ * true when it may be sent (and counts the attempt durably), false when the
12523
+ * message already reached the delivery cap — then it is dead-lettered:
12524
+ * acknowledged, never redelivered, never reaching a model. (Paused channels
12525
+ * never get here: takeDeliverable holds them, so a pause spends no attempts.)
12526
+ * Logs are content-free (event + counts only).
12527
+ */
12528
+ admitRuntimeDelivery(message) {
12529
+ const prior = this.deliveryAttempts.get(message.message_id) ?? 0;
12530
+ if (prior >= this.maxDeliveryAttempts) {
12531
+ this.deadLetter(message, prior);
12532
+ return false;
12533
+ }
12534
+ this.redeliverNotBefore.delete(message.message_id);
12535
+ let durable = 0;
12536
+ try {
12537
+ durable = this.store.noteRuntimeInboxDelivery(message.message_id);
12538
+ } catch (e) {
12539
+ writeServiceLog("warn", "runtime_inbox.attempt_persist_failed", {}, e);
12540
+ }
12541
+ this.deliveryAttempts.set(message.message_id, Math.max(durable, prior + 1));
12542
+ return true;
12543
+ }
12544
+ /**
12545
+ * Final-wave I1: put back runtime deliveries that FAILED — the runtime sent a
12546
+ * `nack` for the message, or the socket closed while it was in flight (an
12547
+ * older runtime closes on any failed turn). Each one's attempt was already
12548
+ * counted durably at delivery; here it either dead-letters (cap reached) or
12549
+ * is requeued at the front behind a 2^attempts × 5 s redelivery backoff.
12550
+ * Messages that were never handed to the runtime go back via requeueFront
12551
+ * instead and are not affected.
12552
+ */
12553
+ requeueFailedRuntimeDeliveries(messages) {
12554
+ const back = [];
12555
+ const now = Date.now();
12556
+ for (const m of messages) {
12557
+ const attempts = this.deliveryAttempts.get(m.message_id) ?? 0;
12558
+ if (attempts >= this.maxDeliveryAttempts) {
12559
+ this.deadLetter(m, attempts);
12560
+ continue;
12561
+ }
12562
+ if (attempts > 0) this.redeliverNotBefore.set(m.message_id, now + redeliveryDelayMs(attempts));
12563
+ back.push(m);
12564
+ }
12565
+ this.requeueFront(back);
12566
+ }
12567
+ /**
12568
+ * Phase 3 F1 dead letter: acknowledged, never redelivered, never reaching a
12569
+ * model — plus (final-wave I1(c)) one content-free notice to the channel,
12570
+ * at most once per channel per hour. Logs carry counts only.
12571
+ */
12572
+ deadLetter(message, attempts) {
12573
+ this.deadLetteredMessages++;
12574
+ writeServiceLog("warn", "runtime_inbox.dead_letter", { attempts });
12575
+ this.ackRuntimeMessages([message.message_id]);
12576
+ this.noticeDeadLetter(message.channel_id);
12577
+ }
12578
+ noticeDeadLetter(channelId) {
12579
+ if (this.closingDown) return;
12580
+ const now = Date.now();
12581
+ const last = this.deadLetterNoticeAt.get(channelId);
12582
+ if (last !== void 0 && now - last < DEAD_LETTER_NOTICE_INTERVAL_MS) return;
12583
+ this.deadLetterNoticeAt.delete(channelId);
12584
+ this.deadLetterNoticeAt.set(channelId, now);
12585
+ if (this.deadLetterNoticeAt.size > 5e3) {
12586
+ const oldest = this.deadLetterNoticeAt.keys().next();
12587
+ if (!oldest.done) this.deadLetterNoticeAt.delete(oldest.value);
12588
+ }
12589
+ let pending;
12590
+ try {
12591
+ pending = this.send(channelId, DEAD_LETTER_NOTICE, void 0, { agentTurn: false });
12592
+ } catch (e) {
12593
+ pending = Promise.reject(e);
12594
+ }
12595
+ void pending.then(
12596
+ () => writeServiceLog("info", "runtime_inbox.dead_letter_notice_sent"),
12597
+ (e) => writeServiceLog("warn", "runtime_inbox.dead_letter_notice_failed", {}, e)
12598
+ );
12599
+ }
12600
+ /** Poke consumers once the earliest redelivery backoff ends. */
12601
+ armRedeliveryWake(at) {
12602
+ if (this.closingDown) return;
12603
+ if (this.redeliveryTimer !== null && this.redeliveryTimerAt <= at) return;
12604
+ if (this.redeliveryTimer !== null) clearTimeout(this.redeliveryTimer);
12605
+ this.redeliveryTimerAt = at;
12606
+ const timer = setTimeout(() => {
12607
+ this.redeliveryTimer = null;
12608
+ this.redeliveryTimerAt = Number.POSITIVE_INFINITY;
12609
+ this.wakeListeners();
12610
+ }, Math.max(0, at - Date.now()));
12611
+ timer.unref?.();
12612
+ this.redeliveryTimer = timer;
12613
+ }
12614
+ /**
12615
+ * 2026-09-25: this agent's OWN display_name for the room @mention filter
12616
+ * (`roomDisplayName`). Never the channel's name: a channel called
12617
+ * "Hermesav, Cortina011" must not make `@hermesav` address this agent. A pure
12618
+ * field read; the roster refresh that fills it runs once per ingest cycle,
12619
+ * TTL-gated, before the filter. Undefined until any roster has listed this
12620
+ * agent, in which case the filter still matches the credential agentName and
12621
+ * the identity id.
12622
+ */
12623
+ ownRoomDisplayName() {
12624
+ return this.lastOwnDisplayName;
12625
+ }
12626
+ /** Phase 3 F3: the loop guard's view of the sender — server roster first. */
12627
+ loopSenderIsAgent(channelId, senderId, envelopeSaysAgent, relayedHumanTurn) {
12628
+ if (relayedHumanTurn) return false;
12629
+ const row = this.freshRoster(channelId)?.get(senderId);
12630
+ if (row) return row.is_agent;
12631
+ return envelopeSaysAgent;
12632
+ }
12633
+ noteAgentTurnSuppressed(agentHops) {
12634
+ this.agentLoopSuppressed++;
12635
+ writeServiceLog("info", "inbound.agent_turn_suppressed", { agent_hops: agentHops });
12636
+ }
12637
+ /**
12638
+ * Phase 3 F1: whether `channelId`'s buffered work must be HELD because the
12639
+ * agent is paused there. Held work stays durably pending (never acked, no
12640
+ * delivery attempt spent) and is released by {@link recheckPause} once the
12641
+ * owner resumes the agent. Only a channel whose send was refused with
12642
+ * `agent_paused` is ever held. Holding arms a recheck at the end of the
12643
+ * PAUSE_RECHECK_MS window, so release does not depend on new traffic.
12644
+ */
12645
+ heldForPause(channelId) {
12646
+ const at = this.pausedChannels.get(channelId);
12647
+ if (at === void 0) return false;
12648
+ this.schedulePauseRecheck(channelId, at);
12649
+ return true;
12650
+ }
12651
+ schedulePauseRecheck(channelId, confirmedAt) {
12652
+ if (this.closingDown || this.pauseRecheckTimers.has(channelId)) return;
12653
+ const delay = Math.max(0, confirmedAt + PAUSE_RECHECK_MS - Date.now());
12654
+ const timer = setTimeout(() => {
12655
+ void this.recheckPause(channelId).catch((e) => {
12656
+ writeServiceLog("warn", "pause.recheck_failed", {}, e);
12657
+ });
12658
+ }, delay);
12659
+ timer.unref?.();
12660
+ this.pauseRecheckTimers.set(channelId, timer);
12661
+ }
12662
+ /**
12663
+ * Re-read this agent's own membership row. Still paused → keep holding and
12664
+ * re-arm while work is held. Resumed — or a failed/inconclusive probe, which
12665
+ * fails OPEN so a channel is never left deaf (the worst case is one model
12666
+ * call whose reply is refused again, re-arming the pause) — releases the
12667
+ * held work and pokes consumers to drain it now.
12668
+ */
12669
+ async recheckPause(channelId) {
12670
+ const timer = this.pauseRecheckTimers.get(channelId);
12671
+ if (timer) clearTimeout(timer);
12672
+ this.pauseRecheckTimers.delete(channelId);
12673
+ if (!this.pausedChannels.has(channelId) || this.closingDown) return;
12674
+ if (await this.probeOwnPause(channelId) === true) {
12675
+ const now = Date.now();
12676
+ this.pausedChannels.set(channelId, now);
12677
+ if (this.buffer.some((m) => m.channel_id === channelId)) this.schedulePauseRecheck(channelId, now);
12678
+ return;
12679
+ }
12680
+ this.clearPause(channelId);
12681
+ }
12682
+ clearPause(channelId) {
12683
+ this.pausedChannels.delete(channelId);
12684
+ const timer = this.pauseRecheckTimers.get(channelId);
12685
+ if (timer) clearTimeout(timer);
12686
+ this.pauseRecheckTimers.delete(channelId);
12687
+ writeServiceLog("info", "pause.released");
12688
+ this.wakeListeners();
12689
+ }
12690
+ /** Read this agent's own `paused_at` from the enclave roster; undefined = unknown. */
12691
+ async probeOwnPause(channelId) {
12692
+ try {
12693
+ const url = `${this.cfg.apiBase}/api/v1/enclaves/${encodeURIComponent(
12694
+ this.resolveEnclave(channelId)
12695
+ )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
12696
+ const res = await this.fetchImpl(url, {
12697
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` }
12698
+ });
12699
+ if (!res.ok) return void 0;
12700
+ const rows = await res.json();
12701
+ if (!Array.isArray(rows)) return void 0;
12702
+ const own = rows.find(
12703
+ (r) => r?.identity_id === this.cfg.identityId
12704
+ );
12705
+ if (!own) return void 0;
12706
+ return typeof own.paused_at === "string" && own.paused_at.length > 0;
12707
+ } catch (e) {
12708
+ writeServiceLog("warn", "pause.probe_failed", {}, e);
12709
+ return void 0;
12710
+ }
12711
+ }
10501
12712
  recordRuntimeReply(inReplyTo, channelId, replyMessageId) {
10502
- return this.runtimeReplyReceipts.record(inReplyTo, channelId, replyMessageId);
12713
+ return this.store.recordRuntimeReplyReceipt(inReplyTo, channelId, replyMessageId);
10503
12714
  }
10504
12715
  runtimeReplyReceipt(inReplyTo) {
10505
- return this.runtimeReplyReceipts.get(inReplyTo);
12716
+ return this.store.runtimeReplyReceipt(inReplyTo);
12717
+ }
12718
+ /**
12719
+ * Phase 3 F1: retire a runtime message whose reply failed TERMINALLY
12720
+ * (agent_paused / send_forbidden): acknowledged durably, never redelivered,
12721
+ * so the model is not run again for it. Pokes consumers so a socket holding
12722
+ * it in flight frees the channel for its next message.
12723
+ */
12724
+ retireRuntimeMessage(messageId) {
12725
+ this.ackRuntimeMessages([messageId]);
12726
+ if (this.retiredRuntimeIds.size >= 5e3) {
12727
+ const oldest = this.retiredRuntimeIds.values().next();
12728
+ if (!oldest.done) this.retiredRuntimeIds.delete(oldest.value);
12729
+ }
12730
+ this.retiredRuntimeIds.add(messageId);
12731
+ this.wakeListeners();
12732
+ }
12733
+ isRuntimeMessageRetired(messageId) {
12734
+ return this.retiredRuntimeIds.has(messageId);
12735
+ }
12736
+ /** Complete the durable handoff only after the runtime explicitly acks. */
12737
+ ackRuntimeMessages(messageIds) {
12738
+ for (const messageId of messageIds) {
12739
+ this.store.ackRuntimeInbox(messageId);
12740
+ this.runtimePendingIds.delete(messageId);
12741
+ this.deliveryAttempts.delete(messageId);
12742
+ this.redeliverNotBefore.delete(messageId);
12743
+ }
12744
+ }
12745
+ async restoreRuntimeInbox() {
12746
+ const rows = await this.store.loadRuntimeInbox();
12747
+ for (const row of rows) {
12748
+ const message = JSON.parse(new TextDecoder().decode(row.payload));
12749
+ if (!message || message.message_id !== row.messageId || typeof message.channel_id !== "string") {
12750
+ throw new Error("restored runtime inbox row has an invalid message binding");
12751
+ }
12752
+ this.runtimePendingIds.add(message.message_id);
12753
+ if (row.deliveryAttempts > 0) {
12754
+ this.deliveryAttempts.set(message.message_id, row.deliveryAttempts);
12755
+ if (row.deliveryAttempts < this.maxDeliveryAttempts) {
12756
+ this.redeliverNotBefore.set(message.message_id, Date.now() + redeliveryDelayMs(row.deliveryAttempts));
12757
+ }
12758
+ }
12759
+ this.buffer.push(message);
12760
+ }
12761
+ }
12762
+ /**
12763
+ * Seal a runtime frame before the backend queue id may be acknowledged.
12764
+ * Returning false is backpressure: the caller leaves the backend row unacked
12765
+ * so it remains the durable queue rather than shedding accepted work.
12766
+ */
12767
+ async stageRuntimeMessage(message) {
12768
+ if (this.runtimePendingIds.has(message.message_id)) return true;
12769
+ if (this.runtimePendingIds.size >= this.maxBuffer) {
12770
+ writeServiceLog("warn", "runtime_inbox.full");
12771
+ return false;
12772
+ }
12773
+ try {
12774
+ const payload = new TextEncoder().encode(JSON.stringify(message));
12775
+ await this.store.stageRuntimeInbox(message.message_id, payload, message.ts);
12776
+ this.runtimePendingIds.add(message.message_id);
12777
+ this.buffer.push(message);
12778
+ return true;
12779
+ } catch (error) {
12780
+ writeServiceLog("error", "runtime_inbox.stage_failed", {}, error);
12781
+ return false;
12782
+ }
10506
12783
  }
10507
12784
  /**
10508
12785
  * Put messages back at the front (SSE/disconnect write-failure recovery).
@@ -10590,6 +12867,9 @@ var BridgeAgent = class _BridgeAgent {
10590
12867
  buffered: this.buffer.length,
10591
12868
  dropped: this.droppedMessages,
10592
12869
  filtered_room: this.filteredRoomMessages,
12870
+ dead_lettered: this.deadLetteredMessages,
12871
+ paused_channels: this.pausedChannels.size,
12872
+ agent_loop_suppressed: this.agentLoopSuppressed,
10593
12873
  rate_limited: this.rateLimitedSends,
10594
12874
  permanently_skipped: this.provider.permanentlySkippedCount(),
10595
12875
  quarantine_drained: this.provider.quarantineDrainedCount(),
@@ -10642,6 +12922,9 @@ var BridgeAgent = class _BridgeAgent {
10642
12922
  errors: this.provider.errorStats(),
10643
12923
  dropped: this.droppedMessages,
10644
12924
  filteredRoomMessages: this.filteredRoomMessages,
12925
+ deadLetteredMessages: this.deadLetteredMessages,
12926
+ pausedChannels: this.pausedChannels.size,
12927
+ agentLoopSuppressed: this.agentLoopSuppressed,
10645
12928
  rateLimitedSends: this.rateLimitedSends,
10646
12929
  permanentlySkipped: this.provider.permanentlySkippedCount(),
10647
12930
  quarantineDrained: this.provider.quarantineDrainedCount(),
@@ -10653,6 +12936,10 @@ var BridgeAgent = class _BridgeAgent {
10653
12936
  }
10654
12937
  async close() {
10655
12938
  this.closingDown = true;
12939
+ for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
12940
+ this.pauseRecheckTimers.clear();
12941
+ if (this.redeliveryTimer !== null) clearTimeout(this.redeliveryTimer);
12942
+ this.redeliveryTimer = null;
10656
12943
  this.pingHandle?.close();
10657
12944
  if (this.safetyTimer) clearInterval(this.safetyTimer);
10658
12945
  if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
@@ -10720,6 +13007,9 @@ var BRIDGE_ERRORS = {
10720
13007
  body_too_large: { http: 400, message: "request body exceeds the size limit" },
10721
13008
  channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
10722
13009
  rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
13010
+ // Phase 3 F1: TERMINAL send outcomes — do not retry the turn.
13011
+ agent_paused: { http: 403, message: "agent is paused in this channel; not sent" },
13012
+ send_forbidden: { http: 403, message: "send refused (credential revoked or not a member); not sent" },
10723
13013
  unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
10724
13014
  // FileSafe vault (B7): digest mismatch, DEK-wrap AEAD failure (bad/dropped
10725
13015
  // nonce, wrong KEK), owner-pin/signature/rollback rejection, or "no wrap
@@ -10742,7 +13032,7 @@ function wsErrorFrame(code, detail) {
10742
13032
  }
10743
13033
 
10744
13034
  // src/http-server.ts
10745
- function writeSse(res, msgs) {
13035
+ function writeSse(res, msgs, onWriteError) {
10746
13036
  for (let i = 0; i < msgs.length; i++) {
10747
13037
  if (res.destroyed || res.writableEnded) return msgs.slice(i);
10748
13038
  try {
@@ -10750,12 +13040,24 @@ function writeSse(res, msgs) {
10750
13040
  data: ${JSON.stringify(msgs[i])}
10751
13041
 
10752
13042
  `);
10753
- } catch {
13043
+ } catch (err) {
13044
+ try {
13045
+ onWriteError?.(err);
13046
+ } catch {
13047
+ }
10754
13048
  return msgs.slice(i);
10755
13049
  }
10756
13050
  }
10757
13051
  return [];
10758
13052
  }
13053
+ function sseWriteFailureReporter() {
13054
+ let warned = false;
13055
+ return (error) => {
13056
+ if (warned) return;
13057
+ warned = true;
13058
+ writeServiceLog("warn", "sse.write_failed", {}, error);
13059
+ };
13060
+ }
10759
13061
  function capabilities(opts) {
10760
13062
  return {
10761
13063
  api_version: "1",
@@ -10782,6 +13084,8 @@ var sendSchema = z3.object({
10782
13084
  text: z3.string().optional(),
10783
13085
  idempotency_key: z3.string().optional(),
10784
13086
  in_reply_to: z3.string().min(1).max(200).optional(),
13087
+ /** Final-wave I2: a notice (e.g. a daily-limit reply) is not an agent turn. */
13088
+ notice: z3.boolean().optional(),
10785
13089
  a2a: a2aSchema.optional()
10786
13090
  }).refine((d) => typeof d.text === "string" || d.a2a !== void 0, {
10787
13091
  message: "send requires either text or a2a"
@@ -10907,7 +13211,16 @@ function createHttpServer(agent, opts) {
10907
13211
  if (method === "POST" && path === "/v1/send") {
10908
13212
  const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
10909
13213
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
10910
- 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);
13214
+ let r;
13215
+ const turn = { agentTurn: isAgentTurn(agent, parsed.data) };
13216
+ try {
13217
+ 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);
13218
+ } catch (e) {
13219
+ if (parsed.data.in_reply_to && (e instanceof AgentPausedSendError || e instanceof SendForbiddenError)) {
13220
+ agent.retireRuntimeMessage(parsed.data.in_reply_to);
13221
+ }
13222
+ throw e;
13223
+ }
10911
13224
  if (parsed.data.in_reply_to) {
10912
13225
  agent.recordRuntimeReply(parsed.data.in_reply_to, parsed.data.channel_id, r.message_id);
10913
13226
  }
@@ -10983,8 +13296,12 @@ function createHttpServer(agent, opts) {
10983
13296
  connection: "keep-alive"
10984
13297
  });
10985
13298
  res.write(":connected\n\n");
13299
+ const onWriteError = sseWriteFailureReporter();
10986
13300
  const drainToClient = () => {
10987
- const left2 = writeSse(res, agent.takeBuffered(channel));
13301
+ const batch = agent.takeBuffered(channel);
13302
+ const left2 = writeSse(res, batch, onWriteError);
13303
+ const leftIds = new Set(left2.map((message) => message.message_id));
13304
+ agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
10988
13305
  if (left2.length) agent.requeueFront(left2);
10989
13306
  };
10990
13307
  const unsubscribe = agent.subscribe(drainToClient);
@@ -11012,11 +13329,12 @@ function createHttpServer(agent, opts) {
11012
13329
  }
11013
13330
  if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
11014
13331
  if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
13332
+ if (e instanceof AgentPausedSendError) return jsonError(res, "agent_paused");
13333
+ if (e instanceof SendForbiddenError) return jsonError(res, "send_forbidden");
11015
13334
  if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
11016
13335
  if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
11017
13336
  if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
11018
- process.stderr.write(`[bridge] request error: ${e?.message ?? e}
11019
- `);
13337
+ writeServiceLog("error", "http.request_failed", {}, e);
11020
13338
  return jsonError(res, "internal");
11021
13339
  }
11022
13340
  };
@@ -11081,21 +13399,35 @@ function attachAgentWebSocket(server, agent, opts) {
11081
13399
  });
11082
13400
  const inflight = /* @__PURE__ */ new Map();
11083
13401
  const flush = () => {
11084
- if (sock.readyState !== WebSocket.OPEN) return;
11085
- const batch = agent.takeBuffered();
11086
- for (let i = 0; i < batch.length; i++) {
11087
- if (sock.readyState !== WebSocket.OPEN) {
11088
- agent.requeueFront(batch.slice(i));
11089
- return;
13402
+ for (; ; ) {
13403
+ if (sock.readyState !== WebSocket.OPEN) return;
13404
+ const busy = /* @__PURE__ */ new Set();
13405
+ for (const [id, m] of inflight) {
13406
+ if (agent.isRuntimeMessageRetired(id)) inflight.delete(id);
13407
+ else busy.add(m.channel_id);
11090
13408
  }
11091
- const frame = { type: "message", ...batch[i] };
11092
- try {
11093
- sock.send(JSON.stringify(frame));
11094
- inflight.set(batch[i].message_id, batch[i]);
11095
- } catch {
11096
- agent.requeueFront(batch.slice(i));
11097
- return;
13409
+ const batch = agent.takeDeliverable(busy);
13410
+ if (batch.length === 0) return;
13411
+ let retired = false;
13412
+ for (let i = 0; i < batch.length; i++) {
13413
+ if (sock.readyState !== WebSocket.OPEN) {
13414
+ agent.requeueFront(batch.slice(i));
13415
+ return;
13416
+ }
13417
+ if (!agent.admitRuntimeDelivery(batch[i])) {
13418
+ retired = true;
13419
+ continue;
13420
+ }
13421
+ const frame = { type: "message", ...batch[i] };
13422
+ try {
13423
+ sock.send(JSON.stringify(frame));
13424
+ inflight.set(batch[i].message_id, batch[i]);
13425
+ } catch {
13426
+ agent.requeueFront(batch.slice(i));
13427
+ return;
13428
+ }
11098
13429
  }
13430
+ if (!retired) return;
11099
13431
  }
11100
13432
  };
11101
13433
  const unsubscribe = agent.subscribe(flush);
@@ -11106,7 +13438,9 @@ function attachAgentWebSocket(server, agent, opts) {
11106
13438
  agent.noteSessionClose();
11107
13439
  unsubscribe();
11108
13440
  if (inflight.size) {
11109
- agent.requeueFront([...inflight.values()]);
13441
+ agent.requeueFailedRuntimeDeliveries(
13442
+ [...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
13443
+ );
11110
13444
  inflight.clear();
11111
13445
  agent.wakeListeners();
11112
13446
  }
@@ -11116,7 +13450,7 @@ function attachAgentWebSocket(server, agent, opts) {
11116
13450
  sock.on("close", cleanup);
11117
13451
  sock.on("error", cleanup);
11118
13452
  sock.on("message", (data) => {
11119
- void handleInbound(sock, agent, data, inflight);
13453
+ void handleInbound(sock, agent, data, inflight, flush);
11120
13454
  });
11121
13455
  flush();
11122
13456
  });
@@ -11140,7 +13474,14 @@ function sendJson(sock, body) {
11140
13474
  } catch {
11141
13475
  }
11142
13476
  }
11143
- async function handleInbound(sock, agent, data, inflight) {
13477
+ function sendErrorCode(e) {
13478
+ if (e instanceof ChannelPendingError) return "channel_pending";
13479
+ if (e instanceof RateLimitedError) return "rate_limited";
13480
+ if (e instanceof AgentPausedSendError) return "agent_paused";
13481
+ if (e instanceof SendForbiddenError) return "send_forbidden";
13482
+ return "invalid_request";
13483
+ }
13484
+ async function handleInbound(sock, agent, data, inflight, flush) {
11144
13485
  let msg;
11145
13486
  try {
11146
13487
  msg = JSON.parse(data.toString("utf8"));
@@ -11159,8 +13500,40 @@ async function handleInbound(sock, agent, data, inflight) {
11159
13500
  return;
11160
13501
  }
11161
13502
  inflight.delete(m.message_id);
13503
+ agent.ackRuntimeMessages([m.message_id]);
13504
+ flush();
13505
+ return;
13506
+ }
13507
+ if (m.type === "hello") {
13508
+ sendJson(sock, { type: "hello", features: ["nack"] });
13509
+ return;
13510
+ }
13511
+ if (m.type === "nack") {
13512
+ if (typeof m.message_id !== "string" || !m.message_id) {
13513
+ sendJson(sock, wsErrorFrame("invalid_request"));
13514
+ return;
13515
+ }
13516
+ const failed = inflight.get(m.message_id);
13517
+ if (!failed) return;
13518
+ inflight.delete(m.message_id);
13519
+ if (agent.isRuntimeMessageRetired(m.message_id)) {
13520
+ flush();
13521
+ return;
13522
+ }
13523
+ writeServiceLog("warn", "runtime_inbox.nack", { reply_failed: m.reason === "reply_failed" });
13524
+ agent.requeueFailedRuntimeDeliveries([failed]);
13525
+ flush();
11162
13526
  return;
11163
13527
  }
13528
+ const replyFailed = (e, inReplyTo) => {
13529
+ const code = sendErrorCode(e);
13530
+ if ((code === "agent_paused" || code === "send_forbidden") && typeof inReplyTo === "string") {
13531
+ inflight.delete(inReplyTo);
13532
+ agent.ackRuntimeMessages([inReplyTo]);
13533
+ }
13534
+ sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
13535
+ if (code === "agent_paused" || code === "send_forbidden") flush();
13536
+ };
11164
13537
  if (m.type === "send") {
11165
13538
  if (typeof m.channel_id !== "string" || !m.channel_id) {
11166
13539
  sendJson(sock, wsErrorFrame("invalid_request"));
@@ -11177,19 +13550,18 @@ async function handleInbound(sock, agent, data, inflight) {
11177
13550
  return;
11178
13551
  }
11179
13552
  try {
11180
- const r = await agent.sendA2A(m.channel_id, { a2a_version: a.a2a_version, kind: a.kind, object: a.object });
13553
+ const r = await agent.sendA2A(
13554
+ m.channel_id,
13555
+ { a2a_version: a.a2a_version, kind: a.kind, object: a.object },
13556
+ void 0,
13557
+ { agentTurn: isAgentTurn(agent, m) }
13558
+ );
11181
13559
  if (typeof m.in_reply_to === "string") {
11182
13560
  agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
11183
13561
  }
11184
- sendJson(sock, { type: "sent", message_id: r.message_id });
13562
+ sendJson(sock, { type: "sent", message_id: r.message_id, in_reply_to: m.in_reply_to });
11185
13563
  } catch (e) {
11186
- if (e instanceof ChannelPendingError) {
11187
- sendJson(sock, wsErrorFrame("channel_pending"));
11188
- } else if (e instanceof RateLimitedError) {
11189
- sendJson(sock, wsErrorFrame("rate_limited"));
11190
- } else {
11191
- sendJson(sock, wsErrorFrame("invalid_request"));
11192
- }
13564
+ replyFailed(e, m.in_reply_to);
11193
13565
  }
11194
13566
  return;
11195
13567
  }
@@ -11198,19 +13570,13 @@ async function handleInbound(sock, agent, data, inflight) {
11198
13570
  return;
11199
13571
  }
11200
13572
  try {
11201
- const r = await agent.send(m.channel_id, m.text);
13573
+ const r = await agent.send(m.channel_id, m.text, void 0, { agentTurn: isAgentTurn(agent, m) });
11202
13574
  if (typeof m.in_reply_to === "string") {
11203
13575
  agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
11204
13576
  }
11205
- sendJson(sock, { type: "sent", message_id: r.message_id });
13577
+ sendJson(sock, { type: "sent", message_id: r.message_id, in_reply_to: m.in_reply_to });
11206
13578
  } catch (e) {
11207
- if (e instanceof ChannelPendingError) {
11208
- sendJson(sock, wsErrorFrame("channel_pending"));
11209
- } else if (e instanceof RateLimitedError) {
11210
- sendJson(sock, wsErrorFrame("rate_limited"));
11211
- } else {
11212
- sendJson(sock, wsErrorFrame("invalid_request"));
11213
- }
13579
+ replyFailed(e, m.in_reply_to);
11214
13580
  }
11215
13581
  return;
11216
13582
  }
@@ -12587,8 +14953,7 @@ async function runServiceVerb(verb) {
12587
14953
  }
12588
14954
  async function main() {
12589
14955
  if (!nodeMajorOk(process.versions.node)) {
12590
- process.stderr.write(`[bridge] fatal: Node ${process.versions.node} unsupported \u2014 requires >= 24 (node:sqlite stable API)
12591
- `);
14956
+ writeServiceLog("error", "startup.node_unsupported");
12592
14957
  process.exit(1);
12593
14958
  }
12594
14959
  const verb = parseVerb(process.argv);
@@ -12606,7 +14971,7 @@ async function main() {
12606
14971
  }
12607
14972
  const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
12608
14973
  if (!credentialPath) {
12609
- process.stderr.write("usage: connect-agent-bridge --credential <path> [--store <path>] [--port N] [--bind ADDR]\n or: agent-bridge install|uninstall --credential <path>\n");
14974
+ writeServiceLog("error", "startup.credential_required.use_--credential");
12610
14975
  process.exit(2);
12611
14976
  }
12612
14977
  const storePath = argOrEnv("--store", "SECURECOMS_STORE");
@@ -12616,8 +14981,7 @@ async function main() {
12616
14981
  daemon = parseDaemonArgs(process.argv, process.env);
12617
14982
  validateBind(daemon.bind, daemon.tls);
12618
14983
  } catch (e) {
12619
- process.stderr.write(`[bridge] config error: ${e.message ?? e}
12620
- `);
14984
+ writeServiceLog("error", "startup.config_invalid", {}, e);
12621
14985
  process.exit(1);
12622
14986
  }
12623
14987
  const tokenFile = daemon.tokenFile ?? join13(dirname7(credentialPath), "bridge-token");
@@ -12648,12 +15012,8 @@ async function main() {
12648
15012
  const slug = slugFromCredentialPath(credentialPath);
12649
15013
  process.once("exit", () => removeConnectionFileIfOwned(homedir3(), identityId, instanceId));
12650
15014
  server.listen(daemon.port, daemon.bind, () => {
12651
- const scheme = tls ? "https" : "http";
12652
- const wsScheme = tls ? "wss" : "ws";
12653
- process.stderr.write(`[bridge] listening on ${scheme}://${daemon.bind}:${daemon.port} (agent WS at ${wsScheme}://${daemon.bind}:${daemon.port}/v1/ws)
12654
- `);
12655
- process.stderr.write(`[bridge] bearer token: ${tokenFingerprint(token)} (token file: ${tokenFile})
12656
- `);
15015
+ writeServiceLog("info", "startup.listening", { port: daemon.port, tls: !!tls });
15016
+ writeServiceLog("info", "startup.token_loaded");
12657
15017
  const addr = server.address();
12658
15018
  const boundPort = typeof addr === "object" && addr ? addr.port : daemon.port;
12659
15019
  try {
@@ -12666,11 +15026,9 @@ async function main() {
12666
15026
  instanceId,
12667
15027
  now: (/* @__PURE__ */ new Date()).toISOString()
12668
15028
  }));
12669
- process.stderr.write(`[bridge] connection file: ~/.securecoms/connections/${identityId}.json
12670
- `);
15029
+ writeServiceLog("info", "startup.connection_file_written");
12671
15030
  } catch (e) {
12672
- process.stderr.write(`[bridge] WARN: could not write connection file: ${e?.message ?? e}
12673
- `);
15031
+ writeServiceLog("warn", "startup.connection_file_write_failed", {}, e);
12674
15032
  }
12675
15033
  });
12676
15034
  }
@@ -12678,8 +15036,7 @@ var here = fileURLToPath2(import.meta.url);
12678
15036
  var isMain = !!process.argv[1] && refersToSameFile(here, process.argv[1], realpathSync);
12679
15037
  if (isMain) {
12680
15038
  main().catch((e) => {
12681
- process.stderr.write(`[bridge] fatal: ${e?.message ?? String(e)}
12682
- `);
15039
+ writeServiceLog("error", "startup.fatal", {}, e);
12683
15040
  process.exit(1);
12684
15041
  });
12685
15042
  }