@securecoms/agent-bridge 0.32.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -15433,6 +15433,18 @@ async function sendWithSelfHeal(session, channelId, build, publish) {
15433
15433
  // ../connect-sdk-ts/dist/groupkey/stub.js
15434
15434
  import { sha256 as sha2563 } from "@noble/hashes/sha2";
15435
15435
 
15436
+ // ../connect-sdk-ts/dist/receive/inbox.js
15437
+ function parseOpenedAt(v) {
15438
+ if (typeof v !== "object" || v === null)
15439
+ return void 0;
15440
+ const o = v;
15441
+ if (typeof o.groupId !== "string" || o.groupId.length === 0)
15442
+ return void 0;
15443
+ if (typeof o.epoch !== "number" || !Number.isSafeInteger(o.epoch) || o.epoch < 0)
15444
+ return void 0;
15445
+ return { groupId: o.groupId, epoch: o.epoch };
15446
+ }
15447
+
15436
15448
  // ../connect-sdk-ts/dist/spans/names.js
15437
15449
  var SPAN = {
15438
15450
  MESSAGE_SENT: "agentvault.connect.message.sent",
@@ -18161,7 +18173,9 @@ function decodeCustodyEntry(bytes) {
18161
18173
  if (typeof plaintextHex !== "string" || typeof rest.queueId !== "string" || typeof rest.channelId !== "string" || typeof rest.custodiedAt !== "number") {
18162
18174
  throw new Error("malformed custody entry");
18163
18175
  }
18164
- return { ...rest, plaintext: hexToBytes22(plaintextHex) };
18176
+ const { openedAt, ...others } = rest;
18177
+ const position = parseOpenedAt(openedAt);
18178
+ return { ...others, ...position ? { openedAt: position } : {}, plaintext: hexToBytes22(plaintextHex) };
18165
18179
  }
18166
18180
  function custodyAad(identityId, queueId) {
18167
18181
  return new TextEncoder().encode(`securecoms-custody-v1\0${identityId}\0${queueId}`);
@@ -18209,7 +18223,17 @@ var CODES = [
18209
18223
  // missed and before any write, so nothing landed. Definite: the committer's
18210
18224
  // work cannot name an agent in a humans-only enclave (admission refuses
18211
18225
  // one), so dropping the commit starts no resend loop.
18212
- { status: 403, code: "humans_only_enclave" }
18226
+ { status: 403, code: "humans_only_enclave" },
18227
+ // ADR-0024 step 2a PR-E (carry-A ruling §3 A): a V Add or Welcome whose
18228
+ // leaf-key claim map is missing, does not cover exactly the Welcome
18229
+ // targets, or names a key that is not the target's current vendable
18230
+ // KeyPackage's. Refused before any write; definite, so the committer drops
18231
+ // the commit, refetches the KeyPackages and rebuilds instead of resending
18232
+ // the same stale bytes forever.
18233
+ { status: 409, code: "voice_stale_key_package" },
18234
+ // PR-E: a Welcome target already holds a bound leaf in this V group (the
18235
+ // DS-level duplicate-leaf guard). Nothing written; a resend cannot land.
18236
+ { status: 409, code: "voice_leaf_already_bound" }
18213
18237
  ];
18214
18238
  var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
18215
18239
  var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
@@ -19256,6 +19280,31 @@ function ownLeafIndexOf(mgr) {
19256
19280
  function tagOf(mgr) {
19257
19281
  return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
19258
19282
  }
19283
+ function leafKeyClaims(targets, pkgs) {
19284
+ if (targets.length !== pkgs.length)
19285
+ throw new Error("leafKeyClaims: targets and KeyPackages differ in length");
19286
+ const out = {};
19287
+ targets.forEach((d, i) => {
19288
+ out[d] = bytesToHex22(pkgs[i].leafNode.signaturePublicKey);
19289
+ });
19290
+ return out;
19291
+ }
19292
+ function leafIndicesForSignatureKey(mgr, keyHex) {
19293
+ const tree = mgr.state?.ratchetTree;
19294
+ if (!tree)
19295
+ return [];
19296
+ const want = keyHex.toLowerCase();
19297
+ const out = [];
19298
+ const numLeaves = Math.floor((tree.length + 1) / 2);
19299
+ for (let i = 0; i < numLeaves; i++) {
19300
+ const node = tree[i * 2];
19301
+ if (!node || node.nodeType !== "leaf")
19302
+ continue;
19303
+ if (bytesToHex22(getSignaturePublicKeyFromLeafIndex2(tree, toLeafIndex2(i))) === want)
19304
+ out.push(i);
19305
+ }
19306
+ return out;
19307
+ }
19259
19308
  function tagOfState(state) {
19260
19309
  const m = new MLSGroupManager(state.ciphersuite);
19261
19310
  m.importState(state);
@@ -19370,6 +19419,12 @@ var SelfAddRefusedError = class extends Error {
19370
19419
  this.name = "SelfAddRefusedError";
19371
19420
  }
19372
19421
  };
19422
+ var VoiceMirrorAddRefusedError = class extends Error {
19423
+ constructor() {
19424
+ super("addMemberToLiveGroup: refusing a voice-channel Add of a device already in the roster mirror (it would mint a duplicate leaf)");
19425
+ this.name = "VoiceMirrorAddRefusedError";
19426
+ }
19427
+ };
19373
19428
  var VaultCredentialMismatchError = class extends Error {
19374
19429
  expected;
19375
19430
  got;
@@ -20859,10 +20914,28 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20859
20914
  * The row is deliberately NOT acked here — the subsequent ensureSession(join) /
20860
20915
  * joinAndReconcile consumes the same Welcome and acks it after joining.
20861
20916
  */
20862
- async peekWelcomeChannel(deviceId) {
20917
+ async peekWelcomeChannel(deviceId, opts = {}) {
20863
20918
  const rows = await this.o.backend.pullDelivery(deviceId);
20864
- const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
20865
- return w?.connect_channel_id ?? null;
20919
+ const asked = /* @__PURE__ */ new Set();
20920
+ for (const r of rows) {
20921
+ if (r.message_type !== "welcome" || r.connect_channel_id == null)
20922
+ continue;
20923
+ const ch = r.connect_channel_id;
20924
+ if (!opts.accept)
20925
+ return ch;
20926
+ if (asked.has(ch))
20927
+ continue;
20928
+ asked.add(ch);
20929
+ let ok = false;
20930
+ try {
20931
+ ok = await opts.accept(ch) === true;
20932
+ } catch {
20933
+ ok = false;
20934
+ }
20935
+ if (ok)
20936
+ return ch;
20937
+ }
20938
+ return null;
20866
20939
  }
20867
20940
  /**
20868
20941
  * Publish this device's KeyPackage if it holds none; otherwise a no-op that
@@ -20968,10 +21041,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20968
21041
  }
20969
21042
  const attempted = [];
20970
21043
  const failed = [];
21044
+ const discarded = [];
20971
21045
  for (const channelId of channelIds) {
20972
21046
  attempted.push(channelId);
20973
21047
  try {
20974
- await this.ensureSession(channelId, { enclaveId: "welcome-drain", bootstrap: false });
21048
+ await this.ensureSession(channelId, {
21049
+ enclaveId: "welcome-drain",
21050
+ bootstrap: false,
21051
+ onUnopenableWelcome: (groupId) => {
21052
+ discarded.push({ channelId, groupId });
21053
+ }
21054
+ });
20975
21055
  } catch (error) {
20976
21056
  failed.push({ channelId, error });
20977
21057
  }
@@ -20981,7 +21061,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20981
21061
  receipt = new DrainReceipt(DRAIN_RECEIPT_TOKEN);
20982
21062
  this.drainReceipts.set(receipt, this.nowMs());
20983
21063
  }
20984
- return { attempted, failed, skipped, unknown, pageFull, receipt };
21064
+ return { attempted, failed, skipped, unknown, pageFull, receipt, discarded };
20985
21065
  }
20986
21066
  /**
20987
21067
  * Resolve the channel's pending commit record, under the channel lock, and
@@ -21454,7 +21534,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21454
21534
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
21455
21535
  return MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
21456
21536
  });
21457
- const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
21537
+ const creatorLeafSignatureKey = bundle ? bytesToHex22(bundle.publicPackage.leafNode.signaturePublicKey) : void 0;
21538
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId], creatorLeafSignatureKey !== void 0 ? { creatorLeafSignatureKey } : void 0);
21458
21539
  if (init.created === false) {
21459
21540
  if (!this.isAdoptableOrphan(init, others)) {
21460
21541
  if (this.isOrphanShape(init, others))
@@ -21474,7 +21555,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21474
21555
  commit_epoch: Number(mgr.epoch),
21475
21556
  expected_epoch: init.current_epoch,
21476
21557
  member_device_ids: [this.o.deviceId, ...others],
21477
- welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : []
21558
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : [],
21559
+ // Task 24b (ruling §3 A): exact cover of the Welcome targets ({} when none).
21560
+ added_leaf_signature_keys: leafKeyClaims(welcome ? others : [], welcome ? pkgs : [])
21478
21561
  }, mgr, null);
21479
21562
  const res = await this.commitWithPendingRecord(channelId, rec, mgr);
21480
21563
  if (!res.landed) {
@@ -21488,15 +21571,32 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21488
21571
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
21489
21572
  if (!w)
21490
21573
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
21574
+ const aborted = () => opts.shouldAbort?.() === true;
21575
+ if (aborted())
21576
+ throw new Error("ensureSession(join): aborted by the caller");
21491
21577
  let mgr;
21492
21578
  try {
21493
21579
  mgr = await this.openWelcome(hexToBytes22(w.payload), [bundle, joinSlot]);
21494
21580
  } catch (err) {
21581
+ if (aborted())
21582
+ throw new Error("ensureSession(join): aborted by the caller");
21495
21583
  this.recordDecryptError(w, err);
21496
21584
  writeServiceLog2("warn", "session.unopenable_welcome_discarded", {}, err);
21497
21585
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
21586
+ try {
21587
+ opts.onUnopenableWelcome?.(w.group_id);
21588
+ } catch (e) {
21589
+ writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
21590
+ }
21591
+ try {
21592
+ this.o.onUnopenableWelcomeDiscarded?.({ channelId, groupId: w.group_id });
21593
+ } catch (e) {
21594
+ writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
21595
+ }
21498
21596
  return;
21499
21597
  }
21598
+ if (aborted())
21599
+ throw new Error("ensureSession(join): aborted by the caller");
21500
21600
  this.managers.set(channelId, mgr);
21501
21601
  await this.saveMain(channelId, mgr);
21502
21602
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
@@ -21707,6 +21807,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21707
21807
  await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "addMemberToLiveGroup");
21708
21808
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
21709
21809
  groupId = init.group_id;
21810
+ if (this.o.isVoiceChannel?.(channelId) === true) {
21811
+ const mirror = init.member_device_ids;
21812
+ if (!Array.isArray(mirror) || mirror.includes(newDeviceId)) {
21813
+ writeServiceLog2("warn", "admission.voice_device_already_in_mirror");
21814
+ throw new VoiceMirrorAddRefusedError();
21815
+ }
21816
+ }
21710
21817
  members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
21711
21818
  }
21712
21819
  if (new TextDecoder().decode(mgr.groupId) !== groupId) {
@@ -21725,7 +21832,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21725
21832
  expected_epoch: priorEpoch,
21726
21833
  member_device_ids: members,
21727
21834
  op: "add",
21728
- welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : []
21835
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : [],
21836
+ // Task 24b (ruling §3 A): the exact Welcome cover's leaf keys.
21837
+ added_leaf_signature_keys: leafKeyClaims(welcome ? [newDeviceId] : [], welcome ? [pkg] : [])
21729
21838
  }, mgr, prior), mgr);
21730
21839
  if (!res.landed) {
21731
21840
  const e = res.error;
@@ -22046,6 +22155,181 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22046
22155
  return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
22047
22156
  }
22048
22157
  }
22158
+ /**
22159
+ * ADR-0024 step 2a, round 2 F4 (Task 24): the committer sweep's DEVICE-level
22160
+ * Remove of one leaf the server's committer-work lists in `remove_devices`
22161
+ * (on the hidden voice channel V: a stale device of an agent or relay
22162
+ * identity that is still on V's roster).
22163
+ *
22164
+ * ⛔ WHY NOT {@link removeDeviceFromLiveGroup}. That path resolves a FOREIGN
22165
+ * identity's leaf by add-order position, which RFC 9420's leftmost-blank
22166
+ * refill permutes. Here the identity keeps a CURRENT device, so a wrong guess
22167
+ * evicts the current device's leaf while the distribute's `removed_device_id`
22168
+ * makes the server drop the STALE device from its mirror. The current device
22169
+ * is then in the roster with no leaf, outside the adds delta forever: an
22170
+ * unrecoverable wedge. So this resolves the leaf by EXACT evidence only:
22171
+ *
22172
+ * - the target device's PUBLISHED KeyPackage, whose LeafNode signature key
22173
+ * is matched against THIS committer's live tree (the device-level leaf
22174
+ * predicate, {@link leafIndicesForKeyPackage}). `generateKeyPackage`
22175
+ * mints a fresh signature keypair per call, so a match names exactly the
22176
+ * leaf that KeyPackage was Added as;
22177
+ * - exactly ONE matching leaf, or nothing is committed;
22178
+ * - that leaf's credential identity is read from the tree, never from the
22179
+ * server, and is what the distribute names as `removed_identity_id`.
22180
+ *
22181
+ * Outcomes. `"removed"`: one leaf evicted and the distribute landed.
22182
+ * `"unresolved"`: no KeyPackage is published for the device, or it matches no
22183
+ * leaf (the device rotated it after its Add, or the leaf is already gone).
22184
+ * Nothing is committed: the leaf, if any, stays, and is reported, never
22185
+ * guessed. The server's next delta lists it again (the caller's throttle
22186
+ * bounds the retries).
22187
+ *
22188
+ * Refusals (thrown before any commit is built, so nothing is distributed):
22189
+ * the committer's own device (`own_device`), any leaf of the committer's own
22190
+ * identity, the committer's own leaf, or an unknown own leaf
22191
+ * (`own_leaf` / `own_leaf_unknown`, {@link OwnLeafRemoveRefusedError}); two
22192
+ * or more matching leaves; a held group that is not the channel's active
22193
+ * group (#757 binding, checked BEFORE init).
22194
+ */
22195
+ async removeStaleDeviceLeafFromLiveGroup(channelId, deviceId, bound) {
22196
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
22197
+ await this.requirePendingResolvedUnlocked(channelId);
22198
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, 0, bound);
22199
+ }));
22200
+ }
22201
+ async removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt, bound) {
22202
+ if (await this.custodyHeld(channelId))
22203
+ throw new CustodyPersistError(channelId);
22204
+ if (deviceId === this.o.deviceId)
22205
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
22206
+ const mgr = await this.manager(channelId);
22207
+ const resolved = bound ? this.resolveBoundDeviceLeaf(mgr, bound) : await this.resolveServedKeyPackageLeaf(mgr, deviceId);
22208
+ if (resolved === "unresolved")
22209
+ return "unresolved";
22210
+ const { leafIndex, leafIdentity } = resolved;
22211
+ await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "removeStaleDeviceLeafFromLiveGroup");
22212
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
22213
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
22214
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: group binding mismatch");
22215
+ }
22216
+ const priorEpoch = Number(mgr.epoch);
22217
+ const prior = tagOf(mgr);
22218
+ const { commit } = await mgr.removeMembers([leafIndex]);
22219
+ if (Number(mgr.epoch) !== priorEpoch + 1) {
22220
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: unexpected epoch drift before distribute");
22221
+ }
22222
+ const members = init.member_device_ids ?? [this.o.deviceId];
22223
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
22224
+ device_id: this.o.deviceId,
22225
+ commit: bytesToHex22(commit),
22226
+ commit_epoch: Number(mgr.epoch),
22227
+ expected_epoch: priorEpoch,
22228
+ member_device_ids: members,
22229
+ op: "remove",
22230
+ removed_identity_id: leafIdentity,
22231
+ removed_device_id: deviceId,
22232
+ welcomes: []
22233
+ }, mgr, prior), mgr);
22234
+ if (!res.landed) {
22235
+ const e = res.error;
22236
+ if (isSupersededRefusal(e))
22237
+ throw new GroupSupersededError(channelId, init.group_id);
22238
+ if (_attempt >= 2 || !isEpochConflict(e))
22239
+ throw e;
22240
+ try {
22241
+ await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
22242
+ } catch (e2) {
22243
+ if (e2 instanceof CustodyPersistError)
22244
+ throw e2;
22245
+ }
22246
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt + 1, bound);
22247
+ }
22248
+ return "removed";
22249
+ }
22250
+ /** F4 (Task 24): the target's PUBLISHED KeyPackage, matched by key against
22251
+ * the committer's tree. Unchanged from Task 24 and its fix round 1. */
22252
+ async resolveServedKeyPackageLeaf(mgr, deviceId) {
22253
+ const kps = await this.o.backend.fetchKeyPackages([deviceId]);
22254
+ const hex = kps[deviceId];
22255
+ if (!hex) {
22256
+ writeServiceLog2("info", "committer.remove_device_unresolved", { no_key_package: true });
22257
+ return "unresolved";
22258
+ }
22259
+ const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
22260
+ const leaves = leafIndicesForKeyPackage(mgr, pkg);
22261
+ if (leaves.length === 0) {
22262
+ writeServiceLog2("info", "committer.remove_device_unresolved", { no_matching_leaf: true });
22263
+ return "unresolved";
22264
+ }
22265
+ if (leaves.length > 1) {
22266
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous key match (more than one leaf)");
22267
+ }
22268
+ const leafIndex = leaves[0];
22269
+ const tree = mgr.state?.ratchetTree;
22270
+ if (!tree)
22271
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
22272
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
22273
+ if (cred.credentialType !== "basic") {
22274
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: leaf carries no basic credential");
22275
+ }
22276
+ const leafIdentity = new TextDecoder().decode(cred.identity);
22277
+ const ownIdentity = new TextDecoder().decode(this.o.identity);
22278
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
22279
+ if (!leafIdentity || leafIdentity === ownIdentity) {
22280
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf");
22281
+ }
22282
+ if (mgr.leafIndicesForIdentity(leafIdentity).length <= 1) {
22283
+ writeServiceLog2("warn", "committer.remove_device_refused_last_leaf", {});
22284
+ return "unresolved";
22285
+ }
22286
+ return { leafIndex, leafIdentity };
22287
+ }
22288
+ /**
22289
+ * ADR-0024 step 2a Task 24b (architect ruling "carry A", §3 B and §4 R5):
22290
+ * resolve the leaf by the SERVER-BOUND leaf signature key (committer work's
22291
+ * `remove_device_leaves`). The server recorded the key when the leaf was
22292
+ * born, from the KeyPackage it vended, so it survives the target's
22293
+ * KeyPackage rotation, revocation and deletion, which the served-KeyPackage
22294
+ * path cannot.
22295
+ *
22296
+ * - Leaves are matched by the bound key ONLY: exactly one match, else
22297
+ * `unresolved` (zero) or a throw (two or more). ⛔ NEVER a fall back to
22298
+ * the served KeyPackage: two evidence sources that disagree fail closed.
22299
+ * - The matched leaf's basic-credential identity must equal the server's
22300
+ * `identityId` for the device (R4), else a throw.
22301
+ * - The committer's own leaf index (or an unknown one) is refused
22302
+ * ({@link refuseOwnLeafRemove}); the own device was refused by the caller.
22303
+ * - R5: a leaf of the committer's OWN identity is admitted (a sibling
22304
+ * device: a revoked laptop). The brick class the own-identity refusal
22305
+ * guards came from positional or identity-level resolution, which this
22306
+ * exact-key path cannot reach, and the own leaf index is still refused.
22307
+ * - No last-leaf refusal: the binding is exact evidence, and an identity's
22308
+ * last leaf is exactly the revoked single-device case (and carry A's
22309
+ * Remove before its re-Add).
22310
+ * Synchronous and before any commit is built: a refusal distributes nothing.
22311
+ */
22312
+ resolveBoundDeviceLeaf(mgr, bound) {
22313
+ const leaves = leafIndicesForSignatureKey(mgr, bound.leafSignatureKeyHex);
22314
+ if (leaves.length === 0) {
22315
+ writeServiceLog2("info", "committer.remove_device_unresolved", { no_bound_leaf: true });
22316
+ return "unresolved";
22317
+ }
22318
+ if (leaves.length > 1) {
22319
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous bound-key match (more than one leaf)");
22320
+ }
22321
+ const leafIndex = leaves[0];
22322
+ const tree = mgr.state?.ratchetTree;
22323
+ if (!tree)
22324
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
22325
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
22326
+ const leafIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
22327
+ if (!leafIdentity || leafIdentity !== bound.identityId) {
22328
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: the bound leaf's credential identity is not the device's identity");
22329
+ }
22330
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
22331
+ return { leafIndex, leafIdentity };
22332
+ }
22049
22333
  /**
22050
22334
  * Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
22051
22335
  * targeted by its owner-observed leaf `signaturePublicKey` against the live
@@ -22814,7 +23098,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22814
23098
  * read #750 made in here is gone (ruling §8.2).
22815
23099
  */
22816
23100
  async trySupersede(channelId, row, callerHeldGroupId, owner, mode, pullRows) {
22817
- const refuse2 = (msg) => {
23101
+ const refuse3 = (msg) => {
22818
23102
  this.recordDecryptError(row, new Error(msg));
22819
23103
  const now = Date.now();
22820
23104
  for (const [k, v] of this.supersedeRefusals) {
@@ -22847,34 +23131,34 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22847
23131
  att = await this.o.backend.fetchResetAttestation(row.group_id);
22848
23132
  }
22849
23133
  if (!att) {
22850
- return refuse2(`supersede refused: no attestation for group ${row.group_id}`);
23134
+ return refuse3(`supersede refused: no attestation for group ${row.group_id}`);
22851
23135
  }
22852
23136
  if (att.signerIdentityId !== owner.identityId) {
22853
- return refuse2("supersede refused: signer is not the pinned owner");
23137
+ return refuse3("supersede refused: signer is not the pinned owner");
22854
23138
  }
22855
23139
  let content;
22856
23140
  try {
22857
23141
  content = JSON.parse(att.signedContent);
22858
23142
  } catch {
22859
- return refuse2("supersede refused: unparseable signed_content");
23143
+ return refuse3("supersede refused: unparseable signed_content");
22860
23144
  }
22861
23145
  if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
22862
- return refuse2("supersede refused: attestation channel/group mismatch");
23146
+ return refuse3("supersede refused: attestation channel/group mismatch");
22863
23147
  }
22864
23148
  if (content.purpose !== RESET_SUPERSEDE_PURPOSE) {
22865
- return refuse2("supersede refused: attestation purpose is not connect-reset-supersede");
23149
+ return refuse3("supersede refused: attestation purpose is not connect-reset-supersede");
22866
23150
  }
22867
23151
  const targetCreatedMs = signedCreatedAtMs(content);
22868
23152
  if (targetCreatedMs === null) {
22869
- return refuse2("supersede refused: unparseable attestation created_at");
23153
+ return refuse3("supersede refused: unparseable attestation created_at");
22870
23154
  }
22871
23155
  const targetPrev = signedPrevLink(content, row.group_id);
22872
23156
  if (targetPrev === null) {
22873
- return refuse2("supersede refused: unparseable attestation prev_group_id");
23157
+ return refuse3("supersede refused: unparseable attestation prev_group_id");
22874
23158
  }
22875
23159
  const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
22876
23160
  if (!ok) {
22877
- return refuse2("supersede refused: signature does not verify against pinned owner key");
23161
+ return refuse3("supersede refused: signature does not verify against pinned owner key");
22878
23162
  }
22879
23163
  const targetLink = targetPrev.chained ? { createdAtMs: targetCreatedMs, chained: true, prevGroupId: targetPrev.prev } : { createdAtMs: targetCreatedMs, chained: false, prevGroupId: null };
22880
23164
  if (callerHeldGroupId !== null && callerHeldGroupId !== row.group_id) {
@@ -22884,7 +23168,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22884
23168
  if (v.kind === "refuse") {
22885
23169
  if (v.notNewer)
22886
23170
  this.supersedeNotNewer.add(`${channelId}:${row.group_id}`);
22887
- return refuse2(v.reason);
23171
+ return refuse3(v.reason);
22888
23172
  }
22889
23173
  }
22890
23174
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
@@ -22892,7 +23176,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22892
23176
  if (!bundle && !supersedeSlot) {
22893
23177
  if (mode === "adopt")
22894
23178
  return { kind: "no_bundle" };
22895
- return refuse2("supersede refused: no pending KeyPackage bundle to open the new Welcome");
23179
+ return refuse3("supersede refused: no pending KeyPackage bundle to open the new Welcome");
22896
23180
  }
22897
23181
  return await this.locks.run(channelId, async () => {
22898
23182
  let inLockHeld;
@@ -22939,7 +23223,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22939
23223
  return { kind: "unopenable", requested };
22940
23224
  }
22941
23225
  if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
22942
- return refuse2("supersede refused: post-join group id != attested new_group_id");
23226
+ return refuse3("supersede refused: post-join group id != attested new_group_id");
22943
23227
  }
22944
23228
  let dead = null;
22945
23229
  try {
@@ -23866,6 +24150,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23866
24150
  if (supersededChannelIds.length) {
23867
24151
  Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
23868
24152
  }
24153
+ if (w2Kept.size) {
24154
+ Object.defineProperty(appEntries, "pendingWelcomeChannelIds", { value: [...w2Kept], enumerable: false });
24155
+ }
23869
24156
  return appEntries;
23870
24157
  }
23871
24158
  /**
@@ -24064,15 +24351,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
24064
24351
  done();
24065
24352
  };
24066
24353
  };
24354
+ const fromManager = async (mgr) => {
24355
+ const openedAt = { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
24356
+ const key = await mgr.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32);
24357
+ return { key, openedAt };
24358
+ };
24067
24359
  const derive = keyFrom ? () => {
24068
24360
  takeInFlight();
24069
- return keyFrom.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32);
24070
- } : () => {
24361
+ return fromManager(keyFrom);
24362
+ } : async () => {
24071
24363
  takeInFlight();
24072
- return this.exporterKeyUnfenced(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch);
24364
+ return fromManager(await this.manager(envelope.channel_id));
24073
24365
  };
24074
24366
  try {
24075
- const key = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
24367
+ const { key, openedAt } = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
24076
24368
  const aad = await buildAssociatedData({
24077
24369
  message_id: envelope.message_id,
24078
24370
  channel_id: envelope.channel_id,
@@ -24096,7 +24388,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
24096
24388
  voiceMode: row.voice_mode ?? void 0,
24097
24389
  voiceDefaultResponder: row.voice_default_responder ?? void 0,
24098
24390
  ...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
24099
- payloadHex: row.payload
24391
+ payloadHex: row.payload,
24392
+ openedAt
24100
24393
  };
24101
24394
  const custodiedAt = this.nowMs();
24102
24395
  this.custody.set(row.queue_id, entry);
@@ -25651,9 +25944,9 @@ var RestMlsBackend = class {
25651
25944
  current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
25652
25945
  };
25653
25946
  }
25654
- async initConnectChannelGroup(channelId, memberDeviceIds) {
25947
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
25655
25948
  const path = `/api/v1/mls/connect-channels/${channelId}/init`;
25656
- const body = { member_device_ids: memberDeviceIds };
25949
+ const body = opts?.creatorLeafSignatureKey !== void 0 ? { member_device_ids: memberDeviceIds, creator_leaf_signature_key: opts.creatorLeafSignatureKey } : { member_device_ids: memberDeviceIds };
25657
25950
  let j;
25658
25951
  for (let attempt = 0; ; attempt++) {
25659
25952
  try {
@@ -25895,6 +26188,22 @@ var RestMlsBackend = class {
25895
26188
  throw new Error(`unrequested-removals: reports is ${describeType(rows)}`);
25896
26189
  return rows;
25897
26190
  }
26191
+ async signalVoiceLeafUnusable(groupId, deviceId) {
26192
+ const res = await this.fetchBounded("mls_voice_leaf_unusable", "control", this.opts.baseUrl + `/api/v1/mls/groups/${groupId}/voice-leaf-unusable`, { method: "POST", headers: this.headers(), body: JSON.stringify({ device_id: deviceId }) });
26193
+ const text = await res.text().catch(() => "");
26194
+ if (res.ok)
26195
+ return { status: res.status };
26196
+ let parsed = null;
26197
+ if (text) {
26198
+ try {
26199
+ parsed = JSON.parse(text);
26200
+ } catch {
26201
+ parsed = null;
26202
+ }
26203
+ }
26204
+ const code = parseErrorCode(parsed);
26205
+ return code === void 0 ? { status: res.status } : { status: res.status, code };
26206
+ }
25898
26207
  async getCommitterWork(channelId) {
25899
26208
  const j = await this.get("mls_committer_work", `/api/v1/mls/connect-channels/${channelId}/committer-work`);
25900
26209
  const requireList = (field) => {
@@ -25904,7 +26213,29 @@ var RestMlsBackend = class {
25904
26213
  }
25905
26214
  return v.map(String);
25906
26215
  };
25907
- return { adds: requireList("adds"), removes: requireList("removes") };
26216
+ const work = {
26217
+ adds: requireList("adds"),
26218
+ removes: requireList("removes")
26219
+ };
26220
+ if (j?.remove_devices !== void 0) {
26221
+ work.removeDevices = requireList("remove_devices");
26222
+ }
26223
+ const rawLeaves = j?.remove_device_leaves;
26224
+ if (rawLeaves !== void 0) {
26225
+ if (!Array.isArray(rawLeaves))
26226
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves", describeType(rawLeaves));
26227
+ const seen = /* @__PURE__ */ new Set();
26228
+ work.removeDeviceLeaves = rawLeaves.map((e) => {
26229
+ const o = e !== null && typeof e === "object" && !Array.isArray(e) ? e : null;
26230
+ const key = typeof o?.leaf_signature_key === "string" ? o.leaf_signature_key.toLowerCase() : null;
26231
+ if (!o || typeof o.device_id !== "string" || !o.device_id || typeof o.identity_id !== "string" || !o.identity_id || key === null || key.length === 0 || key.length % 2 !== 0 || !/^[0-9a-f]+$/.test(key) || seen.has(o.device_id)) {
26232
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves[]", describeType(e));
26233
+ }
26234
+ seen.add(o.device_id);
26235
+ return { deviceId: o.device_id, identityId: o.identity_id, leafSignatureKeyHex: key };
26236
+ });
26237
+ }
26238
+ return work;
25908
26239
  }
25909
26240
  async acquireCommitLease(channelId) {
25910
26241
  try {
@@ -25954,6 +26285,48 @@ var RestMlsBackend = class {
25954
26285
 
25955
26286
  // ../connect-sdk-ts/dist/mls/fake-backend.js
25956
26287
  var DELIVERY_PULL_LIMIT = 100;
26288
+ function keyPackageSignatureKeyHex(kpHex) {
26289
+ const b = hexToBytes22(kpHex);
26290
+ let at = 0;
26291
+ const u16 = () => {
26292
+ if (at + 2 > b.length)
26293
+ throw new Error("t");
26294
+ const v = b[at] << 8 | b[at + 1];
26295
+ at += 2;
26296
+ return v;
26297
+ };
26298
+ const opaque = () => {
26299
+ if (at >= b.length)
26300
+ throw new Error("t");
26301
+ const prefix = b[at] >> 6;
26302
+ if (prefix === 3)
26303
+ throw new Error("t");
26304
+ const size = 1 << prefix;
26305
+ if (at + size > b.length)
26306
+ throw new Error("t");
26307
+ let n = b[at] & 63;
26308
+ for (let i = 1; i < size; i++)
26309
+ n = n << 8 | b[at + i];
26310
+ at += size;
26311
+ if (at + n > b.length)
26312
+ throw new Error("t");
26313
+ const out = b.slice(at, at + n);
26314
+ at += n;
26315
+ return out;
26316
+ };
26317
+ try {
26318
+ if (u16() !== 1 || u16() !== 5)
26319
+ return null;
26320
+ u16();
26321
+ u16();
26322
+ opaque();
26323
+ opaque();
26324
+ const sig = opaque();
26325
+ return sig.length > 0 ? bytesToHex22(sig) : null;
26326
+ } catch {
26327
+ return null;
26328
+ }
26329
+ }
25957
26330
  var FakeConnectBackend = class _FakeConnectBackend {
25958
26331
  // Key-package store: deviceId → hex-encoded KeyPackage
25959
26332
  kps = /* @__PURE__ */ new Map();
@@ -26038,6 +26411,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
26038
26411
  * server's purge_device_queue. */
26039
26412
  linkIdentityDevice(identityId, deviceId) {
26040
26413
  this.deviceByIdentity.set(identityId, deviceId);
26414
+ this.identityByDevice.set(deviceId, identityId);
26041
26415
  }
26042
26416
  async publishKeyPackage(deviceId, keyPackageHex) {
26043
26417
  this.kps.set(deviceId, keyPackageHex);
@@ -26087,8 +26461,38 @@ var FakeConnectBackend = class _FakeConnectBackend {
26087
26461
  current_epoch: this.epochByChannel.get(channelId) ?? 0
26088
26462
  };
26089
26463
  }
26090
- async initConnectChannelGroup(channelId, memberDeviceIds) {
26464
+ /** ADR-0024 step 2a (P-7): channel ids the test registers as a hidden
26465
+ * voice channel V. Init on an EXISTING V returns it unchanged and merges
26466
+ * nothing, as the real backend does. */
26467
+ voiceChannelIds = /* @__PURE__ */ new Set();
26468
+ /** Task 24b (architect ruling "carry A", §3 A-C), modelled for a channel in
26469
+ * {@link voiceChannelIds} only, as the PR-E server does for V:
26470
+ * `channel -> device -> { key (hex), unusableSignaledAt }`, written ONLY
26471
+ * when a V leaf is born (the creator's init, an Add's Welcome) from the
26472
+ * verified claim, and deleted when a Remove of that device (or its
26473
+ * identity) lands. */
26474
+ voiceLeafBindings = /* @__PURE__ */ new Map();
26475
+ /** The device -> identity the server knows (the devices table). Written by
26476
+ * {@link linkIdentityDevice}; read for `remove_device_leaves`. */
26477
+ identityByDevice = /* @__PURE__ */ new Map();
26478
+ static voiceRefusal(path, code) {
26479
+ return new MlsBackendHttpError(path, 409, code);
26480
+ }
26481
+ /** The current vendable KeyPackage key of `deviceId`, or null. */
26482
+ currentLeafKey(deviceId) {
26483
+ const hex = this.kps.get(deviceId);
26484
+ return hex ? keyPackageSignatureKeyHex(hex) : null;
26485
+ }
26486
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
26091
26487
  const alreadyExists = this.groupIdByChannel.has(channelId);
26488
+ if (!alreadyExists && this.voiceChannelIds.has(channelId)) {
26489
+ const creator = memberDeviceIds[0];
26490
+ const current = creator ? this.currentLeafKey(creator) : null;
26491
+ if (!opts?.creatorLeafSignatureKey || current === null || opts.creatorLeafSignatureKey.toLowerCase() !== current) {
26492
+ throw new Error(`/api/v1/mls/connect-channels/${channelId}/init failed: 409 {"detail":"voice_stale_key_package"}`);
26493
+ }
26494
+ this.voiceLeafBindings.set(channelId, /* @__PURE__ */ new Map([[creator, { key: current, unusableSignaledAt: null }]]));
26495
+ }
26092
26496
  let groupId = this.groupIdByChannel.get(channelId);
26093
26497
  if (!groupId) {
26094
26498
  groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
@@ -26096,7 +26500,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
26096
26500
  this.channelByGroup.set(groupId, channelId);
26097
26501
  this.epochByChannel.set(channelId, 0);
26098
26502
  }
26099
- const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
26503
+ const members = alreadyExists && this.voiceChannelIds.has(channelId) ? [...this.membersByChannel.get(channelId) ?? []] : [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
26100
26504
  this.membersByChannel.set(channelId, members);
26101
26505
  return {
26102
26506
  group_id: groupId,
@@ -26233,6 +26637,27 @@ var FakeConnectBackend = class _FakeConnectBackend {
26233
26637
  throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
26234
26638
  }
26235
26639
  }
26640
+ const vBindings = this.voiceChannelIds.has(channelId) ? this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map() : null;
26641
+ let bindNow = [];
26642
+ if (vBindings && req.commit && req.op !== "remove") {
26643
+ const targets = (req.welcomes ?? []).map((w) => w.target_device_id);
26644
+ const claims = req.added_leaf_signature_keys;
26645
+ const path = _FakeConnectBackend.distributePath(groupId);
26646
+ if (!claims)
26647
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
26648
+ const claimed = Object.keys(claims);
26649
+ if (claimed.length !== targets.length || targets.some((t) => !(t in claims))) {
26650
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
26651
+ }
26652
+ for (const t of targets) {
26653
+ const current = this.currentLeafKey(t);
26654
+ if (current === null || claims[t].toLowerCase() !== current)
26655
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
26656
+ if (vBindings.has(t))
26657
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_leaf_already_bound");
26658
+ }
26659
+ bindNow = targets.map((t) => [t, claims[t].toLowerCase()]);
26660
+ }
26236
26661
  if (req.commit) {
26237
26662
  const log = this.commitLog.get(groupId) ?? [];
26238
26663
  log.push({
@@ -26255,6 +26680,12 @@ var FakeConnectBackend = class _FakeConnectBackend {
26255
26680
  this.membersByChannel.set(channelId, remaining);
26256
26681
  if (evictedDevice)
26257
26682
  this.queues.delete(evictedDevice);
26683
+ if (vBindings) {
26684
+ for (const d of [...vBindings.keys()]) {
26685
+ if (d === evictedDevice || !req.removed_device_id && this.identityByDevice.get(d) === req.removed_identity_id)
26686
+ vBindings.delete(d);
26687
+ }
26688
+ }
26258
26689
  } else {
26259
26690
  const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
26260
26691
  this.membersByChannel.set(channelId, members);
@@ -26278,6 +26709,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
26278
26709
  });
26279
26710
  }
26280
26711
  }
26712
+ if (vBindings && bindNow.length > 0) {
26713
+ for (const [d, k] of bindNow)
26714
+ vBindings.set(d, { key: k, unusableSignaledAt: null });
26715
+ this.voiceLeafBindings.set(channelId, vBindings);
26716
+ }
26281
26717
  for (const w of req.welcomes ?? []) {
26282
26718
  this.q(w.target_device_id).push({
26283
26719
  queue_id: "q-" + this._seq++,
@@ -26338,6 +26774,51 @@ var FakeConnectBackend = class _FakeConnectBackend {
26338
26774
  async getCommitterWork(_channelId) {
26339
26775
  return { adds: [], removes: [] };
26340
26776
  }
26777
+ /** Task 24b (ruling §3 C): the carry-A signal, every call recorded. Gates
26778
+ * modelled: the group is a V (else 404), a binding exists (else 409), the
26779
+ * device's current vendable key differs from the bound one (409
26780
+ * `voice_leaf_key_current` when equal, `voice_no_current_key_package`
26781
+ * when it has none). An already-signalled row is an idempotent 202. The
26782
+ * bind and roster gates are the server's (backend tests). */
26783
+ signalVoiceLeafUnusableCalls = [];
26784
+ async signalVoiceLeafUnusable(groupId, deviceId) {
26785
+ this.signalVoiceLeafUnusableCalls.push({ groupId, deviceId });
26786
+ const channelId = this.channelByGroup.get(groupId);
26787
+ if (!channelId || !this.voiceChannelIds.has(channelId))
26788
+ return { status: 404, code: "Not Found" };
26789
+ const row = this.voiceLeafBindings.get(channelId)?.get(deviceId);
26790
+ if (!row)
26791
+ return { status: 409, code: "voice_leaf_binding_missing" };
26792
+ const current = this.currentLeafKey(deviceId);
26793
+ if (current === null)
26794
+ return { status: 409, code: "voice_no_current_key_package" };
26795
+ if (current === row.key)
26796
+ return { status: 409, code: "voice_leaf_key_current" };
26797
+ if (row.unusableSignaledAt === null)
26798
+ row.unusableSignaledAt = this.now();
26799
+ return { status: 202 };
26800
+ }
26801
+ /** Task 24b test seam: V's committer work as the PR-E server derives it,
26802
+ * for a test's `roster` (the server's roster devices): `adds` = roster
26803
+ * devices not in the mirror; `remove_devices` / `remove_device_leaves` =
26804
+ * roster devices whose binding is signalled unusable (NOT in `adds`: they
26805
+ * are still in the mirror). The re-Add follows only from a later call,
26806
+ * after the Remove landed and the mirror dropped the device. */
26807
+ voiceCommitterWork(channelId, roster) {
26808
+ const mirror = this.membersByChannel.get(channelId) ?? [];
26809
+ const bindings = this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map();
26810
+ const signalled = roster.filter((d) => mirror.includes(d) && bindings.get(d)?.unusableSignaledAt != null);
26811
+ return {
26812
+ adds: roster.filter((d) => !mirror.includes(d)),
26813
+ removes: [],
26814
+ removeDevices: signalled,
26815
+ removeDeviceLeaves: signalled.map((d) => ({
26816
+ deviceId: d,
26817
+ identityId: this.identityByDevice.get(d) ?? "",
26818
+ leafSignatureKeyHex: bindings.get(d).key
26819
+ }))
26820
+ };
26821
+ }
26341
26822
  async acquireCommitLease(_channelId) {
26342
26823
  return "fake-lease-token";
26343
26824
  }
@@ -26830,6 +27311,7 @@ function openSchema(db) {
26830
27311
  ensureToolGrantTables(db);
26831
27312
  ensureToolGrantAppliedTable(db);
26832
27313
  ensureRoomSearchCapTable(db);
27314
+ ensureVoiceChannelParentTable(db);
26833
27315
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
26834
27316
  }
26835
27317
  function ensureMlsCustody(db) {
@@ -27015,6 +27497,16 @@ function ensureToolGrantAppliedTable(db) {
27015
27497
  );
27016
27498
  `);
27017
27499
  }
27500
+ function ensureVoiceChannelParentTable(db) {
27501
+ db.exec(`
27502
+ CREATE TABLE IF NOT EXISTS voice_channel_parent (
27503
+ identity_id TEXT NOT NULL,
27504
+ voice_channel_id TEXT NOT NULL,
27505
+ parent_channel_id TEXT NOT NULL,
27506
+ PRIMARY KEY (identity_id, voice_channel_id)
27507
+ );
27508
+ `);
27509
+ }
27018
27510
  function ensureRoomSearchCapTable(db) {
27019
27511
  db.exec(`
27020
27512
  CREATE TABLE IF NOT EXISTS room_search_cap (
@@ -27706,6 +28198,21 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27706
28198
  VALUES (?,?,?,?)
27707
28199
  ON CONFLICT(identity_id, channel_id) DO UPDATE SET is_dm=excluded.is_dm, updated_at=excluded.updated_at`).run(this.id, channelId, isDm ? 1 : 0, Date.now());
27708
28200
  }
28201
+ /** ADR-0024 Task 20 (final review I-1): records V -> P once; a later, different parent is ignored (immutable link). */
28202
+ recordVoiceChannelParent(voiceChannelId, parentChannelId) {
28203
+ this.db.prepare(`INSERT INTO voice_channel_parent (identity_id, voice_channel_id, parent_channel_id) VALUES (?,?,?)
28204
+ ON CONFLICT(identity_id, voice_channel_id) DO NOTHING`).run(this.id, voiceChannelId, parentChannelId);
28205
+ }
28206
+ /** The recorded parent of a voice channel, if any. */
28207
+ voiceChannelParent(voiceChannelId) {
28208
+ const row = this.db.prepare("SELECT parent_channel_id FROM voice_channel_parent WHERE identity_id=? AND voice_channel_id=?").get(this.id, voiceChannelId);
28209
+ return row?.parent_channel_id;
28210
+ }
28211
+ /** Every recorded voice channel of a parent conversation. */
28212
+ voiceChannelsOf(parentChannelId) {
28213
+ const rows = this.db.prepare("SELECT voice_channel_id FROM voice_channel_parent WHERE identity_id=? AND parent_channel_id=?").all(this.id, parentChannelId);
28214
+ return rows.map((r) => r.voice_channel_id);
28215
+ }
27709
28216
  getChannelMeta(channelId) {
27710
28217
  const row = this.db.prepare("SELECT enclave_id, is_dm FROM channel_meta WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
27711
28218
  if (!row)
@@ -32410,7 +32917,7 @@ var EmbedderClient = class {
32410
32917
  resolve4(ok);
32411
32918
  }
32412
32919
  }
32413
- const refuse2 = (reason) => {
32920
+ const refuse3 = (reason) => {
32414
32921
  s.destroy();
32415
32922
  if (!settled) {
32416
32923
  this.fail(reason);
@@ -32429,22 +32936,22 @@ var EmbedderClient = class {
32429
32936
  return;
32430
32937
  }
32431
32938
  const f2 = this.chan.open(line);
32432
- if (!f2) return refuse2("embedder_unavailable");
32939
+ if (!f2) return refuse3("embedder_unavailable");
32433
32940
  this.onFrame(f2);
32434
32941
  return;
32435
32942
  }
32436
32943
  const f = JSON.parse(line);
32437
- if (!f || typeof f !== "object") return refuse2("embedder_unavailable");
32944
+ if (!f || typeof f !== "object") return refuse3("embedder_unavailable");
32438
32945
  if (!ns) {
32439
- if (f.t !== "challenge" || f.protocol !== EMBEDDER_PROTOCOL || typeof f.ns !== "string" || !/^[0-9a-f]{32}$/.test(f.ns)) return refuse2(f.t === "challenge" ? "embedder_mismatch" : "embedder_unavailable");
32946
+ if (f.t !== "challenge" || f.protocol !== EMBEDDER_PROTOCOL || typeof f.ns !== "string" || !/^[0-9a-f]{32}$/.test(f.ns)) return refuse3(f.t === "challenge" ? "embedder_mismatch" : "embedder_unavailable");
32440
32947
  ns = f.ns;
32441
32948
  s.write(JSON.stringify({ t: "hello", nc, mac_c: macClient(token, ns, nc) }) + "\n");
32442
32949
  return;
32443
32950
  }
32444
- if (f.t !== "hello" || typeof f.mac_s !== "string" || !macEqual(f.mac_s, macServer(token, ns, nc))) return refuse2("embedder_unavailable");
32951
+ if (f.t !== "hello" || typeof f.mac_s !== "string" || !macEqual(f.mac_s, macServer(token, ns, nc))) return refuse3("embedder_unavailable");
32445
32952
  const model = this.m.files.find((x) => x.name === "model.onnx");
32446
32953
  const ok = f.protocol === EMBEDDER_PROTOCOL && f.model_id === this.m.modelId && f.model_sha256 === model.sha256 && f.wasm_sha256 === this.m.wasmSha256 && f.dims === this.m.dims;
32447
- if (!ok) return refuse2("embedder_mismatch");
32954
+ if (!ok) return refuse3("embedder_mismatch");
32448
32955
  if (this.closed) {
32449
32956
  s.destroy();
32450
32957
  return done(true);
@@ -32462,9 +32969,9 @@ var EmbedderClient = class {
32462
32969
  this.settleReady(true);
32463
32970
  done(true);
32464
32971
  } catch {
32465
- refuse2("embedder_unavailable");
32972
+ refuse3("embedder_unavailable");
32466
32973
  }
32467
- }, () => refuse2("embedder_unavailable")));
32974
+ }, () => refuse3("embedder_unavailable")));
32468
32975
  });
32469
32976
  }
32470
32977
  onFrame(f) {
@@ -35459,6 +35966,213 @@ function roomIsCrossOrg(rows) {
35459
35966
  return unknown ? null : false;
35460
35967
  }
35461
35968
 
35969
+ // src/voice-channel-binding.ts
35970
+ var BOUND = Object.freeze({ bound: true });
35971
+ var refuse = (reason) => ({ bound: false, reason });
35972
+ function evaluateVoiceBinding(v, p, isVRelay) {
35973
+ if (v.ownLeafIndex === null || v.leaves.length === 0) return refuse("v_not_held");
35974
+ if (p.ownLeafIndex === null || p.leaves.length === 0) return refuse("p_not_held");
35975
+ if (v.leaves.some((l) => !l.basic || l.identity === null)) return refuse("non_basic_leaf");
35976
+ const pIds = /* @__PURE__ */ new Set();
35977
+ for (const l of p.leaves) if (l.basic && l.identity !== null) pIds.add(l.identity);
35978
+ const nonParent = /* @__PURE__ */ new Set();
35979
+ for (const l of v.leaves) if (!pIds.has(l.identity)) nonParent.add(l.identity);
35980
+ if (nonParent.size > 1) return refuse("two_non_parent");
35981
+ if (isVRelay === void 0) return nonParent.size === 0 ? BOUND : refuse("relay_unknown");
35982
+ const relays = vRelayIdentities(v, isVRelay);
35983
+ if (relays.size > 1) return refuse("two_relays");
35984
+ const [relay] = relays;
35985
+ for (const id of nonParent) if (id !== relay) return refuse("foreign_leaf");
35986
+ return BOUND;
35987
+ }
35988
+ function vRelayIdentities(v, isVRelay) {
35989
+ const relays = /* @__PURE__ */ new Set();
35990
+ for (const l of v.leaves) if (l.basic && l.identity !== null && isVRelay(l.identity)) relays.add(l.identity);
35991
+ return relays;
35992
+ }
35993
+ function voiceRelayIdentity(v, isVRelay) {
35994
+ if (isVRelay === void 0) return void 0;
35995
+ const relays = vRelayIdentities(v, isVRelay);
35996
+ return relays.size === 1 ? [...relays][0] : void 0;
35997
+ }
35998
+ function parseVoiceResolution(channelId, status, body) {
35999
+ if (status === 404) return { kind: "absent" };
36000
+ if (status !== 200) return { kind: "error" };
36001
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return { kind: "error" };
36002
+ const b = body;
36003
+ if (b.channel_id !== channelId || typeof b.channel_kind !== "string") return { kind: "error" };
36004
+ if (b.channel_kind !== "voice") return { kind: "not_voice" };
36005
+ const parent3 = b.parent_channel_id;
36006
+ if (typeof parent3 !== "string" || parent3.length === 0 || parent3 === channelId) return { kind: "error" };
36007
+ return { kind: "voice", parentChannelId: parent3 };
36008
+ }
36009
+ var VOICE_RESOLVE_RETRY_BASE_MS = 3e4;
36010
+ var VOICE_RESOLVE_RETRY_MAX_MS = 10 * 6e4;
36011
+ var VOICE_RESOLVE_ABSENT_HELD_MAX_MS = 6e4;
36012
+ var VOICE_RECEIPT_CAP = 1e4;
36013
+ var VOICE_RECEIPT_MAX_AGE_MS = 15 * 6e4;
36014
+ function voiceParentStamp(p) {
36015
+ return JSON.stringify([p.groupId, p.epoch]);
36016
+ }
36017
+ var VoiceChannelRegistry = class {
36018
+ parents = /* @__PURE__ */ new Map();
36019
+ notVoice = /* @__PURE__ */ new Set();
36020
+ failures = /* @__PURE__ */ new Map();
36021
+ bindings = /* @__PURE__ */ new Map();
36022
+ generations = /* @__PURE__ */ new Map();
36023
+ /** Fix I-1 / Task 20 R1: message id -> P's held position before the batch that first received it; null = not knowable. */
36024
+ receipts = /* @__PURE__ */ new Map();
36025
+ /** Final review M-d: arrivals not recorded because the map was full of young records. */
36026
+ capReached = 0;
36027
+ /** True when a lookup should run now: never once resolved; after a failure, only past its back-off. */
36028
+ resolveDue(channelId, now, opts = {}) {
36029
+ if (this.parents.has(channelId) || this.notVoice.has(channelId)) return false;
36030
+ const f = this.failures.get(channelId);
36031
+ if (f === void 0) return true;
36032
+ let wait = Math.min(VOICE_RESOLVE_RETRY_MAX_MS, VOICE_RESOLVE_RETRY_BASE_MS * 2 ** Math.min(f.attempts - 1, 20));
36033
+ if (f.kind === "absent" && opts.heldGroup === true) wait = Math.min(wait, VOICE_RESOLVE_ABSENT_HELD_MAX_MS);
36034
+ return now - f.at >= wait;
36035
+ }
36036
+ record(channelId, r, now) {
36037
+ if (r.kind === "voice") {
36038
+ this.parents.set(channelId, r.parentChannelId);
36039
+ this.failures.delete(channelId);
36040
+ } else if (r.kind === "not_voice") {
36041
+ this.notVoice.add(channelId);
36042
+ this.failures.delete(channelId);
36043
+ } else {
36044
+ const prev = this.failures.get(channelId);
36045
+ this.failures.set(channelId, { at: now, attempts: (prev?.attempts ?? 0) + 1, kind: r.kind });
36046
+ }
36047
+ }
36048
+ /** True once get_channel has answered for this channel (voice or not). */
36049
+ isResolved(channelId) {
36050
+ return this.parents.has(channelId) || this.notVoice.has(channelId);
36051
+ }
36052
+ /** Final review M-a: true once get_channel answered this channel's own row with a kind other than voice. */
36053
+ isNotVoice(channelId) {
36054
+ return this.notVoice.has(channelId);
36055
+ }
36056
+ /** Final review (R∈P): bound Vs whose admitted R is also a P leaf (the pre-stamp shape; a sunset signal). */
36057
+ relayInParentCount() {
36058
+ let n = 0;
36059
+ for (const [v, e] of this.bindings) {
36060
+ if (this.parents.has(v) && e.verdict.bound && e.relay !== void 0 && e.parentMembers?.has(e.relay) === true) n++;
36061
+ }
36062
+ return n;
36063
+ }
36064
+ /** True for a resolved V, bound or not. */
36065
+ isVoice(channelId) {
36066
+ return this.parents.has(channelId);
36067
+ }
36068
+ /** P of a resolved V, bound or not; undefined otherwise. Never use this to route: see {@link boundParentOf}. */
36069
+ parentOf(channelId) {
36070
+ return this.parents.get(channelId);
36071
+ }
36072
+ /** Every resolved V with its P. */
36073
+ voiceChannels() {
36074
+ return [...this.parents.entries()];
36075
+ }
36076
+ /** The gate for a channel the bridge's listing has never classified (`unlisted`), or for a resolved V. */
36077
+ gate(channelId, unlisted) {
36078
+ if (this.parents.has(channelId)) return "drop";
36079
+ if (!unlisted || this.notVoice.has(channelId)) return "normal";
36080
+ return this.failures.get(channelId)?.kind === "absent" ? "drop" : "hold";
36081
+ }
36082
+ bindingKey(channelId) {
36083
+ return this.bindings.get(channelId)?.key;
36084
+ }
36085
+ setBinding(channelId, key, verdict, positions = null, admitted = {}) {
36086
+ this.bindings.set(channelId, { key, positions, verdict, ...admitted });
36087
+ }
36088
+ /** Fix M-1: each evaluation of a V takes a generation; only the newest started may store its verdict. */
36089
+ beginEvaluation(channelId) {
36090
+ const g = (this.generations.get(channelId) ?? 0) + 1;
36091
+ this.generations.set(channelId, g);
36092
+ return g;
36093
+ }
36094
+ isLatestEvaluation(channelId, generation) {
36095
+ return this.generations.get(channelId) === generation;
36096
+ }
36097
+ /**
36098
+ * Fix I-1: records a frame's receipt stamp the FIRST time it is seen; a re-served frame keeps its first record.
36099
+ * Task 20 R2: at the cap nothing is evicted and the new arrival is not recorded (refused: no record, no parent).
36100
+ */
36101
+ noteReceipt(messageId, pStamp, now = Date.now()) {
36102
+ if (this.receipts.has(messageId)) return;
36103
+ if (this.receipts.size >= VOICE_RECEIPT_CAP) {
36104
+ for (const [id, r] of this.receipts) if (now - r.at > VOICE_RECEIPT_MAX_AGE_MS) this.receipts.delete(id);
36105
+ if (this.receipts.size >= VOICE_RECEIPT_CAP) {
36106
+ this.capReached++;
36107
+ return;
36108
+ }
36109
+ }
36110
+ this.receipts.set(messageId, { stamp: pStamp, at: now });
36111
+ }
36112
+ /** Final review M-d: arrivals refused because the receipt map was full (a number only). */
36113
+ receiptCapReached() {
36114
+ return this.capReached;
36115
+ }
36116
+ /** Test visibility: receipt records held. */
36117
+ receiptCount() {
36118
+ return this.receipts.size;
36119
+ }
36120
+ forgetReceipt(messageId) {
36121
+ this.receipts.delete(messageId);
36122
+ }
36123
+ /**
36124
+ * Fix I-1: P for one frame on V only when V is bound now AND both groups' held positions are exactly those at the
36125
+ * frame's receipt. A frame received at other positions (or with none recorded) gets undefined: dropped, never held.
36126
+ */
36127
+ boundParentForFrame(channelId, messageId, openedAt) {
36128
+ return this.frameContext(channelId, messageId, openedAt)?.parent;
36129
+ }
36130
+ /**
36131
+ * Task 20: the per-frame verdict with what the accept filter needs. Bound now AND the frame's pre-batch P stamp equals
36132
+ * the verdict's P position AND (R1) the frame's own opening position equals the verdict's V position. A frame with
36133
+ * no record, a null record, or no `openedAt` is refused.
36134
+ */
36135
+ frameContext(channelId, messageId, openedAt) {
36136
+ const parent3 = this.boundParentOf(channelId);
36137
+ if (parent3 === void 0) return void 0;
36138
+ const entry = this.bindings.get(channelId);
36139
+ const at = this.receipts.get(messageId)?.stamp;
36140
+ if (entry?.positions == null || typeof at !== "string" || at !== entry.positions.p) return void 0;
36141
+ const v = entry.positions.v;
36142
+ if (v === null || openedAt == null || openedAt.groupId !== v.groupId || openedAt.epoch !== v.epoch) return void 0;
36143
+ return { parent: parent3, relay: entry.relay, parentMembers: entry.parentMembers ?? /* @__PURE__ */ new Set() };
36144
+ }
36145
+ /** The current verdict, or undefined when never evaluated. */
36146
+ binding(channelId) {
36147
+ return this.bindings.get(channelId)?.verdict;
36148
+ }
36149
+ /** P only while V is a resolved V whose last verdict is bound; undefined otherwise. */
36150
+ boundParentOf(channelId) {
36151
+ const parent3 = this.parents.get(channelId);
36152
+ return parent3 !== void 0 && this.bindings.get(channelId)?.verdict.bound === true ? parent3 : void 0;
36153
+ }
36154
+ };
36155
+
36156
+ // src/voice-channel-frame.ts
36157
+ function classifyVoiceFrame(f) {
36158
+ if (f.senderId === f.ownIdentityId) return "drop";
36159
+ const fromRelay = f.relay !== void 0 && f.senderId === f.relay;
36160
+ const fromMember = f.parentMembers.has(f.senderId);
36161
+ if (!fromRelay && !fromMember) return "drop";
36162
+ let body;
36163
+ try {
36164
+ body = JSON.parse(new TextDecoder().decode(f.plaintext));
36165
+ } catch {
36166
+ return "drop";
36167
+ }
36168
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return "drop";
36169
+ const b = body;
36170
+ if (b.type === "call_transcript") return "call_transcript";
36171
+ if (b.type !== void 0 && b.type !== "text" || typeof b.text !== "string") return "drop";
36172
+ if (fromRelay) return "relay_turn";
36173
+ return b.text.trim() === VOICE_CALL_START_MARKER ? "greeting" : "drop";
36174
+ }
36175
+
35462
36176
  // src/grants/owner-turn.ts
35463
36177
  import { ed25519 as ed255196 } from "@noble/curves/ed25519";
35464
36178
  var HEX = /^(?:[0-9a-fA-F]{2})+$/;
@@ -35554,6 +36268,7 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
35554
36268
  var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
35555
36269
  Object.freeze({ tool: "current_time", mode: "read" })
35556
36270
  ]);
36271
+ var BRIDGE_CAPABILITIES = Object.freeze(["voice_channel_v1"]);
35557
36272
  var RELAYED_VOICE_GRANTS = Object.freeze([
35558
36273
  Object.freeze({ tool: "current_time", mode: "read" }),
35559
36274
  Object.freeze({ tool: "web_search", mode: "read" })
@@ -35828,12 +36543,19 @@ var RoomClassChangedSendError = class extends SendForbiddenError {
35828
36543
  this.message = `reply dropped for channel ${channelId}: the room changed after its tools were granted; no retry`;
35829
36544
  }
35830
36545
  };
36546
+ function voiceVerdictPositions(v, p) {
36547
+ return {
36548
+ v: v.groupId !== null && v.epoch !== null ? { groupId: v.groupId, epoch: v.epoch } : null,
36549
+ p: voiceParentStamp(p)
36550
+ };
36551
+ }
35831
36552
  function rosterFreshAt(entry, heldEpoch, heldGroupId) {
35832
36553
  return entry !== void 0 && typeof heldEpoch === "number" && entry.epoch === heldEpoch && typeof heldGroupId === "string" && entry.groupId === heldGroupId;
35833
36554
  }
35834
36555
  var RoomGrantEpochMovedError = class extends Error {
35835
36556
  };
35836
36557
  var ROOM_GRANT_TRACK_MAX = 4096;
36558
+ var VOICE_FRAME_PARENT_MAX = 4096;
35837
36559
  var SessionSupersededError = class extends Error {
35838
36560
  constructor() {
35839
36561
  super("session_superseded: another copy of this agent took its session during start()");
@@ -36265,6 +36987,13 @@ var BridgeAgent = class _BridgeAgent {
36265
36987
  supersedeRefundable = /* @__PURE__ */ new Set();
36266
36988
  /** M-1: channels this process has used as a FileSafe vault channel. */
36267
36989
  vaultChannelIds = /* @__PURE__ */ new Set();
36990
+ /**
36991
+ * ADR-0024 step 2a Task 19 (spec §9): hidden voice channels V resolved to their conversation P, lookup back-off, and
36992
+ * each V's binding verdict. In memory only: a restart re-resolves (one get_channel per V) and re-evaluates.
36993
+ */
36994
+ voiceChannels = new VoiceChannelRegistry();
36995
+ /** Fix M-3: channels a listing row marked channel_kind "voice"; never recorded as listed. */
36996
+ listedAsVoice = /* @__PURE__ */ new Set();
36268
36997
  /** Phase 3 F3: consecutive agent turns per channel. */
36269
36998
  agentHops = new AgentHopTracker();
36270
36999
  maxAgentHops;
@@ -36295,6 +37024,12 @@ var BridgeAgent = class _BridgeAgent {
36295
37024
  * counter is incremented. Surfaced on health()/metrics().
36296
37025
  */
36297
37026
  failedAttachments = 0;
37027
+ /** Task 20 R4: V frames dropped by the per-frame verdict (not bound, or not at the verdict's positions). A number only. */
37028
+ voiceFramesRefused = 0;
37029
+ /** Task 20: frames on a bound V the accept filter dropped (not R's voice frame, a greeting marker or a transcript). */
37030
+ voiceFramesFiltered = 0;
37031
+ /** Task 20: message id of an admitted V frame -> its P (B.8 keys). Bounded; an entry lives one ingest cycle in practice. */
37032
+ voiceFrameParent = /* @__PURE__ */ new Map();
36298
37033
  /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
36299
37034
  refusedVaultRefs = 0;
36300
37035
  /**
@@ -36708,7 +37443,9 @@ var BridgeAgent = class _BridgeAgent {
36708
37443
  // Fix round MEDIUM 2: a channel this process used as a FileSafe vault
36709
37444
  // channel is never swept (the sweeper also refuses any channel no listing
36710
37445
  // has shown, which covers a vault channel after a restart).
36711
- conversations: () => this.store.memoryConversationIds().filter((c) => !this.vaultChannelIds.has(c)),
37446
+ // Task 20 final review I-1: a voice channel V is never listed, so it is swept as its parent P: V is present exactly
37447
+ // when P is, and purgeConversation(P) purges every V of P. P is swept even when only its V holds memory.
37448
+ conversations: () => [...new Set(this.store.memoryConversationIds().filter((c) => !this.vaultChannelIds.has(c)).map((c) => this.store.voiceChannelParent(c) ?? c))],
36712
37449
  enclaveOf: (c) => this.store.getChannelMeta(c)?.enclaveId,
36713
37450
  // Audit r1 M6: the persisted flag, never the channelOwner cache. Set only
36714
37451
  // from a listing, so undefined means no listing has ever shown the channel.
@@ -37128,6 +37865,8 @@ var BridgeAgent = class _BridgeAgent {
37128
37865
  // the actual protected-memory adapter. It is an enum, never a path or
37129
37866
  // provider credential.
37130
37867
  memory_provider: this.cfg.memoryProvider ?? "securecoms",
37868
+ // Spec §2 (Task 18): the per-device capability declaration; a fixed enum list, one body for every report path.
37869
+ capabilities: [...BRIDGE_CAPABILITIES],
37131
37870
  // Spec §11.1-11.2 (OQ-E): flat keys; the string `memory` above is
37132
37871
  // unchanged. memory_notes is true only when notes are actually on (the
37133
37872
  // provider is securecoms and the parsed payload.memory keeps them on);
@@ -38375,6 +39114,7 @@ var BridgeAgent = class _BridgeAgent {
38375
39114
  * across a supersede), and the group must not move around the reads.
38376
39115
  */
38377
39116
  async reclassifyForReply(channelId, grant) {
39117
+ if (this.voiceChannels.isVoice(channelId)) return false;
38378
39118
  const startedAt = Date.now();
38379
39119
  await this.refreshChannelRoster(channelId);
38380
39120
  const held = await this.heldPosition(channelId);
@@ -38391,8 +39131,9 @@ var BridgeAgent = class _BridgeAgent {
38391
39131
  this.enforceRateFloor("a2a", channelId);
38392
39132
  return;
38393
39133
  }
38394
- const meta = this.freshMeta(channelId);
38395
- if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
39134
+ const capChannel = this.capChannelFor(channelId);
39135
+ const meta = this.freshMeta(capChannel);
39136
+ if (meta && !meta.is_dm) this.enforceRateFloor("room", capChannel);
38396
39137
  }
38397
39138
  /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
38398
39139
  afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture) {
@@ -38874,8 +39615,289 @@ var BridgeAgent = class _BridgeAgent {
38874
39615
  return probeResultFor("threw");
38875
39616
  }
38876
39617
  }
39618
+ // ── ADR-0024 step 2a Task 19: the hidden voice channel V (spec §9, B.8) ──────
39619
+ //
39620
+ // The rule and the cache live in voice-channel-binding.ts; this is the wiring: the get_channel lookup, the bridge's
39621
+ // own held ratchet trees and positions, V's member list (is_relay only), and the receive-cycle gate.
39622
+ /**
39623
+ * Task 20's accessor: P's channel id ONLY while V is bound under the spec §9 rule at both groups' held positions as
39624
+ * of the last evaluation (every receive cycle, before any frame is processed; or {@link voiceParentNow}); undefined
39625
+ * for an unbound or refused V, and for any channel that is not a resolved V.
39626
+ */
39627
+ voiceParentOf(channelId) {
39628
+ return this.voiceChannels.boundParentOf(channelId);
39629
+ }
39630
+ /** True for a channel resolved as a V, bound or not. Such a channel is never processed as a normal channel. */
39631
+ isKnownVoiceChannel(channelId) {
39632
+ return this.voiceChannels.isVoice(channelId);
39633
+ }
39634
+ /** Re-evaluates one V at the held positions now, then answers as {@link voiceParentOf}. */
39635
+ async voiceParentNow(channelId) {
39636
+ const parent3 = this.voiceChannels.parentOf(channelId);
39637
+ if (parent3 === void 0) return void 0;
39638
+ await this.reevaluateVoiceBinding(channelId, parent3);
39639
+ return this.voiceChannels.boundParentOf(channelId);
39640
+ }
39641
+ /** A channel no listing has ever classified (in memory or persisted). V is hidden from the listing, so it is one. */
39642
+ isUnlistedChannel(channelId) {
39643
+ return this.channelMeta.get(channelId) === void 0 && this.store.getChannelMeta(channelId)?.isDm === void 0;
39644
+ }
39645
+ voiceChannelGate(channelId) {
39646
+ if (this.vaultChannelIds.has(channelId)) return "normal";
39647
+ return this.voiceChannels.gate(channelId, this.isUnlistedChannel(channelId));
39648
+ }
39649
+ /**
39650
+ * Looks up one channel with get_channel (the voice arm, OQ-5 (a)). Only the classification is kept. Never throws; a
39651
+ * failure is recorded and retried (30 s doubling to 10 min). Called only from {@link resolveUnlistedChannels}, which
39652
+ * selects unlisted channels whose back-off allows.
39653
+ */
39654
+ async resolveVoiceChannel(channelId) {
39655
+ let status = "threw";
39656
+ let body;
39657
+ try {
39658
+ const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(channelId)}?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
39659
+ const res = await this.fetchBounded("get_channel", "read", url, { headers: { authorization: `Bearer ${this.cfg.apiKey}` } });
39660
+ if (res.ok) {
39661
+ body = await res.json();
39662
+ } else {
39663
+ try {
39664
+ await res.body?.cancel();
39665
+ } catch {
39666
+ }
39667
+ }
39668
+ status = res.status;
39669
+ } catch {
39670
+ status = "threw";
39671
+ }
39672
+ const r = parseVoiceResolution(channelId, status, body);
39673
+ this.voiceChannels.record(channelId, r, Date.now());
39674
+ if (r.kind === "voice") {
39675
+ try {
39676
+ this.store.recordVoiceChannelParent(channelId, r.parentChannelId);
39677
+ } catch {
39678
+ }
39679
+ }
39680
+ if (r.kind === "voice") writeServiceLog("info", "voice.channel_lookup", { result: ServiceLogEnum.of("voice", ["voice", "absent", "error"]) });
39681
+ else if (r.kind !== "not_voice") writeServiceLog("warn", "voice.channel_lookup", { result: ServiceLogEnum.of(r.kind, ["voice", "absent", "error"]) });
39682
+ }
39683
+ /**
39684
+ * Looks up every unlisted channel among `channelIds` whose back-off allows. A channel is "unlisted" only after this
39685
+ * cycle's listing: if any candidate remains and the cycle has not listed yet, one listing runs first, so a channel
39686
+ * that is merely new (listed, not yet cached) is never looked up. Returns true when it ran that listing.
39687
+ */
39688
+ async resolveUnlistedChannels(channelIds, listed) {
39689
+ const ids = [...new Set(channelIds)].filter((c) => !this.vaultChannelIds.has(c));
39690
+ const held = /* @__PURE__ */ new Set();
39691
+ for (const c of ids) {
39692
+ if (!this.isUnlistedChannel(c) || this.voiceChannels.isResolved(c)) continue;
39693
+ try {
39694
+ if ((await this.heldPosition(c)).groupId !== null) held.add(c);
39695
+ } catch {
39696
+ }
39697
+ }
39698
+ const due = () => ids.filter((c) => this.isUnlistedChannel(c) && this.voiceChannels.resolveDue(c, Date.now(), { heldGroup: held.has(c) }));
39699
+ let candidates = due();
39700
+ if (candidates.length === 0) return false;
39701
+ let ran = false;
39702
+ if (!listed) {
39703
+ await this.refreshChannelMeta();
39704
+ ran = true;
39705
+ candidates = due();
39706
+ }
39707
+ for (const c of candidates) await this.resolveVoiceChannel(c);
39708
+ return ran;
39709
+ }
39710
+ /** Final review M-c: V -> consecutive failed member-list reads for the binding, and when the last one ran. */
39711
+ voiceRosterRetry = /* @__PURE__ */ new Map();
39712
+ /** The first retry after a failure is immediate (next cycle); then 30 s, doubling, capped at 10 min. */
39713
+ voiceRosterRetryDue(v) {
39714
+ const f = this.voiceRosterRetry.get(v);
39715
+ if (f === void 0 || f.attempts <= 1) return true;
39716
+ const wait = Math.min(VOICE_RESOLVE_RETRY_MAX_MS, VOICE_RESOLVE_RETRY_BASE_MS * 2 ** Math.min(f.attempts - 2, 20));
39717
+ return Date.now() - f.at >= wait;
39718
+ }
39719
+ noteVoiceRosterRead(v, ok) {
39720
+ if (ok) {
39721
+ this.voiceRosterRetry.delete(v);
39722
+ return;
39723
+ }
39724
+ this.voiceRosterRetry.set(v, { at: Date.now(), attempts: (this.voiceRosterRetry.get(v)?.attempts ?? 0) + 1 });
39725
+ }
39726
+ /** Every resolved V, re-evaluated where a position or a roster moved. */
39727
+ async reevaluateVoiceBindings() {
39728
+ for (const [v, p] of this.voiceChannels.voiceChannels()) await this.reevaluateVoiceBinding(v, p);
39729
+ }
39730
+ /**
39731
+ * The spec §9 binding rule over the bridge's OWN ratchet trees (never a server list of leaves), keyed by both
39732
+ * groups' held (group, epoch) and both rosters' fetch times: a change in any of them re-evaluates. V's member list
39733
+ * is read only for `is_relay`, and only when it is fresh at V's held position. Fail closed: a tree that moved while
39734
+ * it was read, or any exception, leaves V unbound and is re-evaluated next time.
39735
+ */
39736
+ async reevaluateVoiceBinding(v, p) {
39737
+ const generation = this.voiceChannels.beginEvaluation(v);
39738
+ try {
39739
+ const vPos = await this.heldPosition(v);
39740
+ const pPos = await this.heldPosition(p);
39741
+ const positions = voiceVerdictPositions(vPos, pPos);
39742
+ const parentNotConversation = this.voiceChannels.isVoice(p) || this.isUnlistedChannel(p);
39743
+ const keyOf = () => JSON.stringify([positions, this.freshRosterEntry(v)?.at ?? null, this.freshRosterEntry(p)?.at ?? null, parentNotConversation]);
39744
+ if (this.voiceChannels.bindingKey(v) === keyOf()) return;
39745
+ if (parentNotConversation) {
39746
+ if (this.voiceChannels.isLatestEvaluation(v, generation)) this.noteVoiceBinding(v, keyOf(), { bound: false, reason: "parent_not_conversation" }, positions);
39747
+ return;
39748
+ }
39749
+ const vTree = await this.provider.leafTreeSummary(v);
39750
+ const pTree = await this.provider.leafTreeSummary(p);
39751
+ const relayCheck = () => {
39752
+ const entry = this.freshRosterEntry(v);
39753
+ return entry !== void 0 && rosterFreshAt(entry, vPos.epoch, vPos.groupId) ? (id) => entry.members.get(id)?.is_relay === true : void 0;
39754
+ };
39755
+ if (relayCheck() === void 0 && this.voiceRosterRetryDue(v)) {
39756
+ await this.refreshChannelRoster(v);
39757
+ this.noteVoiceRosterRead(v, relayCheck() !== void 0);
39758
+ }
39759
+ const isVRelay = relayCheck();
39760
+ let verdict = evaluateVoiceBinding(vTree, pTree, isVRelay);
39761
+ const vAfter = await this.heldPosition(v);
39762
+ const pAfter = await this.heldPosition(p);
39763
+ const moved = vAfter.groupId !== vPos.groupId || vAfter.epoch !== vPos.epoch || pAfter.groupId !== pPos.groupId || pAfter.epoch !== pPos.epoch;
39764
+ if (moved) verdict = { bound: false, reason: "moved" };
39765
+ const retry = !verdict.bound && (verdict.reason === "relay_unknown" || verdict.reason === "moved") || verdict.bound && isVRelay === void 0;
39766
+ if (!this.voiceChannels.isLatestEvaluation(v, generation)) return;
39767
+ const parentMembers = /* @__PURE__ */ new Set();
39768
+ for (const l of pTree.leaves) if (l.basic && l.identity !== null) parentMembers.add(l.identity);
39769
+ this.noteVoiceBinding(v, retry ? "" : keyOf(), verdict, positions, void 0, {
39770
+ relay: verdict.bound ? voiceRelayIdentity(vTree, isVRelay) : void 0,
39771
+ parentMembers
39772
+ });
39773
+ } catch (e) {
39774
+ if (this.voiceChannels.isLatestEvaluation(v, generation)) this.noteVoiceBinding(v, "", { bound: false, reason: "moved" }, null, e);
39775
+ }
39776
+ }
39777
+ /**
39778
+ * Fix I-1 / Task 20 R1: P's held position for every resolved V, read before a receive so frames can be stamped. V's
39779
+ * half is not stamped here: each frame carries the epoch that opened it (`openedAt`).
39780
+ */
39781
+ async voicePositionSnapshot() {
39782
+ const out = /* @__PURE__ */ new Map();
39783
+ for (const [v, p] of this.voiceChannels.voiceChannels()) {
39784
+ try {
39785
+ out.set(v, voiceParentStamp(await this.heldPosition(p)));
39786
+ } catch {
39787
+ }
39788
+ }
39789
+ return out;
39790
+ }
39791
+ /**
39792
+ * Fix I-1, for Task 20: P for ONE frame on V, only when V is bound now AND neither P's nor V's held (group, epoch)
39793
+ * moved between that frame's first receipt and the verdict. Otherwise undefined: the frame is dropped (acked, never
39794
+ * held, never processed). A frame first received before V was resolved has no receipt positions and is refused.
39795
+ */
39796
+ voiceParentForFrame(channelId, messageId, openedAt) {
39797
+ return this.voiceChannels.boundParentForFrame(channelId, messageId, openedAt);
39798
+ }
39799
+ /** Stores a verdict; a change of verdict logs one content-free line. */
39800
+ noteVoiceBinding(v, key, verdict, positions, error, admitted = {}) {
39801
+ const prev = this.voiceChannels.binding(v);
39802
+ this.voiceChannels.setBinding(v, key, verdict, positions, admitted);
39803
+ const state = verdict.bound ? "bound" : verdict.reason;
39804
+ const prevState = prev === void 0 ? void 0 : prev.bound ? "bound" : prev.reason;
39805
+ if (state === prevState && error === void 0) return;
39806
+ writeServiceLog(verdict.bound ? "info" : "warn", "voice.channel_binding", {
39807
+ state: ServiceLogEnum.of(state, ["bound", "v_not_held", "p_not_held", "non_basic_leaf", "foreign_leaf", "two_non_parent", "two_relays", "relay_unknown", "moved", "parent_not_conversation"])
39808
+ }, error);
39809
+ }
39810
+ /**
39811
+ * The receive-cycle gate, before any per-frame work. A resolved V is never a normal channel: a frame passes only when
39812
+ * the per-frame verdict names P (bound now, its P stamp and its own opening epoch at the verdict's positions) AND the
39813
+ * accept filter takes it (Task 20: R's voice frame or a P member's greeting marker; a call_transcript is acked, not a
39814
+ * turn). Every other V frame is acked unprocessed and never recorded. A channel get_channel answered 404 is acked
39815
+ * unprocessed (fail closed). An unlisted channel whose lookup errored or has not run is held: neither processed nor
39816
+ * acked, so it is re-served once the lookup answers. Everything else passes unchanged.
39817
+ */
39818
+ async gateVoiceEntries(entries) {
39819
+ const keep = [];
39820
+ const drop = [];
39821
+ for (const m of entries) {
39822
+ const gate = this.voiceChannelGate(m.channelId);
39823
+ if (gate === "hold") continue;
39824
+ if (gate === "normal") {
39825
+ this.voiceChannels.forgetReceipt(m.messageId);
39826
+ keep.push(m);
39827
+ continue;
39828
+ }
39829
+ if (this.voiceChannels.isVoice(m.channelId)) {
39830
+ const admitted = this.admitVoiceFrame(m);
39831
+ this.voiceChannels.forgetReceipt(m.messageId);
39832
+ if (admitted) {
39833
+ keep.push(m);
39834
+ continue;
39835
+ }
39836
+ } else {
39837
+ this.voiceChannels.forgetReceipt(m.messageId);
39838
+ }
39839
+ drop.push(m.queueId);
39840
+ }
39841
+ if (drop.length) {
39842
+ try {
39843
+ await this.provider.ackDelivery(this.cfg.deviceId, drop);
39844
+ } catch {
39845
+ }
39846
+ }
39847
+ return keep;
39848
+ }
39849
+ /**
39850
+ * Task 20: the per-frame verdict and the accept filter for one frame on a resolved V. True only for a model turn
39851
+ * (R's voice frame, a P member's greeting marker), which is then keyed per B.8 through {@link voiceFrameParent}. A
39852
+ * call_transcript is accepted but is not a turn (acked, nothing more, as step 1 on P). Counters are numbers only.
39853
+ */
39854
+ admitVoiceFrame(m) {
39855
+ const parent3 = this.voiceParentForFrame(m.channelId, m.messageId, m.openedAt ?? null);
39856
+ const ctx = parent3 === void 0 ? void 0 : this.voiceChannels.frameContext(m.channelId, m.messageId, m.openedAt ?? null);
39857
+ if (ctx === void 0) {
39858
+ this.voiceFramesRefused++;
39859
+ return false;
39860
+ }
39861
+ const kind2 = classifyVoiceFrame({
39862
+ senderId: m.envelope.sender_id,
39863
+ ownIdentityId: this.cfg.identityId,
39864
+ relay: ctx.relay,
39865
+ parentMembers: ctx.parentMembers,
39866
+ plaintext: m.plaintext
39867
+ });
39868
+ if (kind2 === "call_transcript") return false;
39869
+ if (kind2 === "drop") {
39870
+ this.voiceFramesFiltered++;
39871
+ return false;
39872
+ }
39873
+ this.voiceFrameParent.set(m.messageId, ctx.parent);
39874
+ if (this.voiceFrameParent.size > VOICE_FRAME_PARENT_MAX) this.voiceFrameParent.delete(this.voiceFrameParent.keys().next().value);
39875
+ return true;
39876
+ }
39877
+ /**
39878
+ * Task 20 (B.8): the key channel for metadata, audience, owner, manifest and caps. For an admitted V frame, its P;
39879
+ * for any other frame, its own channel.
39880
+ */
39881
+ keyChannelForFrame(m) {
39882
+ return this.voiceFrameParent.get(m.messageId) ?? m.channelId;
39883
+ }
39884
+ /**
39885
+ * Task 20 (B.8 "per-room daily caps → P", the room rate floor): the channel a cap or floor counts against. A known V
39886
+ * counts against its bound P; an unbound V against itself (no grant can be issued there, so nothing is widened).
39887
+ */
39888
+ capChannelFor(channelId) {
39889
+ return this.voiceChannels.parentOf(channelId) ?? channelId;
39890
+ }
39891
+ /** Phase 3 F1 pause, for a voice channel V also read on its P (a paused agent runs no inference on the call either). */
39892
+ pausedFor(channelId) {
39893
+ return this.pausedChannels.has(channelId) || this.pausedChannels.has(this.capChannelFor(channelId));
39894
+ }
38877
39895
  /** §5.4: one conversation's turns, facts, notes and recall, inside the memory mutex, with every in-memory copy. */
38878
39896
  async purgeConversation(channelId) {
39897
+ await this.purgeOneConversation(channelId);
39898
+ for (const v of this.store.voiceChannelsOf(channelId)) await this.purgeOneConversation(v);
39899
+ }
39900
+ async purgeOneConversation(channelId) {
38879
39901
  const scopes = this.unifiedOn ? this.store.ownerDmScopeIds().filter((s) => parseOwnerDmScopeId(s)?.channelId === channelId) : [];
38880
39902
  await this.notes.purgeChannel(channelId);
38881
39903
  for (const s of scopes) this.store.clearConversationAbsence(s);
@@ -39732,6 +40754,12 @@ var BridgeAgent = class _BridgeAgent {
39732
40754
  * frozen at receipt) AND a window-eligible turn (voiceWindowEligible), whatever the window size. Two call sites: the voice
39733
40755
  * window flush, and the ingest tap block when the window did not take the turn (off, 0 by default). Like the typed tap, the write-time owner scope's pinned identity must be the speaking identity;
39734
40756
  * otherwise nothing is written (H-4). Logs are content-free (U17). Never throws.
40757
+ *
40758
+ * Bridge 0.33.1 (architect Q2, C5 not accepted): a turn on a voice channel V carries `parent`, its P from the
40759
+ * per-frame verdict. The scope is ownerScopeForWrite(P) (P's pin and roster, as for a turn on P), the item keeps
40760
+ * channelId = V (so V's own capture still guards it in the store), and P's receipt capture is checked at commit,
40761
+ * inside the memory mutex: a purge or room clear of P since receipt writes nothing. No P capture at receipt, or no
40762
+ * persisted V -> P link (the purge of P reaches V's items only through it): nothing is written.
39735
40763
  */
39736
40764
  async tapUnifiedVoice(m, c) {
39737
40765
  const skip = (reason) => this.logUnifiedTapSkip(reason);
@@ -39740,9 +40768,22 @@ var BridgeAgent = class _BridgeAgent {
39740
40768
  if (m.authorization?.relay_speaker !== "owner" || m.speaker === void 0) return skip("audience");
39741
40769
  if (c.stamp === void 0) return skip("stamp_missing");
39742
40770
  if (c.capture.purgeEpoch < 0) return skip("purged");
39743
- const scopeId = await this.ownerScopeForWrite(m.channel_id);
40771
+ const parent3 = c.parent;
40772
+ const parentCapture = parent3?.capture;
40773
+ if (parent3 === void 0 && this.voiceChannels.isVoice(m.channel_id)) return skip("scope");
40774
+ if (parent3 !== void 0) {
40775
+ if (parentCapture === void 0 || parentCapture.purgeEpoch < 0) return skip("scope");
40776
+ if (this.store.voiceChannelParent(m.channel_id) !== parent3.channelId) return skip("scope");
40777
+ }
40778
+ const scopeId = await this.ownerScopeForWrite(parent3?.channelId ?? m.channel_id);
39744
40779
  if (scopeId === null) return skip("scope");
39745
40780
  if (parseOwnerDmScopeId(scopeId)?.ownerIdentityId !== m.speaker.identityId) return skip("scope");
40781
+ const precommit = parent3 === void 0 || parentCapture === void 0 ? void 0 : () => {
40782
+ const now = this.store.itemCapture(parent3.channelId);
40783
+ if (now.purgeEpoch !== parentCapture.purgeEpoch) return "purged";
40784
+ if (now.roomClearGeneration !== parentCapture.roomClearGeneration) return "room_cleared";
40785
+ return null;
40786
+ };
39746
40787
  const r = await this.items.commit({
39747
40788
  scopeKind: "owner_dm",
39748
40789
  scopeId,
@@ -39753,7 +40794,8 @@ var BridgeAgent = class _BridgeAgent {
39753
40794
  source: "owner_voice",
39754
40795
  trust: OWNER_VOICE_TRUST,
39755
40796
  text: typeof m.text === "string" ? m.text : "",
39756
- expect: { ...c.capture, clearGeneration: c.stamp }
40797
+ expect: { ...c.capture, clearGeneration: c.stamp },
40798
+ ...precommit ? { precommit } : {}
39757
40799
  });
39758
40800
  return r.ok ? { ok: true } : { ok: false, reason: r.reason };
39759
40801
  } catch {
@@ -40115,17 +41157,17 @@ var BridgeAgent = class _BridgeAgent {
40115
41157
  async applyMemoryNote(raw, ctx) {
40116
41158
  if (this.unifiedOn) return this.applyUnifiedNote(raw, ctx);
40117
41159
  const ids = echoIds(raw);
40118
- const refuse2 = (reason, echo = ids) => {
41160
+ const refuse3 = (reason, echo = ids) => {
40119
41161
  writeServiceLog("info", "memory.note_refused", {
40120
41162
  reason_class: ServiceLogEnum.of(reason, ["mode", "invalid", "message", "audience", "provenance", "secret", "cap", "cleared", "store", "budget", "purged", "not_owner_words"])
40121
41163
  });
40122
41164
  return { type: "memory_note_result", ...echo, ok: false, reason };
40123
41165
  };
40124
- if (!this.notesProviderOn()) return refuse2("mode");
41166
+ if (!this.notesProviderOn()) return refuse3("mode");
40125
41167
  const f = parseMemoryNoteFrame(raw);
40126
- if (!f) return refuse2("invalid");
40127
- if (ctx.pendingRequests.has(f.request_id)) return refuse2("invalid", { message_id: f.message_id });
40128
- if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse2("invalid", {});
41168
+ if (!f) return refuse3("invalid");
41169
+ if (ctx.pendingRequests.has(f.request_id)) return refuse3("invalid", { message_id: f.message_id });
41170
+ if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse3("invalid", {});
40129
41171
  ctx.pendingRequests.add(f.request_id);
40130
41172
  try {
40131
41173
  const w2 = () => {
@@ -40137,13 +41179,13 @@ var BridgeAgent = class _BridgeAgent {
40137
41179
  return cur.record;
40138
41180
  };
40139
41181
  const rec = w2();
40140
- if (rec === void 0) return refuse2("message");
41182
+ if (rec === void 0) return refuse3("message");
40141
41183
  const channelId = rec.channel_id;
40142
- if (rec.authorization?.memory_write !== true) return refuse2("audience");
40143
- if (!this.noteTurnClean(rec.message_id)) return refuse2("audience");
41184
+ if (rec.authorization?.memory_write !== true) return refuse3("audience");
41185
+ if (!this.noteTurnClean(rec.message_id)) return refuse3("audience");
40144
41186
  const held = await this.heldPosition(channelId);
40145
41187
  const now = this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false });
40146
- if (now.memory_write !== true) return refuse2("audience");
41188
+ if (now.memory_write !== true) return refuse3("audience");
40147
41189
  const spend = async () => {
40148
41190
  const rw = await this.store.prepareRuntimeInboxRewrite(rec.message_id, bumpW9);
40149
41191
  if (rw === "declined") return "budget";
@@ -40159,44 +41201,44 @@ var BridgeAgent = class _BridgeAgent {
40159
41201
  };
40160
41202
  if (f.op === "forget" && typeof f.id === "object") {
40161
41203
  const shown = ctx.shownRecall(f.message_id, ctx.token);
40162
- if (!shown || !shown.has(f.id.recall)) return refuse2("invalid");
41204
+ if (!shown || !shown.has(f.id.recall)) return refuse3("invalid");
40163
41205
  const rw = await spend();
40164
- if (typeof rw === "string") return refuse2(rw);
40165
- if (w2() !== rec) return refuse2("message");
40166
- if (!this.notesProviderOn()) return refuse2("mode");
41206
+ if (typeof rw === "string") return refuse3(rw);
41207
+ if (w2() !== rec) return refuse3("message");
41208
+ if (!this.notesProviderOn()) return refuse3("mode");
40167
41209
  const r2 = await this.recall.forget(channelId, f.id.recall, rw, precommit(rw));
40168
- if (r2 === "unknown_id") return refuse2("invalid");
40169
- if (r2 !== "ok") return refuse2(r2);
41210
+ if (r2 === "unknown_id") return refuse3("invalid");
41211
+ if (r2 !== "ok") return refuse3(r2);
40170
41212
  writeServiceLog("info", "memory.recall_forgotten");
40171
41213
  return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true };
40172
41214
  }
40173
41215
  let mutation;
40174
41216
  let rewrite;
40175
41217
  if (f.op === "forget") {
40176
- if (typeof f.id !== "number") return refuse2("invalid");
41218
+ if (typeof f.id !== "number") return refuse3("invalid");
40177
41219
  const rw = await spend();
40178
- if (typeof rw === "string") return refuse2(rw);
41220
+ if (typeof rw === "string") return refuse3(rw);
40179
41221
  rewrite = rw;
40180
41222
  mutation = { op: "forget", id: f.id, rewrite: rw };
40181
41223
  } else {
40182
41224
  const p = prepareStoredNote(f.text, { text: rec.text, ...rec.transcribed === true ? { transcribed: true } : {} });
40183
- if (!p.ok) return refuse2(p.reason);
41225
+ if (!p.ok) return refuse3(p.reason);
40184
41226
  if (f.op === "replace") {
40185
- if (typeof f.id !== "number") return refuse2("invalid");
41227
+ if (typeof f.id !== "number") return refuse3("invalid");
40186
41228
  const rw = await spend();
40187
- if (typeof rw === "string") return refuse2(rw);
41229
+ if (typeof rw === "string") return refuse3(rw);
40188
41230
  rewrite = rw;
40189
41231
  mutation = { op: "replace", id: f.id, text: p.text, rewrite: rw };
40190
41232
  } else {
40191
41233
  mutation = { op: "add", text: p.text };
40192
41234
  }
40193
41235
  }
40194
- if (w2() !== rec) return refuse2("message");
40195
- if (!this.notesProviderOn()) return refuse2("mode");
41236
+ if (w2() !== rec) return refuse3("message");
41237
+ if (!this.notesProviderOn()) return refuse3("mode");
40196
41238
  const stamp = this.ingestGeneration.get(rec.message_id);
40197
- if (stamp === void 0) return refuse2("cleared");
41239
+ if (stamp === void 0) return refuse3("cleared");
40198
41240
  const r = await this.notes.write(channelId, mutation, stamp, precommit(rewrite));
40199
- if (!r.ok) return refuse2(r.reason);
41241
+ if (!r.ok) return refuse3(r.reason);
40200
41242
  writeServiceLog("info", "memory.note_written", {
40201
41243
  op: ServiceLogEnum.of(f.op, ["add", "replace", "forget"]),
40202
41244
  notes_count: r.notesCount,
@@ -40205,7 +41247,7 @@ var BridgeAgent = class _BridgeAgent {
40205
41247
  return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true, id: r.id };
40206
41248
  } catch (e) {
40207
41249
  writeServiceLog("warn", "memory.note_write_failed", {}, e);
40208
- return refuse2("store");
41250
+ return refuse3("store");
40209
41251
  } finally {
40210
41252
  ctx.pendingRequests.delete(f.request_id);
40211
41253
  }
@@ -40219,17 +41261,17 @@ var BridgeAgent = class _BridgeAgent {
40219
41261
  */
40220
41262
  async applyUnifiedNote(raw, ctx) {
40221
41263
  const ids = echoIds(raw);
40222
- const refuse2 = (reason, echo = ids) => {
41264
+ const refuse3 = (reason, echo = ids) => {
40223
41265
  writeServiceLog("info", "memory.note_refused", {
40224
41266
  reason_class: ServiceLogEnum.of(reason, ["mode", "invalid", "message", "audience", "provenance", "secret", "cap", "cleared", "store", "budget", "purged", "not_owner_words"])
40225
41267
  });
40226
41268
  return { type: "memory_note_result", ...echo, ok: false, reason };
40227
41269
  };
40228
- if (!this.unifiedProviderOn()) return refuse2("mode");
41270
+ if (!this.unifiedProviderOn()) return refuse3("mode");
40229
41271
  const f = parseMemoryNoteFrame(raw);
40230
- if (!f) return refuse2("invalid");
40231
- if (ctx.pendingRequests.has(f.request_id)) return refuse2("invalid", { message_id: f.message_id });
40232
- if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse2("invalid", {});
41272
+ if (!f) return refuse3("invalid");
41273
+ if (ctx.pendingRequests.has(f.request_id)) return refuse3("invalid", { message_id: f.message_id });
41274
+ if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse3("invalid", {});
40233
41275
  ctx.pendingRequests.add(f.request_id);
40234
41276
  try {
40235
41277
  const w2 = () => {
@@ -40241,25 +41283,25 @@ var BridgeAgent = class _BridgeAgent {
40241
41283
  return cur.record;
40242
41284
  };
40243
41285
  const rec = w2();
40244
- if (rec === void 0) return refuse2("message");
41286
+ if (rec === void 0) return refuse3("message");
40245
41287
  const channelId = rec.channel_id;
40246
- if (rec.authorization?.memory_write !== true) return refuse2("audience");
40247
- if (!this.noteTurnClean(rec.message_id)) return refuse2("audience");
41288
+ if (rec.authorization?.memory_write !== true) return refuse3("audience");
41289
+ if (!this.noteTurnClean(rec.message_id)) return refuse3("audience");
40248
41290
  const held = await this.heldPosition(channelId);
40249
- if (this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false }).memory_write !== true) return refuse2("audience");
41291
+ if (this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false }).memory_write !== true) return refuse3("audience");
40250
41292
  const scopeId = this.ownerScopeNow(channelId);
40251
- if (scopeId === null) return refuse2("audience");
41293
+ if (scopeId === null) return refuse3("audience");
40252
41294
  const precommit = () => !this.unifiedProviderOn() ? "mode" : w2() !== rec ? "message" : null;
40253
- if (f.op === "replace") return refuse2("invalid");
40254
- if (f.op === "forget") return await this.forgetUnified(f, rec, channelId, ctx, refuse2, precommit);
41295
+ if (f.op === "replace") return refuse3("invalid");
41296
+ if (f.op === "forget") return await this.forgetUnified(f, rec, channelId, ctx, refuse3, precommit);
40255
41297
  const stripped = allowlistPin(f.text);
40256
- if (stripped.trim().length === 0) return refuse2("invalid");
41298
+ if (stripped.trim().length === 0) return refuse3("invalid");
40257
41299
  const shown = ctx.shownUnified(f.message_id, ctx.token) ?? /* @__PURE__ */ new Set();
40258
- if (!pinCoverage(stripped, rec.text, this.unifiedReads.coverageTexts(f.message_id, shown)).ok) return refuse2("not_owner_words");
40259
- if (scanNote(stripped) !== null) return refuse2("secret");
40260
- if (stripped.length > PIN_MAX_CHARS) return refuse2("cap");
41300
+ if (!pinCoverage(stripped, rec.text, this.unifiedReads.coverageTexts(f.message_id, shown)).ok) return refuse3("not_owner_words");
41301
+ if (scanNote(stripped) !== null) return refuse3("secret");
41302
+ if (stripped.length > PIN_MAX_CHARS) return refuse3("cap");
40261
41303
  const stamp = this.ingestGeneration.get(rec.message_id);
40262
- if (stamp === void 0) return refuse2("cleared");
41304
+ if (stamp === void 0) return refuse3("cleared");
40263
41305
  const capture = this.store.itemCapture(channelId);
40264
41306
  const r = await this.items.commit({
40265
41307
  scopeKind: "owner_dm",
@@ -40277,7 +41319,7 @@ var BridgeAgent = class _BridgeAgent {
40277
41319
  });
40278
41320
  if (!r.ok) {
40279
41321
  const map = { pin_cap: "cap", secret: "secret", cleared: "cleared", room_cleared: "cleared", disabled: "mode", mode: "mode", purged: "purged", message: "message", duplicate: "invalid", invalid: "invalid", empty: "invalid" };
40280
- return refuse2(map[r.reason] ?? "store");
41322
+ return refuse3(map[r.reason] ?? "store");
40281
41323
  }
40282
41324
  await this.notes.exclusive(async () => this.unifiedReads.bumpGeneration());
40283
41325
  void this.send(channelId, pinnedLine(stripped), void 0, { agentTurn: false }).catch((e) => writeServiceLog("warn", "memory.pinned_line_failed", {}, e));
@@ -40285,7 +41327,7 @@ var BridgeAgent = class _BridgeAgent {
40285
41327
  return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true, id: r.itemIds[0] };
40286
41328
  } catch (e) {
40287
41329
  writeServiceLog("warn", "memory.note_write_failed", {}, e);
40288
- return refuse2("store");
41330
+ return refuse3("store");
40289
41331
  } finally {
40290
41332
  ctx.pendingRequests.delete(f.request_id);
40291
41333
  }
@@ -40299,16 +41341,16 @@ var BridgeAgent = class _BridgeAgent {
40299
41341
  * is invalidated in the same mutex hold, the item leaves the ranking hot set, then the bridge publishes `Forgot:`
40300
41342
  * (OQ-P2: afterPublish keeps the line out of history and the items). Every reason is a key of UNIFIED_REFUSAL.
40301
41343
  */
40302
- async forgetUnified(f, rec, channelId, ctx, refuse2, precommit) {
40303
- if (typeof f.id !== "object") return refuse2("invalid");
41344
+ async forgetUnified(f, rec, channelId, ctx, refuse3, precommit) {
41345
+ if (typeof f.id !== "object") return refuse3("invalid");
40304
41346
  const itemId = f.id.recall;
40305
41347
  const shown = ctx.shownUnified(f.message_id, ctx.token);
40306
- if (!shown || !shown.has(itemId)) return refuse2("invalid");
41348
+ if (!shown || !shown.has(itemId)) return refuse3("invalid");
40307
41349
  const text = this.unifiedReads.shownText(f.message_id, itemId);
40308
- if (text === void 0) return refuse2("invalid");
41350
+ if (text === void 0) return refuse3("invalid");
40309
41351
  const rw = await this.store.prepareRuntimeInboxRewrite(rec.message_id, bumpW9);
40310
- if (rw === "declined") return refuse2("budget");
40311
- if (rw === "no_row") return refuse2("message");
41352
+ if (rw === "declined") return refuse3("budget");
41353
+ if (rw === "no_row") return refuse3("message");
40312
41354
  const r = await this.notes.exclusive(async () => {
40313
41355
  const veto = precommit();
40314
41356
  if (veto !== null) return { ok: false, reason: veto };
@@ -40321,7 +41363,7 @@ var BridgeAgent = class _BridgeAgent {
40321
41363
  }
40322
41364
  return out;
40323
41365
  });
40324
- if (!r.ok) return refuse2(r.reason === "unknown_id" ? "invalid" : r.reason);
41366
+ if (!r.ok) return refuse3(r.reason === "unknown_id" ? "invalid" : r.reason);
40325
41367
  this.rankingNote(() => this.ranking.removed(r.itemIds));
40326
41368
  void this.send(channelId, forgotLine(text), void 0, { agentTurn: false }).catch((e) => writeServiceLog("warn", "memory.forgot_line_failed", {}, e));
40327
41369
  writeServiceLog("info", "memory.item_forgotten");
@@ -40395,8 +41437,9 @@ var BridgeAgent = class _BridgeAgent {
40395
41437
  this.fastReplyChains.set(channelId, next);
40396
41438
  }
40397
41439
  /** Count a fast reply against FAST_REPLY_MAX_JOBS and reserve its room send-floor token; returns the release. */
40398
- reserveFastReply(channelId) {
41440
+ reserveFastReply(replyChannel) {
40399
41441
  this.fastReplyJobs++;
41442
+ const channelId = this.capChannelFor(replyChannel);
40400
41443
  this.fastReplyReserved.set(channelId, (this.fastReplyReserved.get(channelId) ?? 0) + 1);
40401
41444
  return () => {
40402
41445
  this.fastReplyJobs--;
@@ -40620,6 +41663,7 @@ var BridgeAgent = class _BridgeAgent {
40620
41663
  });
40621
41664
  if (!res.ok) throw new Error(`list_channels failed: ${res.status}`);
40622
41665
  const rows = await res.json();
41666
+ for (const r of rows) if (r.channel_kind === "voice") this.listedAsVoice.add(r.channel_id);
40623
41667
  for (const r of rows) this.store.recordChannelEnclave(r.channel_id, r.enclave_id);
40624
41668
  return rows.map((r) => ({
40625
41669
  channel_id: r.channel_id,
@@ -40703,6 +41747,7 @@ var BridgeAgent = class _BridgeAgent {
40703
41747
  */
40704
41748
  recordListedChannelMeta(channels, at) {
40705
41749
  for (const c of channels) {
41750
+ if (this.listedAsVoice.has(c.channel_id)) continue;
40706
41751
  this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at });
40707
41752
  this.store.recordChannelIsDm(c.channel_id, c.is_dm);
40708
41753
  }
@@ -40886,7 +41931,8 @@ var BridgeAgent = class _BridgeAgent {
40886
41931
  }
40887
41932
  const self = members.get(this.cfg.identityId);
40888
41933
  if (self?.name) this.lastOwnDisplayName = self.name;
40889
- await this.resolveAndPinOwner(channelId, liveRows);
41934
+ const unclassified = this.isUnlistedChannel(channelId) && !this.voiceChannels.isNotVoice(channelId);
41935
+ if (!this.voiceChannels.isVoice(channelId) && !unclassified) await this.resolveAndPinOwner(channelId, liveRows);
40890
41936
  } catch {
40891
41937
  }
40892
41938
  }
@@ -40922,7 +41968,7 @@ var BridgeAgent = class _BridgeAgent {
40922
41968
  * held group (never the backend's row label, M-2). NEVER throws (M-4): any
40923
41969
  * exception is `failed verify_error`, and the row is acked like any other turn.
40924
41970
  */
40925
- async verifyOwnerTurn(m) {
41971
+ async verifyOwnerTurn(m, keyChannel = m.channelId) {
40926
41972
  const pin = this.cfg.ownerGrantPin?.pin ?? null;
40927
41973
  if (!pin || m.envelope.sender_id !== pin.ownerIdentityId) return OWNER_TURN_UNCHECKED;
40928
41974
  try {
@@ -40934,9 +41980,9 @@ var BridgeAgent = class _BridgeAgent {
40934
41980
  }
40935
41981
  if (v.sender_id !== pin.ownerIdentityId) return this.ownerTurnFailed(m, "malformed");
40936
41982
  let leafClass = "ok";
40937
- if (this.freshMeta(m.channelId)?.is_dm === true) {
41983
+ if (this.freshMeta(keyChannel)?.is_dm === true) {
40938
41984
  const signedDm = this.toolGrantManifest?.owner_dm_channel_id;
40939
- if (signedDm !== void 0 && signedDm !== m.channelId) return this.ownerTurnFailed(m, "wrong_dm_channel");
41985
+ if (signedDm !== void 0 && signedDm !== keyChannel) return this.ownerTurnFailed(m, "wrong_dm_channel");
40940
41986
  leafClass = classifyOwnerDmLeaves((await this.provider.leafTreeSummary(m.channelId)).leaves, {
40941
41987
  ownerIdentityId: pin.ownerIdentityId,
40942
41988
  agentIdentityId: this.cfg.identityId,
@@ -41100,9 +42146,14 @@ var BridgeAgent = class _BridgeAgent {
41100
42146
  });
41101
42147
  }
41102
42148
  /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
41103
- async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId, voiceScoped) {
42149
+ async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId, voiceScoped, voiceParent) {
41104
42150
  const held = await this.heldPosition(channelId);
41105
- const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId, { ownerVerdict, voiceScoped });
42151
+ const keyHeld = voiceParent === void 0 ? held : await this.heldPosition(voiceParent);
42152
+ const auth = this.authorizationFor(voiceParent ?? channelId, senderId, relaySpeakerId, keyHeld.epoch, keyHeld.groupId, {
42153
+ ownerVerdict,
42154
+ voiceScoped,
42155
+ ...voiceParent === void 0 ? {} : { voice: { channelId, heldEpoch: held.epoch, heldGroupId: held.groupId } }
42156
+ });
41106
42157
  if (!(auth.audience === "team_room" && auth.tool_grants.length > 0)) return auth;
41107
42158
  if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
41108
42159
  writeServiceLog("warn", "room_grant.untracked");
@@ -41155,7 +42206,10 @@ var BridgeAgent = class _BridgeAgent {
41155
42206
  const meta = this.freshMeta(channelId);
41156
42207
  const entry = this.freshRosterEntry(channelId);
41157
42208
  const roster = entry?.members;
41158
- const sender = roster?.get(senderId);
42209
+ const vEntry = opts.voice === void 0 ? void 0 : this.freshRosterEntry(opts.voice.channelId);
42210
+ const vSender = vEntry?.members.get(senderId);
42211
+ const sender = opts.voice === void 0 ? roster?.get(senderId) : vSender === void 0 ? void 0 : roster?.get(senderId) ?? vSender;
42212
+ const senderIsRelay = opts.voice === void 0 ? sender?.is_relay === true : vSender?.is_relay === true;
41159
42213
  let audience = "unknown";
41160
42214
  if (meta && sender) {
41161
42215
  if (sender.is_agent) audience = "agent_to_agent";
@@ -41167,15 +42221,15 @@ var BridgeAgent = class _BridgeAgent {
41167
42221
  let tool_grants = NO_TOOL_GRANTS;
41168
42222
  let relaySpeaker;
41169
42223
  let memoryWrite = false;
41170
- const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId);
41171
- const ownerDmFresh = epochFresh || this.ownerDmCycleFresh.has(channelId);
42224
+ const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId) && (opts.voice === void 0 || rosterFreshAt(vEntry, opts.voice.heldEpoch, opts.voice.heldGroupId));
42225
+ const ownerDmFresh = epochFresh || opts.voice === void 0 && this.ownerDmCycleFresh.has(channelId);
41172
42226
  if (ownerDmFresh && this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
41173
42227
  const pinned = this.channelOwner.get(channelId);
41174
42228
  const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
41175
42229
  if (ownerId !== void 0) {
41176
42230
  const ownerDirect = audience === "owner_dm" && senderId === ownerId;
41177
42231
  const owner = roster?.get(ownerId);
41178
- const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
42232
+ const ownerRelayed = relaySpeakerId !== void 0 && senderIsRelay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
41179
42233
  if (ownerDirect) tool_grants = opts.voiceScoped === true ? RELAYED_VOICE_GRANTS : OWNER_DM_DEFAULT_READ_GRANTS;
41180
42234
  if (ownerRelayed) tool_grants = RELAYED_VOICE_GRANTS;
41181
42235
  if (ownerRelayed) relaySpeaker = "owner";
@@ -41854,6 +42908,9 @@ var BridgeAgent = class _BridgeAgent {
41854
42908
  async ingestCycleBody(receiptIds) {
41855
42909
  if (this.closingDown) return;
41856
42910
  const cycleStart = performance.now();
42911
+ let voiceListed = false;
42912
+ const cycleStartedAt = Date.now();
42913
+ const joinedInPhase0 = /* @__PURE__ */ new Set();
41857
42914
  try {
41858
42915
  const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
41859
42916
  this.recordRecvLag(rows);
@@ -41863,9 +42920,11 @@ var BridgeAgent = class _BridgeAgent {
41863
42920
  }
41864
42921
  for (const cid of channelIds) {
41865
42922
  if (this.joinState.get(cid) !== "JOINED") {
42923
+ joinedInPhase0.add(cid);
41866
42924
  await this.reconcileJoin(cid);
41867
42925
  }
41868
42926
  }
42927
+ if (await this.resolveUnlistedChannels(channelIds, false)) voiceListed = true;
41869
42928
  for (const cid of channelIds) {
41870
42929
  if (this.freshRoster(cid) === void 0) {
41871
42930
  await this.refreshChannelRoster(cid);
@@ -41890,6 +42949,13 @@ var BridgeAgent = class _BridgeAgent {
41890
42949
  }
41891
42950
  } catch {
41892
42951
  }
42952
+ const voiceStamp = await this.voicePositionSnapshot();
42953
+ const receiveStartedAt = Date.now();
42954
+ const stampsFor = (m) => {
42955
+ if (m.receivedAt >= receiveStartedAt) return true;
42956
+ const p = this.voiceChannels.parentOf(m.channelId);
42957
+ return m.receivedAt >= cycleStartedAt && p !== void 0 && !joinedInPhase0.has(p);
42958
+ };
41893
42959
  let entries;
41894
42960
  try {
41895
42961
  entries = await this.latency.time("recv.decrypt", () => this.provider.receiveOnce(this.cfg.deviceId));
@@ -41899,29 +42965,56 @@ var BridgeAgent = class _BridgeAgent {
41899
42965
  for (const cid of entries.supersededChannelIds ?? []) {
41900
42966
  this.joinState.set(cid, "JOINED");
41901
42967
  }
41902
- if (!entries.length) return;
42968
+ if (!entries.length) {
42969
+ await this.reevaluateVoiceBindings();
42970
+ return;
42971
+ }
41903
42972
  await this.maybePruneOwnerReplay();
41904
42973
  const receivedAt = Date.now();
41905
42974
  const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
41906
42975
  const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
41907
42976
  const recallModeEpochAtReceipt = this.store.recallState().modeEpoch;
42977
+ const unifiedVoiceParentAt = (p) => {
42978
+ const cap = itemCaptureAtReceipt.get(p);
42979
+ return { channelId: p, capture: cap === void 0 ? void 0 : { ...cap, purgeEpoch: purgeEpochAtReceipt.get(p) ?? -1 } };
42980
+ };
41908
42981
  for (const m of entries) {
41909
42982
  this.lastInboundAt.set(m.channelId, receivedAt);
41910
42983
  if (m.voiceMode === true) this.heartbeat.noteVoiceActivity();
41911
42984
  if (!purgeEpochAtReceipt.has(m.channelId)) purgeEpochAtReceipt.set(m.channelId, this.store.conversationPurgeEpoch(m.channelId));
41912
42985
  if (this.unifiedOn && !itemCaptureAtReceipt.has(m.channelId)) itemCaptureAtReceipt.set(m.channelId, this.store.itemCapture(m.channelId));
42986
+ const receiptParent = this.voiceChannels.parentOf(m.channelId);
42987
+ if (receiptParent !== void 0) {
42988
+ if (!purgeEpochAtReceipt.has(receiptParent)) purgeEpochAtReceipt.set(receiptParent, this.store.conversationPurgeEpoch(receiptParent));
42989
+ if (this.unifiedOn && !itemCaptureAtReceipt.has(receiptParent)) itemCaptureAtReceipt.set(receiptParent, this.store.itemCapture(receiptParent));
42990
+ }
41913
42991
  this.recallReceipt.set(m.messageId, { purgeEpoch: purgeEpochAtReceipt.get(m.channelId), modeEpoch: recallModeEpochAtReceipt });
41914
42992
  receiptIds.push(m.messageId);
42993
+ const stamp = voiceStamp.get(m.channelId);
42994
+ if (stamp !== void 0) this.voiceChannels.noteReceipt(m.messageId, stampsFor(m) ? stamp : null);
42995
+ else if (this.voiceChannels.isVoice(m.channelId) || this.isUnlistedChannel(m.channelId) && !this.voiceChannels.isResolved(m.channelId)) {
42996
+ this.voiceChannels.noteReceipt(m.messageId, null);
42997
+ }
41915
42998
  }
41916
- let metaRefreshed = false;
41917
- if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
42999
+ if (await this.resolveUnlistedChannels(entries.map((m) => m.channelId), voiceListed)) voiceListed = true;
43000
+ await this.reevaluateVoiceBindings();
43001
+ entries = await this.gateVoiceEntries(entries);
43002
+ let metaRefreshed = voiceListed;
43003
+ if (!voiceListed && entries.some((m) => this.freshMeta(this.keyChannelForFrame(m)) === void 0)) {
41918
43004
  await this.refreshChannelMeta();
41919
43005
  metaRefreshed = true;
41920
43006
  }
41921
- await this.refreshRostersForCycle(entries.map((m) => m.channelId));
43007
+ await this.refreshRostersForCycle(entries.flatMap((m) => {
43008
+ const key = this.keyChannelForFrame(m);
43009
+ return key === m.channelId ? [m.channelId] : [m.channelId, key];
43010
+ }));
41922
43011
  const relayMissRefreshed = /* @__PURE__ */ new Set();
41923
43012
  const acked = [];
41924
43013
  for (const m of entries) {
43014
+ if (this.voiceChannels.isVoice(m.channelId) && !this.voiceFrameParent.has(m.messageId)) {
43015
+ acked.push(m.queueId);
43016
+ continue;
43017
+ }
41925
43018
  try {
41926
43019
  if (m.voiceMode === false) this.callBuffer.reset(m.channelId);
41927
43020
  const encId = m.envelope.enclave_id;
@@ -42082,7 +43175,9 @@ var BridgeAgent = class _BridgeAgent {
42082
43175
  }
42083
43176
  if (typeof parsed.text === "string") {
42084
43177
  if (parsed.text.trim() === VOICE_CALL_START_MARKER) this.callBuffer.reset(m.channelId);
42085
- const meta = this.freshMeta(m.channelId);
43178
+ const keyChannel = this.keyChannelForFrame(m);
43179
+ const voiceParent = keyChannel !== m.channelId ? keyChannel : void 0;
43180
+ const meta = this.freshMeta(keyChannel);
42086
43181
  const senderType = m.envelope.sender_type;
42087
43182
  const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
42088
43183
  const claimsRelaySpeaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string";
@@ -42129,14 +43224,15 @@ var BridgeAgent = class _BridgeAgent {
42129
43224
  senderIsRelay: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.is_relay === true,
42130
43225
  text: parsed.text
42131
43226
  });
42132
- const ownerVerdict = await this.verifyOwnerTurn(m);
43227
+ const ownerVerdict = await this.verifyOwnerTurn(m, keyChannel);
42133
43228
  const turnAuthorization = await this.authorizeTurn(
42134
43229
  m.channelId,
42135
43230
  m.messageId,
42136
43231
  m.envelope.sender_id,
42137
43232
  ownerVerdict,
42138
43233
  speaker?.identityId,
42139
- voiceScoped
43234
+ voiceScoped,
43235
+ voiceParent
42140
43236
  );
42141
43237
  await this.prepareNotesForTurn(m.channelId, turnAuthorization);
42142
43238
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
@@ -42163,11 +43259,12 @@ var BridgeAgent = class _BridgeAgent {
42163
43259
  }
42164
43260
  let fastPathRoomBlocked = false;
42165
43261
  if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
42166
- const reserved = this.fastReplyReserved.get(m.channelId) ?? 0;
42167
- if (this.rateFloor.available(`room:${m.channelId}`) - reserved < 2) fastPathRoomBlocked = true;
43262
+ const floorKey = this.capChannelFor(m.channelId);
43263
+ const reserved = this.fastReplyReserved.get(floorKey) ?? 0;
43264
+ if (this.rateFloor.available(`room:${floorKey}`) - reserved < 2) fastPathRoomBlocked = true;
42168
43265
  }
42169
43266
  if (fastPathRoomBlocked) this.fastPathSkipped++;
42170
- const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedChannels.has(m.channelId);
43267
+ const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedFor(m.channelId);
42171
43268
  const fastGreeting = isCallStart && fastPathFiring;
42172
43269
  const runtimeVoice = (ackSent) => m.voiceMode ? {
42173
43270
  voice: true,
@@ -42236,6 +43333,7 @@ var BridgeAgent = class _BridgeAgent {
42236
43333
  pending: Promise.resolve(),
42237
43334
  purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
42238
43335
  itemCapture: itemCaptureAtReceipt.get(m.channelId),
43336
+ ...voiceParent !== void 0 ? { voiceParent: unifiedVoiceParentAt(voiceParent) } : {},
42239
43337
  bufferGeneration
42240
43338
  };
42241
43339
  this.voiceCoalescing.set(windowKey, win);
@@ -42266,18 +43364,19 @@ var BridgeAgent = class _BridgeAgent {
42266
43364
  const merged = { ...held, text, memory: windowMemory };
42267
43365
  if (this.unifiedOn) {
42268
43366
  const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
42269
- const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
43367
+ const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 }, ...win.voiceParent ? { parent: win.voiceParent } : {} });
42270
43368
  this.unifiedWork.add(work);
42271
43369
  void work.finally(() => this.unifiedWork.delete(work));
42272
43370
  }
42273
43371
  void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice", win.purgeEpoch);
42274
43372
  let roomBlocked = false;
42275
43373
  if (fastPathEligible && isRoom) {
42276
- const reserved = this.fastReplyReserved.get(channelId) ?? 0;
42277
- if (this.rateFloor.available(`room:${channelId}`) - reserved < 2) roomBlocked = true;
43374
+ const floorKey = this.capChannelFor(channelId);
43375
+ const reserved = this.fastReplyReserved.get(floorKey) ?? 0;
43376
+ if (this.rateFloor.available(`room:${floorKey}`) - reserved < 2) roomBlocked = true;
42278
43377
  }
42279
43378
  if (roomBlocked) this.fastPathSkipped++;
42280
- const plan = fastPathEligible && !roomBlocked && !this.pausedChannels.has(channelId) ? this.planFastPath(firstId, text, false) : void 0;
43379
+ const plan = fastPathEligible && !roomBlocked && !this.pausedFor(channelId) ? this.planFastPath(firstId, text, false) : void 0;
42281
43380
  if (!plan || plan.kind === "dup" || plan.kind === "skip") {
42282
43381
  if (plan?.kind === "skip" && plan.full) this.fastPathSkipped++;
42283
43382
  await this.settleHeldRuntimeMessage(merged, true);
@@ -42355,7 +43454,11 @@ var BridgeAgent = class _BridgeAgent {
42355
43454
  );
42356
43455
  if (windowEligible && turnAuthorization.relay_speaker === "owner") {
42357
43456
  const snap = itemCaptureAtReceipt.get(m.channelId) ?? { ...this.store.itemCapture(m.channelId), purgeEpoch: -1 };
42358
- const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), { stamp: this.ingestGeneration.get(m.messageId), capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 } });
43457
+ const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
43458
+ stamp: this.ingestGeneration.get(m.messageId),
43459
+ capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 },
43460
+ ...voiceParent !== void 0 ? { parent: unifiedVoiceParentAt(voiceParent) } : {}
43461
+ });
42359
43462
  this.unifiedWork.add(work);
42360
43463
  void work.finally(() => this.unifiedWork.delete(work));
42361
43464
  }
@@ -42381,6 +43484,7 @@ var BridgeAgent = class _BridgeAgent {
42381
43484
  }
42382
43485
  }
42383
43486
  for (const m of entries) this.recallReceipt.delete(m.messageId);
43487
+ for (const m of entries) this.voiceFrameParent.delete(m.messageId);
42384
43488
  if (acked.length) {
42385
43489
  try {
42386
43490
  await this.latency.time("recv.ack", () => this.provider.ackDelivery(this.cfg.deviceId, acked));
@@ -42661,8 +43765,9 @@ var BridgeAgent = class _BridgeAgent {
42661
43765
  }
42662
43766
  }
42663
43767
  /** Spec §7.5 (T-14c, M-4): the runtime reports an executed-call cap for a room. Never throws. */
42664
- noteRoomCapReached(channelId) {
42665
- if (typeof channelId !== "string" || this.closingDown) return;
43768
+ noteRoomCapReached(reported) {
43769
+ if (typeof reported !== "string" || this.closingDown) return;
43770
+ const channelId = this.capChannelFor(reported);
42666
43771
  if (this.roomSearchDailyCap === 0) return;
42667
43772
  if (this.freshMeta(channelId)?.is_dm !== false || this.channelRoster.get(channelId)?.crossOrg !== false) return;
42668
43773
  const day = utcDayOf(Date.now());
@@ -42719,8 +43824,9 @@ ${channelId}`;
42719
43824
  const a = entry.authorization;
42720
43825
  if (!a || a.audience !== "team_room" || !a.tool_grants.some((g) => g.tool === "web_search")) return entry;
42721
43826
  const day = utcDayOf(Date.now());
43827
+ const room = this.capChannelFor(entry.channel_id);
42722
43828
  if (this.roomSearchDailyCap === 0) {
42723
- this.logRoomCap(day, entry.channel_id, "operator");
43829
+ this.logRoomCap(day, room, "operator");
42724
43830
  return this.withoutWebSearch(entry);
42725
43831
  }
42726
43832
  if (this.agentRoomSearchDailyCap === 0) {
@@ -42728,9 +43834,9 @@ ${channelId}`;
42728
43834
  return this.withoutWebSearch(entry);
42729
43835
  }
42730
43836
  try {
42731
- const n = this.store.countRoomSearchGrant(entry.channel_id, day);
42732
- if (n === this.roomSearchDailyCap && this.store.markRoomSearchCapReached(entry.channel_id, day, "bridge")) {
42733
- this.logRoomCap(day, entry.channel_id, "bridge");
43837
+ const n = this.store.countRoomSearchGrant(room, day);
43838
+ if (n === this.roomSearchDailyCap && this.store.markRoomSearchCapReached(room, day, "bridge")) {
43839
+ this.logRoomCap(day, room, "bridge");
42734
43840
  }
42735
43841
  if (n > this.roomSearchDailyCap) return this.withoutWebSearch(entry);
42736
43842
  const total = this.store.countRoomSearchGrant(AGENT_TOTAL_KEY, day);
@@ -42827,9 +43933,15 @@ ${channelId}`;
42827
43933
  * PAUSE_RECHECK_MS window, so release does not depend on new traffic.
42828
43934
  */
42829
43935
  heldForPause(channelId) {
42830
- const at = this.pausedChannels.get(channelId);
43936
+ let key = channelId;
43937
+ let at = this.pausedChannels.get(channelId);
43938
+ const parent3 = at === void 0 ? this.voiceChannels.parentOf(channelId) : void 0;
43939
+ if (parent3 !== void 0) {
43940
+ key = parent3;
43941
+ at = this.pausedChannels.get(parent3);
43942
+ }
42831
43943
  if (at === void 0) return false;
42832
- this.schedulePauseRecheck(channelId, at);
43944
+ this.schedulePauseRecheck(key, at);
42833
43945
  return true;
42834
43946
  }
42835
43947
  schedulePauseRecheck(channelId, confirmedAt) {
@@ -42858,7 +43970,7 @@ ${channelId}`;
42858
43970
  if (await this.probeOwnPause(channelId) === true) {
42859
43971
  const now = Date.now();
42860
43972
  this.pausedChannels.set(channelId, now);
42861
- if (this.buffer.some((m) => m.channel_id === channelId)) this.schedulePauseRecheck(channelId, now);
43973
+ if (this.buffer.some((m) => m.channel_id === channelId || this.voiceChannels.parentOf(m.channel_id) === channelId)) this.schedulePauseRecheck(channelId, now);
42862
43974
  return;
42863
43975
  }
42864
43976
  this.clearPause(channelId);
@@ -43337,6 +44449,10 @@ ${channelId}`;
43337
44449
  custody_store_unavailable: this.provider.custodyHealth().storeUnavailable,
43338
44450
  failed_attachments: this.failedAttachments,
43339
44451
  refused_vault_refs: this.refusedVaultRefs,
44452
+ voice_frames_refused: this.voiceFramesRefused,
44453
+ voice_frames_filtered: this.voiceFramesFiltered,
44454
+ voice_receipt_cap_reached: this.voiceChannels.receiptCapReached(),
44455
+ voice_relay_in_parent: this.voiceChannels.relayInParentCount(),
43340
44456
  fast_path_sent: this.fastPathSent,
43341
44457
  fast_path_failed: this.fastPathFailed,
43342
44458
  fast_path_skipped: this.fastPathSkipped,
@@ -46000,7 +47116,7 @@ var PIN_COPY = Object.freeze({
46000
47116
  usage: "usage: agent-bridge pin --credential <path> --file <owner-signing-key.json> --agent <agent_identity_id>"
46001
47117
  // [COPY, approved by Eric 2026-10-12] (P-4)
46002
47118
  });
46003
- var refuse = (reason) => ({ ok: false, reason });
47119
+ var refuse2 = (reason) => ({ ok: false, reason });
46004
47120
  function isSymlink(path) {
46005
47121
  try {
46006
47122
  return lstatSync8(path).isSymbolicLink();
@@ -46030,33 +47146,33 @@ function runPinCommand(opts) {
46030
47146
  const credDir = dirname12(resolve2(opts.credentialPath));
46031
47147
  const target = join23(credDir, PIN_FILE_NAME);
46032
47148
  try {
46033
- if (realpathSync2(dirname12(resolve2(opts.pinSourcePath))) !== realpathSync2(credDir)) return refuse("directory_mismatch");
47149
+ if (realpathSync2(dirname12(resolve2(opts.pinSourcePath))) !== realpathSync2(credDir)) return refuse2("directory_mismatch");
46034
47150
  } catch {
46035
- return refuse("io_error");
47151
+ return refuse2("io_error");
46036
47152
  }
46037
47153
  try {
46038
- if (isSymlink(opts.credentialPath) || isSymlink(opts.pinSourcePath) || isSymlink(target)) return refuse("symlink_refused");
47154
+ if (isSymlink(opts.credentialPath) || isSymlink(opts.pinSourcePath) || isSymlink(target)) return refuse2("symlink_refused");
46039
47155
  } catch {
46040
- return refuse("io_error");
47156
+ return refuse2("io_error");
46041
47157
  }
46042
47158
  let source;
46043
47159
  try {
46044
47160
  source = readJson(opts.pinSourcePath);
46045
47161
  } catch {
46046
- return refuse("pin_invalid");
47162
+ return refuse2("pin_invalid");
46047
47163
  }
46048
47164
  const parsed = parsePinObject(source);
46049
- if (!parsed.ok) return refuse("pin_invalid");
47165
+ if (!parsed.ok) return refuse2("pin_invalid");
46050
47166
  let cred;
46051
47167
  try {
46052
47168
  const c = readJson(opts.credentialPath);
46053
- if (typeof c !== "object" || c === null || Array.isArray(c)) return refuse("credential_invalid");
47169
+ if (typeof c !== "object" || c === null || Array.isArray(c)) return refuse2("credential_invalid");
46054
47170
  cred = c;
46055
47171
  } catch {
46056
- return refuse("credential_invalid");
47172
+ return refuse2("credential_invalid");
46057
47173
  }
46058
- for (const k of REQUIRED) if (typeof cred[k] !== "string" || !cred[k]) return refuse("credential_invalid");
46059
- if (cred.identity_id !== opts.agentIdentityId) return refuse("agent_mismatch");
47174
+ for (const k of REQUIRED) if (typeof cred[k] !== "string" || !cred[k]) return refuse2("credential_invalid");
47175
+ if (cred.identity_id !== opts.agentIdentityId) return refuse2("agent_mismatch");
46060
47176
  opts.announce(cred.identity_id);
46061
47177
  const newKey = parsed.pin.publicKeyB64;
46062
47178
  let priorFileKey;
@@ -46081,7 +47197,7 @@ function runPinCommand(opts) {
46081
47197
  } catch {
46082
47198
  }
46083
47199
  }
46084
- return refuse("io_error");
47200
+ return refuse2("io_error");
46085
47201
  };
46086
47202
  const step = (s) => opts.afterStep?.(s);
46087
47203
  try {
@@ -46589,7 +47705,7 @@ function realRunModeDeps(credentialPath, storePath) {
46589
47705
  }
46590
47706
  async function runMode(deps) {
46591
47707
  const { credentialPath, storePath, log } = deps;
46592
- const refuse2 = (reason, extra, e) => {
47708
+ const refuse3 = (reason, extra, e) => {
46593
47709
  const fields = {
46594
47710
  reason: ServiceLogEnum.of(reason, [
46595
47711
  "credential_not_found",
@@ -46621,18 +47737,18 @@ async function runMode(deps) {
46621
47737
  cfg = loadConfig({ credentialPath, storePath });
46622
47738
  daemon = parseDaemonArgs(deps.argv, deps.env);
46623
47739
  } catch (e) {
46624
- if (e instanceof ConfigError) refuse2(e.reason, { field: e.field, source: e.source }, e);
46625
- else refuse2("unknown", {}, e);
47740
+ if (e instanceof ConfigError) refuse3(e.reason, { field: e.field, source: e.source }, e);
47741
+ else refuse3("unknown", {}, e);
46626
47742
  return deps.exit(1);
46627
47743
  }
46628
47744
  try {
46629
47745
  validateBind(daemon.bind, daemon.tls);
46630
47746
  } catch (e) {
46631
- refuse2("bind_refused", {}, e);
47747
+ refuse3("bind_refused", {}, e);
46632
47748
  return deps.exit(1);
46633
47749
  }
46634
47750
  if (!CANONICAL_UUID2.test(cfg.identityId)) {
46635
- refuse2("identity_id_invalid", { field: "identity_id" });
47751
+ refuse3("identity_id_invalid", { field: "identity_id" });
46636
47752
  return deps.exit(1);
46637
47753
  }
46638
47754
  const lockDir = join25(deps.home, ".securecoms", "locks");