@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/index.js CHANGED
@@ -14687,6 +14687,18 @@ async function sendWithSelfHeal(session, channelId, build, publish) {
14687
14687
  // ../connect-sdk-ts/dist/groupkey/stub.js
14688
14688
  import { sha256 as sha2563 } from "@noble/hashes/sha2";
14689
14689
 
14690
+ // ../connect-sdk-ts/dist/receive/inbox.js
14691
+ function parseOpenedAt(v) {
14692
+ if (typeof v !== "object" || v === null)
14693
+ return void 0;
14694
+ const o = v;
14695
+ if (typeof o.groupId !== "string" || o.groupId.length === 0)
14696
+ return void 0;
14697
+ if (typeof o.epoch !== "number" || !Number.isSafeInteger(o.epoch) || o.epoch < 0)
14698
+ return void 0;
14699
+ return { groupId: o.groupId, epoch: o.epoch };
14700
+ }
14701
+
14690
14702
  // ../connect-sdk-ts/dist/spans/names.js
14691
14703
  var SPAN = {
14692
14704
  MESSAGE_SENT: "agentvault.connect.message.sent",
@@ -17415,7 +17427,9 @@ function decodeCustodyEntry(bytes) {
17415
17427
  if (typeof plaintextHex !== "string" || typeof rest.queueId !== "string" || typeof rest.channelId !== "string" || typeof rest.custodiedAt !== "number") {
17416
17428
  throw new Error("malformed custody entry");
17417
17429
  }
17418
- return { ...rest, plaintext: hexToBytes22(plaintextHex) };
17430
+ const { openedAt, ...others } = rest;
17431
+ const position = parseOpenedAt(openedAt);
17432
+ return { ...others, ...position ? { openedAt: position } : {}, plaintext: hexToBytes22(plaintextHex) };
17419
17433
  }
17420
17434
  function custodyAad(identityId, queueId) {
17421
17435
  return new TextEncoder().encode(`securecoms-custody-v1\0${identityId}\0${queueId}`);
@@ -17463,7 +17477,17 @@ var CODES = [
17463
17477
  // missed and before any write, so nothing landed. Definite: the committer's
17464
17478
  // work cannot name an agent in a humans-only enclave (admission refuses
17465
17479
  // one), so dropping the commit starts no resend loop.
17466
- { status: 403, code: "humans_only_enclave" }
17480
+ { status: 403, code: "humans_only_enclave" },
17481
+ // ADR-0024 step 2a PR-E (carry-A ruling §3 A): a V Add or Welcome whose
17482
+ // leaf-key claim map is missing, does not cover exactly the Welcome
17483
+ // targets, or names a key that is not the target's current vendable
17484
+ // KeyPackage's. Refused before any write; definite, so the committer drops
17485
+ // the commit, refetches the KeyPackages and rebuilds instead of resending
17486
+ // the same stale bytes forever.
17487
+ { status: 409, code: "voice_stale_key_package" },
17488
+ // PR-E: a Welcome target already holds a bound leaf in this V group (the
17489
+ // DS-level duplicate-leaf guard). Nothing written; a resend cannot land.
17490
+ { status: 409, code: "voice_leaf_already_bound" }
17467
17491
  ];
17468
17492
  var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
17469
17493
  var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
@@ -18510,6 +18534,31 @@ function ownLeafIndexOf(mgr) {
18510
18534
  function tagOf(mgr) {
18511
18535
  return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
18512
18536
  }
18537
+ function leafKeyClaims(targets, pkgs) {
18538
+ if (targets.length !== pkgs.length)
18539
+ throw new Error("leafKeyClaims: targets and KeyPackages differ in length");
18540
+ const out = {};
18541
+ targets.forEach((d, i) => {
18542
+ out[d] = bytesToHex22(pkgs[i].leafNode.signaturePublicKey);
18543
+ });
18544
+ return out;
18545
+ }
18546
+ function leafIndicesForSignatureKey(mgr, keyHex) {
18547
+ const tree = mgr.state?.ratchetTree;
18548
+ if (!tree)
18549
+ return [];
18550
+ const want = keyHex.toLowerCase();
18551
+ const out = [];
18552
+ const numLeaves = Math.floor((tree.length + 1) / 2);
18553
+ for (let i = 0; i < numLeaves; i++) {
18554
+ const node = tree[i * 2];
18555
+ if (!node || node.nodeType !== "leaf")
18556
+ continue;
18557
+ if (bytesToHex22(getSignaturePublicKeyFromLeafIndex2(tree, toLeafIndex2(i))) === want)
18558
+ out.push(i);
18559
+ }
18560
+ return out;
18561
+ }
18513
18562
  function tagOfState(state) {
18514
18563
  const m = new MLSGroupManager(state.ciphersuite);
18515
18564
  m.importState(state);
@@ -18624,6 +18673,12 @@ var SelfAddRefusedError = class extends Error {
18624
18673
  this.name = "SelfAddRefusedError";
18625
18674
  }
18626
18675
  };
18676
+ var VoiceMirrorAddRefusedError = class extends Error {
18677
+ constructor() {
18678
+ super("addMemberToLiveGroup: refusing a voice-channel Add of a device already in the roster mirror (it would mint a duplicate leaf)");
18679
+ this.name = "VoiceMirrorAddRefusedError";
18680
+ }
18681
+ };
18627
18682
  var VaultCredentialMismatchError = class extends Error {
18628
18683
  expected;
18629
18684
  got;
@@ -20113,10 +20168,28 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20113
20168
  * The row is deliberately NOT acked here — the subsequent ensureSession(join) /
20114
20169
  * joinAndReconcile consumes the same Welcome and acks it after joining.
20115
20170
  */
20116
- async peekWelcomeChannel(deviceId) {
20171
+ async peekWelcomeChannel(deviceId, opts = {}) {
20117
20172
  const rows = await this.o.backend.pullDelivery(deviceId);
20118
- const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
20119
- return w?.connect_channel_id ?? null;
20173
+ const asked = /* @__PURE__ */ new Set();
20174
+ for (const r of rows) {
20175
+ if (r.message_type !== "welcome" || r.connect_channel_id == null)
20176
+ continue;
20177
+ const ch = r.connect_channel_id;
20178
+ if (!opts.accept)
20179
+ return ch;
20180
+ if (asked.has(ch))
20181
+ continue;
20182
+ asked.add(ch);
20183
+ let ok = false;
20184
+ try {
20185
+ ok = await opts.accept(ch) === true;
20186
+ } catch {
20187
+ ok = false;
20188
+ }
20189
+ if (ok)
20190
+ return ch;
20191
+ }
20192
+ return null;
20120
20193
  }
20121
20194
  /**
20122
20195
  * Publish this device's KeyPackage if it holds none; otherwise a no-op that
@@ -20222,10 +20295,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20222
20295
  }
20223
20296
  const attempted = [];
20224
20297
  const failed = [];
20298
+ const discarded = [];
20225
20299
  for (const channelId of channelIds) {
20226
20300
  attempted.push(channelId);
20227
20301
  try {
20228
- await this.ensureSession(channelId, { enclaveId: "welcome-drain", bootstrap: false });
20302
+ await this.ensureSession(channelId, {
20303
+ enclaveId: "welcome-drain",
20304
+ bootstrap: false,
20305
+ onUnopenableWelcome: (groupId) => {
20306
+ discarded.push({ channelId, groupId });
20307
+ }
20308
+ });
20229
20309
  } catch (error) {
20230
20310
  failed.push({ channelId, error });
20231
20311
  }
@@ -20235,7 +20315,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20235
20315
  receipt = new DrainReceipt(DRAIN_RECEIPT_TOKEN);
20236
20316
  this.drainReceipts.set(receipt, this.nowMs());
20237
20317
  }
20238
- return { attempted, failed, skipped, unknown, pageFull, receipt };
20318
+ return { attempted, failed, skipped, unknown, pageFull, receipt, discarded };
20239
20319
  }
20240
20320
  /**
20241
20321
  * Resolve the channel's pending commit record, under the channel lock, and
@@ -20708,7 +20788,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20708
20788
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
20709
20789
  return MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
20710
20790
  });
20711
- const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
20791
+ const creatorLeafSignatureKey = bundle ? bytesToHex22(bundle.publicPackage.leafNode.signaturePublicKey) : void 0;
20792
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId], creatorLeafSignatureKey !== void 0 ? { creatorLeafSignatureKey } : void 0);
20712
20793
  if (init.created === false) {
20713
20794
  if (!this.isAdoptableOrphan(init, others)) {
20714
20795
  if (this.isOrphanShape(init, others))
@@ -20728,7 +20809,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20728
20809
  commit_epoch: Number(mgr.epoch),
20729
20810
  expected_epoch: init.current_epoch,
20730
20811
  member_device_ids: [this.o.deviceId, ...others],
20731
- welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : []
20812
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : [],
20813
+ // Task 24b (ruling §3 A): exact cover of the Welcome targets ({} when none).
20814
+ added_leaf_signature_keys: leafKeyClaims(welcome ? others : [], welcome ? pkgs : [])
20732
20815
  }, mgr, null);
20733
20816
  const res = await this.commitWithPendingRecord(channelId, rec, mgr);
20734
20817
  if (!res.landed) {
@@ -20742,15 +20825,32 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20742
20825
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
20743
20826
  if (!w)
20744
20827
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
20828
+ const aborted = () => opts.shouldAbort?.() === true;
20829
+ if (aborted())
20830
+ throw new Error("ensureSession(join): aborted by the caller");
20745
20831
  let mgr;
20746
20832
  try {
20747
20833
  mgr = await this.openWelcome(hexToBytes22(w.payload), [bundle, joinSlot]);
20748
20834
  } catch (err) {
20835
+ if (aborted())
20836
+ throw new Error("ensureSession(join): aborted by the caller");
20749
20837
  this.recordDecryptError(w, err);
20750
20838
  writeServiceLog("warn", "session.unopenable_welcome_discarded", {}, err);
20751
20839
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
20840
+ try {
20841
+ opts.onUnopenableWelcome?.(w.group_id);
20842
+ } catch (e) {
20843
+ writeServiceLog("warn", "session.unopenable_welcome_report_failed", {}, e);
20844
+ }
20845
+ try {
20846
+ this.o.onUnopenableWelcomeDiscarded?.({ channelId, groupId: w.group_id });
20847
+ } catch (e) {
20848
+ writeServiceLog("warn", "session.unopenable_welcome_report_failed", {}, e);
20849
+ }
20752
20850
  return;
20753
20851
  }
20852
+ if (aborted())
20853
+ throw new Error("ensureSession(join): aborted by the caller");
20754
20854
  this.managers.set(channelId, mgr);
20755
20855
  await this.saveMain(channelId, mgr);
20756
20856
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
@@ -20961,6 +21061,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20961
21061
  await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "addMemberToLiveGroup");
20962
21062
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
20963
21063
  groupId = init.group_id;
21064
+ if (this.o.isVoiceChannel?.(channelId) === true) {
21065
+ const mirror = init.member_device_ids;
21066
+ if (!Array.isArray(mirror) || mirror.includes(newDeviceId)) {
21067
+ writeServiceLog("warn", "admission.voice_device_already_in_mirror");
21068
+ throw new VoiceMirrorAddRefusedError();
21069
+ }
21070
+ }
20964
21071
  members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
20965
21072
  }
20966
21073
  if (new TextDecoder().decode(mgr.groupId) !== groupId) {
@@ -20979,7 +21086,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20979
21086
  expected_epoch: priorEpoch,
20980
21087
  member_device_ids: members,
20981
21088
  op: "add",
20982
- welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : []
21089
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : [],
21090
+ // Task 24b (ruling §3 A): the exact Welcome cover's leaf keys.
21091
+ added_leaf_signature_keys: leafKeyClaims(welcome ? [newDeviceId] : [], welcome ? [pkg] : [])
20983
21092
  }, mgr, prior), mgr);
20984
21093
  if (!res.landed) {
20985
21094
  const e = res.error;
@@ -21300,6 +21409,181 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21300
21409
  return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
21301
21410
  }
21302
21411
  }
21412
+ /**
21413
+ * ADR-0024 step 2a, round 2 F4 (Task 24): the committer sweep's DEVICE-level
21414
+ * Remove of one leaf the server's committer-work lists in `remove_devices`
21415
+ * (on the hidden voice channel V: a stale device of an agent or relay
21416
+ * identity that is still on V's roster).
21417
+ *
21418
+ * ⛔ WHY NOT {@link removeDeviceFromLiveGroup}. That path resolves a FOREIGN
21419
+ * identity's leaf by add-order position, which RFC 9420's leftmost-blank
21420
+ * refill permutes. Here the identity keeps a CURRENT device, so a wrong guess
21421
+ * evicts the current device's leaf while the distribute's `removed_device_id`
21422
+ * makes the server drop the STALE device from its mirror. The current device
21423
+ * is then in the roster with no leaf, outside the adds delta forever: an
21424
+ * unrecoverable wedge. So this resolves the leaf by EXACT evidence only:
21425
+ *
21426
+ * - the target device's PUBLISHED KeyPackage, whose LeafNode signature key
21427
+ * is matched against THIS committer's live tree (the device-level leaf
21428
+ * predicate, {@link leafIndicesForKeyPackage}). `generateKeyPackage`
21429
+ * mints a fresh signature keypair per call, so a match names exactly the
21430
+ * leaf that KeyPackage was Added as;
21431
+ * - exactly ONE matching leaf, or nothing is committed;
21432
+ * - that leaf's credential identity is read from the tree, never from the
21433
+ * server, and is what the distribute names as `removed_identity_id`.
21434
+ *
21435
+ * Outcomes. `"removed"`: one leaf evicted and the distribute landed.
21436
+ * `"unresolved"`: no KeyPackage is published for the device, or it matches no
21437
+ * leaf (the device rotated it after its Add, or the leaf is already gone).
21438
+ * Nothing is committed: the leaf, if any, stays, and is reported, never
21439
+ * guessed. The server's next delta lists it again (the caller's throttle
21440
+ * bounds the retries).
21441
+ *
21442
+ * Refusals (thrown before any commit is built, so nothing is distributed):
21443
+ * the committer's own device (`own_device`), any leaf of the committer's own
21444
+ * identity, the committer's own leaf, or an unknown own leaf
21445
+ * (`own_leaf` / `own_leaf_unknown`, {@link OwnLeafRemoveRefusedError}); two
21446
+ * or more matching leaves; a held group that is not the channel's active
21447
+ * group (#757 binding, checked BEFORE init).
21448
+ */
21449
+ async removeStaleDeviceLeafFromLiveGroup(channelId, deviceId, bound) {
21450
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
21451
+ await this.requirePendingResolvedUnlocked(channelId);
21452
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, 0, bound);
21453
+ }));
21454
+ }
21455
+ async removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt, bound) {
21456
+ if (await this.custodyHeld(channelId))
21457
+ throw new CustodyPersistError(channelId);
21458
+ if (deviceId === this.o.deviceId)
21459
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
21460
+ const mgr = await this.manager(channelId);
21461
+ const resolved = bound ? this.resolveBoundDeviceLeaf(mgr, bound) : await this.resolveServedKeyPackageLeaf(mgr, deviceId);
21462
+ if (resolved === "unresolved")
21463
+ return "unresolved";
21464
+ const { leafIndex, leafIdentity } = resolved;
21465
+ await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "removeStaleDeviceLeafFromLiveGroup");
21466
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
21467
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
21468
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: group binding mismatch");
21469
+ }
21470
+ const priorEpoch = Number(mgr.epoch);
21471
+ const prior = tagOf(mgr);
21472
+ const { commit } = await mgr.removeMembers([leafIndex]);
21473
+ if (Number(mgr.epoch) !== priorEpoch + 1) {
21474
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: unexpected epoch drift before distribute");
21475
+ }
21476
+ const members = init.member_device_ids ?? [this.o.deviceId];
21477
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
21478
+ device_id: this.o.deviceId,
21479
+ commit: bytesToHex22(commit),
21480
+ commit_epoch: Number(mgr.epoch),
21481
+ expected_epoch: priorEpoch,
21482
+ member_device_ids: members,
21483
+ op: "remove",
21484
+ removed_identity_id: leafIdentity,
21485
+ removed_device_id: deviceId,
21486
+ welcomes: []
21487
+ }, mgr, prior), mgr);
21488
+ if (!res.landed) {
21489
+ const e = res.error;
21490
+ if (isSupersededRefusal(e))
21491
+ throw new GroupSupersededError(channelId, init.group_id);
21492
+ if (_attempt >= 2 || !isEpochConflict(e))
21493
+ throw e;
21494
+ try {
21495
+ await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
21496
+ } catch (e2) {
21497
+ if (e2 instanceof CustodyPersistError)
21498
+ throw e2;
21499
+ }
21500
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt + 1, bound);
21501
+ }
21502
+ return "removed";
21503
+ }
21504
+ /** F4 (Task 24): the target's PUBLISHED KeyPackage, matched by key against
21505
+ * the committer's tree. Unchanged from Task 24 and its fix round 1. */
21506
+ async resolveServedKeyPackageLeaf(mgr, deviceId) {
21507
+ const kps = await this.o.backend.fetchKeyPackages([deviceId]);
21508
+ const hex = kps[deviceId];
21509
+ if (!hex) {
21510
+ writeServiceLog("info", "committer.remove_device_unresolved", { no_key_package: true });
21511
+ return "unresolved";
21512
+ }
21513
+ const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
21514
+ const leaves = leafIndicesForKeyPackage(mgr, pkg);
21515
+ if (leaves.length === 0) {
21516
+ writeServiceLog("info", "committer.remove_device_unresolved", { no_matching_leaf: true });
21517
+ return "unresolved";
21518
+ }
21519
+ if (leaves.length > 1) {
21520
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous key match (more than one leaf)");
21521
+ }
21522
+ const leafIndex = leaves[0];
21523
+ const tree = mgr.state?.ratchetTree;
21524
+ if (!tree)
21525
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
21526
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
21527
+ if (cred.credentialType !== "basic") {
21528
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: leaf carries no basic credential");
21529
+ }
21530
+ const leafIdentity = new TextDecoder().decode(cred.identity);
21531
+ const ownIdentity = new TextDecoder().decode(this.o.identity);
21532
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
21533
+ if (!leafIdentity || leafIdentity === ownIdentity) {
21534
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf");
21535
+ }
21536
+ if (mgr.leafIndicesForIdentity(leafIdentity).length <= 1) {
21537
+ writeServiceLog("warn", "committer.remove_device_refused_last_leaf", {});
21538
+ return "unresolved";
21539
+ }
21540
+ return { leafIndex, leafIdentity };
21541
+ }
21542
+ /**
21543
+ * ADR-0024 step 2a Task 24b (architect ruling "carry A", §3 B and §4 R5):
21544
+ * resolve the leaf by the SERVER-BOUND leaf signature key (committer work's
21545
+ * `remove_device_leaves`). The server recorded the key when the leaf was
21546
+ * born, from the KeyPackage it vended, so it survives the target's
21547
+ * KeyPackage rotation, revocation and deletion, which the served-KeyPackage
21548
+ * path cannot.
21549
+ *
21550
+ * - Leaves are matched by the bound key ONLY: exactly one match, else
21551
+ * `unresolved` (zero) or a throw (two or more). ⛔ NEVER a fall back to
21552
+ * the served KeyPackage: two evidence sources that disagree fail closed.
21553
+ * - The matched leaf's basic-credential identity must equal the server's
21554
+ * `identityId` for the device (R4), else a throw.
21555
+ * - The committer's own leaf index (or an unknown one) is refused
21556
+ * ({@link refuseOwnLeafRemove}); the own device was refused by the caller.
21557
+ * - R5: a leaf of the committer's OWN identity is admitted (a sibling
21558
+ * device: a revoked laptop). The brick class the own-identity refusal
21559
+ * guards came from positional or identity-level resolution, which this
21560
+ * exact-key path cannot reach, and the own leaf index is still refused.
21561
+ * - No last-leaf refusal: the binding is exact evidence, and an identity's
21562
+ * last leaf is exactly the revoked single-device case (and carry A's
21563
+ * Remove before its re-Add).
21564
+ * Synchronous and before any commit is built: a refusal distributes nothing.
21565
+ */
21566
+ resolveBoundDeviceLeaf(mgr, bound) {
21567
+ const leaves = leafIndicesForSignatureKey(mgr, bound.leafSignatureKeyHex);
21568
+ if (leaves.length === 0) {
21569
+ writeServiceLog("info", "committer.remove_device_unresolved", { no_bound_leaf: true });
21570
+ return "unresolved";
21571
+ }
21572
+ if (leaves.length > 1) {
21573
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous bound-key match (more than one leaf)");
21574
+ }
21575
+ const leafIndex = leaves[0];
21576
+ const tree = mgr.state?.ratchetTree;
21577
+ if (!tree)
21578
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
21579
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
21580
+ const leafIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
21581
+ if (!leafIdentity || leafIdentity !== bound.identityId) {
21582
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: the bound leaf's credential identity is not the device's identity");
21583
+ }
21584
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
21585
+ return { leafIndex, leafIdentity };
21586
+ }
21303
21587
  /**
21304
21588
  * Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
21305
21589
  * targeted by its owner-observed leaf `signaturePublicKey` against the live
@@ -22068,7 +22352,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22068
22352
  * read #750 made in here is gone (ruling §8.2).
22069
22353
  */
22070
22354
  async trySupersede(channelId, row, callerHeldGroupId, owner, mode, pullRows) {
22071
- const refuse = (msg) => {
22355
+ const refuse2 = (msg) => {
22072
22356
  this.recordDecryptError(row, new Error(msg));
22073
22357
  const now = Date.now();
22074
22358
  for (const [k, v] of this.supersedeRefusals) {
@@ -22101,34 +22385,34 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22101
22385
  att = await this.o.backend.fetchResetAttestation(row.group_id);
22102
22386
  }
22103
22387
  if (!att) {
22104
- return refuse(`supersede refused: no attestation for group ${row.group_id}`);
22388
+ return refuse2(`supersede refused: no attestation for group ${row.group_id}`);
22105
22389
  }
22106
22390
  if (att.signerIdentityId !== owner.identityId) {
22107
- return refuse("supersede refused: signer is not the pinned owner");
22391
+ return refuse2("supersede refused: signer is not the pinned owner");
22108
22392
  }
22109
22393
  let content;
22110
22394
  try {
22111
22395
  content = JSON.parse(att.signedContent);
22112
22396
  } catch {
22113
- return refuse("supersede refused: unparseable signed_content");
22397
+ return refuse2("supersede refused: unparseable signed_content");
22114
22398
  }
22115
22399
  if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
22116
- return refuse("supersede refused: attestation channel/group mismatch");
22400
+ return refuse2("supersede refused: attestation channel/group mismatch");
22117
22401
  }
22118
22402
  if (content.purpose !== RESET_SUPERSEDE_PURPOSE) {
22119
- return refuse("supersede refused: attestation purpose is not connect-reset-supersede");
22403
+ return refuse2("supersede refused: attestation purpose is not connect-reset-supersede");
22120
22404
  }
22121
22405
  const targetCreatedMs = signedCreatedAtMs(content);
22122
22406
  if (targetCreatedMs === null) {
22123
- return refuse("supersede refused: unparseable attestation created_at");
22407
+ return refuse2("supersede refused: unparseable attestation created_at");
22124
22408
  }
22125
22409
  const targetPrev = signedPrevLink(content, row.group_id);
22126
22410
  if (targetPrev === null) {
22127
- return refuse("supersede refused: unparseable attestation prev_group_id");
22411
+ return refuse2("supersede refused: unparseable attestation prev_group_id");
22128
22412
  }
22129
22413
  const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
22130
22414
  if (!ok) {
22131
- return refuse("supersede refused: signature does not verify against pinned owner key");
22415
+ return refuse2("supersede refused: signature does not verify against pinned owner key");
22132
22416
  }
22133
22417
  const targetLink = targetPrev.chained ? { createdAtMs: targetCreatedMs, chained: true, prevGroupId: targetPrev.prev } : { createdAtMs: targetCreatedMs, chained: false, prevGroupId: null };
22134
22418
  if (callerHeldGroupId !== null && callerHeldGroupId !== row.group_id) {
@@ -22138,7 +22422,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22138
22422
  if (v.kind === "refuse") {
22139
22423
  if (v.notNewer)
22140
22424
  this.supersedeNotNewer.add(`${channelId}:${row.group_id}`);
22141
- return refuse(v.reason);
22425
+ return refuse2(v.reason);
22142
22426
  }
22143
22427
  }
22144
22428
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
@@ -22146,7 +22430,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22146
22430
  if (!bundle && !supersedeSlot) {
22147
22431
  if (mode === "adopt")
22148
22432
  return { kind: "no_bundle" };
22149
- return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
22433
+ return refuse2("supersede refused: no pending KeyPackage bundle to open the new Welcome");
22150
22434
  }
22151
22435
  return await this.locks.run(channelId, async () => {
22152
22436
  let inLockHeld;
@@ -22193,7 +22477,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22193
22477
  return { kind: "unopenable", requested };
22194
22478
  }
22195
22479
  if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
22196
- return refuse("supersede refused: post-join group id != attested new_group_id");
22480
+ return refuse2("supersede refused: post-join group id != attested new_group_id");
22197
22481
  }
22198
22482
  let dead = null;
22199
22483
  try {
@@ -23120,6 +23404,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23120
23404
  if (supersededChannelIds.length) {
23121
23405
  Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
23122
23406
  }
23407
+ if (w2Kept.size) {
23408
+ Object.defineProperty(appEntries, "pendingWelcomeChannelIds", { value: [...w2Kept], enumerable: false });
23409
+ }
23123
23410
  return appEntries;
23124
23411
  }
23125
23412
  /**
@@ -23318,15 +23605,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23318
23605
  done();
23319
23606
  };
23320
23607
  };
23608
+ const fromManager = async (mgr) => {
23609
+ const openedAt = { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
23610
+ const key = await mgr.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32);
23611
+ return { key, openedAt };
23612
+ };
23321
23613
  const derive = keyFrom ? () => {
23322
23614
  takeInFlight();
23323
- return keyFrom.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32);
23324
- } : () => {
23615
+ return fromManager(keyFrom);
23616
+ } : async () => {
23325
23617
  takeInFlight();
23326
- return this.exporterKeyUnfenced(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch);
23618
+ return fromManager(await this.manager(envelope.channel_id));
23327
23619
  };
23328
23620
  try {
23329
- const key = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
23621
+ const { key, openedAt } = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
23330
23622
  const aad = await buildAssociatedData({
23331
23623
  message_id: envelope.message_id,
23332
23624
  channel_id: envelope.channel_id,
@@ -23350,7 +23642,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23350
23642
  voiceMode: row.voice_mode ?? void 0,
23351
23643
  voiceDefaultResponder: row.voice_default_responder ?? void 0,
23352
23644
  ...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
23353
- payloadHex: row.payload
23645
+ payloadHex: row.payload,
23646
+ openedAt
23354
23647
  };
23355
23648
  const custodiedAt = this.nowMs();
23356
23649
  this.custody.set(row.queue_id, entry);
@@ -24905,9 +25198,9 @@ var RestMlsBackend = class {
24905
25198
  current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
24906
25199
  };
24907
25200
  }
24908
- async initConnectChannelGroup(channelId, memberDeviceIds) {
25201
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
24909
25202
  const path = `/api/v1/mls/connect-channels/${channelId}/init`;
24910
- const body = { member_device_ids: memberDeviceIds };
25203
+ const body = opts?.creatorLeafSignatureKey !== void 0 ? { member_device_ids: memberDeviceIds, creator_leaf_signature_key: opts.creatorLeafSignatureKey } : { member_device_ids: memberDeviceIds };
24911
25204
  let j;
24912
25205
  for (let attempt = 0; ; attempt++) {
24913
25206
  try {
@@ -25149,6 +25442,22 @@ var RestMlsBackend = class {
25149
25442
  throw new Error(`unrequested-removals: reports is ${describeType(rows)}`);
25150
25443
  return rows;
25151
25444
  }
25445
+ async signalVoiceLeafUnusable(groupId, deviceId) {
25446
+ 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 }) });
25447
+ const text = await res.text().catch(() => "");
25448
+ if (res.ok)
25449
+ return { status: res.status };
25450
+ let parsed = null;
25451
+ if (text) {
25452
+ try {
25453
+ parsed = JSON.parse(text);
25454
+ } catch {
25455
+ parsed = null;
25456
+ }
25457
+ }
25458
+ const code = parseErrorCode(parsed);
25459
+ return code === void 0 ? { status: res.status } : { status: res.status, code };
25460
+ }
25152
25461
  async getCommitterWork(channelId) {
25153
25462
  const j = await this.get("mls_committer_work", `/api/v1/mls/connect-channels/${channelId}/committer-work`);
25154
25463
  const requireList = (field) => {
@@ -25158,7 +25467,29 @@ var RestMlsBackend = class {
25158
25467
  }
25159
25468
  return v.map(String);
25160
25469
  };
25161
- return { adds: requireList("adds"), removes: requireList("removes") };
25470
+ const work = {
25471
+ adds: requireList("adds"),
25472
+ removes: requireList("removes")
25473
+ };
25474
+ if (j?.remove_devices !== void 0) {
25475
+ work.removeDevices = requireList("remove_devices");
25476
+ }
25477
+ const rawLeaves = j?.remove_device_leaves;
25478
+ if (rawLeaves !== void 0) {
25479
+ if (!Array.isArray(rawLeaves))
25480
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves", describeType(rawLeaves));
25481
+ const seen = /* @__PURE__ */ new Set();
25482
+ work.removeDeviceLeaves = rawLeaves.map((e) => {
25483
+ const o = e !== null && typeof e === "object" && !Array.isArray(e) ? e : null;
25484
+ const key = typeof o?.leaf_signature_key === "string" ? o.leaf_signature_key.toLowerCase() : null;
25485
+ 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)) {
25486
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves[]", describeType(e));
25487
+ }
25488
+ seen.add(o.device_id);
25489
+ return { deviceId: o.device_id, identityId: o.identity_id, leafSignatureKeyHex: key };
25490
+ });
25491
+ }
25492
+ return work;
25162
25493
  }
25163
25494
  async acquireCommitLease(channelId) {
25164
25495
  try {
@@ -25208,6 +25539,48 @@ var RestMlsBackend = class {
25208
25539
 
25209
25540
  // ../connect-sdk-ts/dist/mls/fake-backend.js
25210
25541
  var DELIVERY_PULL_LIMIT = 100;
25542
+ function keyPackageSignatureKeyHex(kpHex) {
25543
+ const b = hexToBytes22(kpHex);
25544
+ let at = 0;
25545
+ const u16 = () => {
25546
+ if (at + 2 > b.length)
25547
+ throw new Error("t");
25548
+ const v = b[at] << 8 | b[at + 1];
25549
+ at += 2;
25550
+ return v;
25551
+ };
25552
+ const opaque = () => {
25553
+ if (at >= b.length)
25554
+ throw new Error("t");
25555
+ const prefix = b[at] >> 6;
25556
+ if (prefix === 3)
25557
+ throw new Error("t");
25558
+ const size = 1 << prefix;
25559
+ if (at + size > b.length)
25560
+ throw new Error("t");
25561
+ let n = b[at] & 63;
25562
+ for (let i = 1; i < size; i++)
25563
+ n = n << 8 | b[at + i];
25564
+ at += size;
25565
+ if (at + n > b.length)
25566
+ throw new Error("t");
25567
+ const out = b.slice(at, at + n);
25568
+ at += n;
25569
+ return out;
25570
+ };
25571
+ try {
25572
+ if (u16() !== 1 || u16() !== 5)
25573
+ return null;
25574
+ u16();
25575
+ u16();
25576
+ opaque();
25577
+ opaque();
25578
+ const sig = opaque();
25579
+ return sig.length > 0 ? bytesToHex22(sig) : null;
25580
+ } catch {
25581
+ return null;
25582
+ }
25583
+ }
25211
25584
  var FakeConnectBackend = class _FakeConnectBackend {
25212
25585
  // Key-package store: deviceId → hex-encoded KeyPackage
25213
25586
  kps = /* @__PURE__ */ new Map();
@@ -25292,6 +25665,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25292
25665
  * server's purge_device_queue. */
25293
25666
  linkIdentityDevice(identityId, deviceId) {
25294
25667
  this.deviceByIdentity.set(identityId, deviceId);
25668
+ this.identityByDevice.set(deviceId, identityId);
25295
25669
  }
25296
25670
  async publishKeyPackage(deviceId, keyPackageHex) {
25297
25671
  this.kps.set(deviceId, keyPackageHex);
@@ -25341,8 +25715,38 @@ var FakeConnectBackend = class _FakeConnectBackend {
25341
25715
  current_epoch: this.epochByChannel.get(channelId) ?? 0
25342
25716
  };
25343
25717
  }
25344
- async initConnectChannelGroup(channelId, memberDeviceIds) {
25718
+ /** ADR-0024 step 2a (P-7): channel ids the test registers as a hidden
25719
+ * voice channel V. Init on an EXISTING V returns it unchanged and merges
25720
+ * nothing, as the real backend does. */
25721
+ voiceChannelIds = /* @__PURE__ */ new Set();
25722
+ /** Task 24b (architect ruling "carry A", §3 A-C), modelled for a channel in
25723
+ * {@link voiceChannelIds} only, as the PR-E server does for V:
25724
+ * `channel -> device -> { key (hex), unusableSignaledAt }`, written ONLY
25725
+ * when a V leaf is born (the creator's init, an Add's Welcome) from the
25726
+ * verified claim, and deleted when a Remove of that device (or its
25727
+ * identity) lands. */
25728
+ voiceLeafBindings = /* @__PURE__ */ new Map();
25729
+ /** The device -> identity the server knows (the devices table). Written by
25730
+ * {@link linkIdentityDevice}; read for `remove_device_leaves`. */
25731
+ identityByDevice = /* @__PURE__ */ new Map();
25732
+ static voiceRefusal(path, code) {
25733
+ return new MlsBackendHttpError(path, 409, code);
25734
+ }
25735
+ /** The current vendable KeyPackage key of `deviceId`, or null. */
25736
+ currentLeafKey(deviceId) {
25737
+ const hex = this.kps.get(deviceId);
25738
+ return hex ? keyPackageSignatureKeyHex(hex) : null;
25739
+ }
25740
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
25345
25741
  const alreadyExists = this.groupIdByChannel.has(channelId);
25742
+ if (!alreadyExists && this.voiceChannelIds.has(channelId)) {
25743
+ const creator = memberDeviceIds[0];
25744
+ const current = creator ? this.currentLeafKey(creator) : null;
25745
+ if (!opts?.creatorLeafSignatureKey || current === null || opts.creatorLeafSignatureKey.toLowerCase() !== current) {
25746
+ throw new Error(`/api/v1/mls/connect-channels/${channelId}/init failed: 409 {"detail":"voice_stale_key_package"}`);
25747
+ }
25748
+ this.voiceLeafBindings.set(channelId, /* @__PURE__ */ new Map([[creator, { key: current, unusableSignaledAt: null }]]));
25749
+ }
25346
25750
  let groupId = this.groupIdByChannel.get(channelId);
25347
25751
  if (!groupId) {
25348
25752
  groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
@@ -25350,7 +25754,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25350
25754
  this.channelByGroup.set(groupId, channelId);
25351
25755
  this.epochByChannel.set(channelId, 0);
25352
25756
  }
25353
- const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
25757
+ const members = alreadyExists && this.voiceChannelIds.has(channelId) ? [...this.membersByChannel.get(channelId) ?? []] : [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
25354
25758
  this.membersByChannel.set(channelId, members);
25355
25759
  return {
25356
25760
  group_id: groupId,
@@ -25487,6 +25891,27 @@ var FakeConnectBackend = class _FakeConnectBackend {
25487
25891
  throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
25488
25892
  }
25489
25893
  }
25894
+ const vBindings = this.voiceChannelIds.has(channelId) ? this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map() : null;
25895
+ let bindNow = [];
25896
+ if (vBindings && req.commit && req.op !== "remove") {
25897
+ const targets = (req.welcomes ?? []).map((w) => w.target_device_id);
25898
+ const claims = req.added_leaf_signature_keys;
25899
+ const path = _FakeConnectBackend.distributePath(groupId);
25900
+ if (!claims)
25901
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
25902
+ const claimed = Object.keys(claims);
25903
+ if (claimed.length !== targets.length || targets.some((t) => !(t in claims))) {
25904
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
25905
+ }
25906
+ for (const t of targets) {
25907
+ const current = this.currentLeafKey(t);
25908
+ if (current === null || claims[t].toLowerCase() !== current)
25909
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
25910
+ if (vBindings.has(t))
25911
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_leaf_already_bound");
25912
+ }
25913
+ bindNow = targets.map((t) => [t, claims[t].toLowerCase()]);
25914
+ }
25490
25915
  if (req.commit) {
25491
25916
  const log = this.commitLog.get(groupId) ?? [];
25492
25917
  log.push({
@@ -25509,6 +25934,12 @@ var FakeConnectBackend = class _FakeConnectBackend {
25509
25934
  this.membersByChannel.set(channelId, remaining);
25510
25935
  if (evictedDevice)
25511
25936
  this.queues.delete(evictedDevice);
25937
+ if (vBindings) {
25938
+ for (const d of [...vBindings.keys()]) {
25939
+ if (d === evictedDevice || !req.removed_device_id && this.identityByDevice.get(d) === req.removed_identity_id)
25940
+ vBindings.delete(d);
25941
+ }
25942
+ }
25512
25943
  } else {
25513
25944
  const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
25514
25945
  this.membersByChannel.set(channelId, members);
@@ -25532,6 +25963,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
25532
25963
  });
25533
25964
  }
25534
25965
  }
25966
+ if (vBindings && bindNow.length > 0) {
25967
+ for (const [d, k] of bindNow)
25968
+ vBindings.set(d, { key: k, unusableSignaledAt: null });
25969
+ this.voiceLeafBindings.set(channelId, vBindings);
25970
+ }
25535
25971
  for (const w of req.welcomes ?? []) {
25536
25972
  this.q(w.target_device_id).push({
25537
25973
  queue_id: "q-" + this._seq++,
@@ -25592,6 +26028,51 @@ var FakeConnectBackend = class _FakeConnectBackend {
25592
26028
  async getCommitterWork(_channelId) {
25593
26029
  return { adds: [], removes: [] };
25594
26030
  }
26031
+ /** Task 24b (ruling §3 C): the carry-A signal, every call recorded. Gates
26032
+ * modelled: the group is a V (else 404), a binding exists (else 409), the
26033
+ * device's current vendable key differs from the bound one (409
26034
+ * `voice_leaf_key_current` when equal, `voice_no_current_key_package`
26035
+ * when it has none). An already-signalled row is an idempotent 202. The
26036
+ * bind and roster gates are the server's (backend tests). */
26037
+ signalVoiceLeafUnusableCalls = [];
26038
+ async signalVoiceLeafUnusable(groupId, deviceId) {
26039
+ this.signalVoiceLeafUnusableCalls.push({ groupId, deviceId });
26040
+ const channelId = this.channelByGroup.get(groupId);
26041
+ if (!channelId || !this.voiceChannelIds.has(channelId))
26042
+ return { status: 404, code: "Not Found" };
26043
+ const row = this.voiceLeafBindings.get(channelId)?.get(deviceId);
26044
+ if (!row)
26045
+ return { status: 409, code: "voice_leaf_binding_missing" };
26046
+ const current = this.currentLeafKey(deviceId);
26047
+ if (current === null)
26048
+ return { status: 409, code: "voice_no_current_key_package" };
26049
+ if (current === row.key)
26050
+ return { status: 409, code: "voice_leaf_key_current" };
26051
+ if (row.unusableSignaledAt === null)
26052
+ row.unusableSignaledAt = this.now();
26053
+ return { status: 202 };
26054
+ }
26055
+ /** Task 24b test seam: V's committer work as the PR-E server derives it,
26056
+ * for a test's `roster` (the server's roster devices): `adds` = roster
26057
+ * devices not in the mirror; `remove_devices` / `remove_device_leaves` =
26058
+ * roster devices whose binding is signalled unusable (NOT in `adds`: they
26059
+ * are still in the mirror). The re-Add follows only from a later call,
26060
+ * after the Remove landed and the mirror dropped the device. */
26061
+ voiceCommitterWork(channelId, roster) {
26062
+ const mirror = this.membersByChannel.get(channelId) ?? [];
26063
+ const bindings = this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map();
26064
+ const signalled = roster.filter((d) => mirror.includes(d) && bindings.get(d)?.unusableSignaledAt != null);
26065
+ return {
26066
+ adds: roster.filter((d) => !mirror.includes(d)),
26067
+ removes: [],
26068
+ removeDevices: signalled,
26069
+ removeDeviceLeaves: signalled.map((d) => ({
26070
+ deviceId: d,
26071
+ identityId: this.identityByDevice.get(d) ?? "",
26072
+ leafSignatureKeyHex: bindings.get(d).key
26073
+ }))
26074
+ };
26075
+ }
25595
26076
  async acquireCommitLease(_channelId) {
25596
26077
  return "fake-lease-token";
25597
26078
  }
@@ -26084,6 +26565,7 @@ function openSchema(db) {
26084
26565
  ensureToolGrantTables(db);
26085
26566
  ensureToolGrantAppliedTable(db);
26086
26567
  ensureRoomSearchCapTable(db);
26568
+ ensureVoiceChannelParentTable(db);
26087
26569
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
26088
26570
  }
26089
26571
  function ensureMlsCustody(db) {
@@ -26269,6 +26751,16 @@ function ensureToolGrantAppliedTable(db) {
26269
26751
  );
26270
26752
  `);
26271
26753
  }
26754
+ function ensureVoiceChannelParentTable(db) {
26755
+ db.exec(`
26756
+ CREATE TABLE IF NOT EXISTS voice_channel_parent (
26757
+ identity_id TEXT NOT NULL,
26758
+ voice_channel_id TEXT NOT NULL,
26759
+ parent_channel_id TEXT NOT NULL,
26760
+ PRIMARY KEY (identity_id, voice_channel_id)
26761
+ );
26762
+ `);
26763
+ }
26272
26764
  function ensureRoomSearchCapTable(db) {
26273
26765
  db.exec(`
26274
26766
  CREATE TABLE IF NOT EXISTS room_search_cap (
@@ -26960,6 +27452,21 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
26960
27452
  VALUES (?,?,?,?)
26961
27453
  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());
26962
27454
  }
27455
+ /** ADR-0024 Task 20 (final review I-1): records V -> P once; a later, different parent is ignored (immutable link). */
27456
+ recordVoiceChannelParent(voiceChannelId, parentChannelId) {
27457
+ this.db.prepare(`INSERT INTO voice_channel_parent (identity_id, voice_channel_id, parent_channel_id) VALUES (?,?,?)
27458
+ ON CONFLICT(identity_id, voice_channel_id) DO NOTHING`).run(this.id, voiceChannelId, parentChannelId);
27459
+ }
27460
+ /** The recorded parent of a voice channel, if any. */
27461
+ voiceChannelParent(voiceChannelId) {
27462
+ const row = this.db.prepare("SELECT parent_channel_id FROM voice_channel_parent WHERE identity_id=? AND voice_channel_id=?").get(this.id, voiceChannelId);
27463
+ return row?.parent_channel_id;
27464
+ }
27465
+ /** Every recorded voice channel of a parent conversation. */
27466
+ voiceChannelsOf(parentChannelId) {
27467
+ const rows = this.db.prepare("SELECT voice_channel_id FROM voice_channel_parent WHERE identity_id=? AND parent_channel_id=?").all(this.id, parentChannelId);
27468
+ return rows.map((r) => r.voice_channel_id);
27469
+ }
26963
27470
  getChannelMeta(channelId) {
26964
27471
  const row = this.db.prepare("SELECT enclave_id, is_dm FROM channel_meta WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
26965
27472
  if (!row)
@@ -31783,7 +32290,7 @@ var EmbedderClient = class {
31783
32290
  resolve(ok);
31784
32291
  }
31785
32292
  }
31786
- const refuse = (reason) => {
32293
+ const refuse2 = (reason) => {
31787
32294
  s.destroy();
31788
32295
  if (!settled) {
31789
32296
  this.fail(reason);
@@ -31802,22 +32309,22 @@ var EmbedderClient = class {
31802
32309
  return;
31803
32310
  }
31804
32311
  const f2 = this.chan.open(line);
31805
- if (!f2) return refuse("embedder_unavailable");
32312
+ if (!f2) return refuse2("embedder_unavailable");
31806
32313
  this.onFrame(f2);
31807
32314
  return;
31808
32315
  }
31809
32316
  const f = JSON.parse(line);
31810
- if (!f || typeof f !== "object") return refuse("embedder_unavailable");
32317
+ if (!f || typeof f !== "object") return refuse2("embedder_unavailable");
31811
32318
  if (!ns) {
31812
- if (f.t !== "challenge" || f.protocol !== EMBEDDER_PROTOCOL || typeof f.ns !== "string" || !/^[0-9a-f]{32}$/.test(f.ns)) return refuse(f.t === "challenge" ? "embedder_mismatch" : "embedder_unavailable");
32319
+ 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");
31813
32320
  ns = f.ns;
31814
32321
  s.write(JSON.stringify({ t: "hello", nc, mac_c: macClient(token, ns, nc) }) + "\n");
31815
32322
  return;
31816
32323
  }
31817
- if (f.t !== "hello" || typeof f.mac_s !== "string" || !macEqual(f.mac_s, macServer(token, ns, nc))) return refuse("embedder_unavailable");
32324
+ if (f.t !== "hello" || typeof f.mac_s !== "string" || !macEqual(f.mac_s, macServer(token, ns, nc))) return refuse2("embedder_unavailable");
31818
32325
  const model = this.m.files.find((x) => x.name === "model.onnx");
31819
32326
  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;
31820
- if (!ok) return refuse("embedder_mismatch");
32327
+ if (!ok) return refuse2("embedder_mismatch");
31821
32328
  if (this.closed) {
31822
32329
  s.destroy();
31823
32330
  return done(true);
@@ -31835,9 +32342,9 @@ var EmbedderClient = class {
31835
32342
  this.settleReady(true);
31836
32343
  done(true);
31837
32344
  } catch {
31838
- refuse("embedder_unavailable");
32345
+ refuse2("embedder_unavailable");
31839
32346
  }
31840
- }, () => refuse("embedder_unavailable")));
32347
+ }, () => refuse2("embedder_unavailable")));
31841
32348
  });
31842
32349
  }
31843
32350
  onFrame(f) {
@@ -35175,6 +35682,213 @@ function roomIsCrossOrg(rows) {
35175
35682
  return unknown ? null : false;
35176
35683
  }
35177
35684
 
35685
+ // src/voice-channel-binding.ts
35686
+ var BOUND = Object.freeze({ bound: true });
35687
+ var refuse = (reason) => ({ bound: false, reason });
35688
+ function evaluateVoiceBinding(v, p, isVRelay) {
35689
+ if (v.ownLeafIndex === null || v.leaves.length === 0) return refuse("v_not_held");
35690
+ if (p.ownLeafIndex === null || p.leaves.length === 0) return refuse("p_not_held");
35691
+ if (v.leaves.some((l) => !l.basic || l.identity === null)) return refuse("non_basic_leaf");
35692
+ const pIds = /* @__PURE__ */ new Set();
35693
+ for (const l of p.leaves) if (l.basic && l.identity !== null) pIds.add(l.identity);
35694
+ const nonParent = /* @__PURE__ */ new Set();
35695
+ for (const l of v.leaves) if (!pIds.has(l.identity)) nonParent.add(l.identity);
35696
+ if (nonParent.size > 1) return refuse("two_non_parent");
35697
+ if (isVRelay === void 0) return nonParent.size === 0 ? BOUND : refuse("relay_unknown");
35698
+ const relays = vRelayIdentities(v, isVRelay);
35699
+ if (relays.size > 1) return refuse("two_relays");
35700
+ const [relay] = relays;
35701
+ for (const id of nonParent) if (id !== relay) return refuse("foreign_leaf");
35702
+ return BOUND;
35703
+ }
35704
+ function vRelayIdentities(v, isVRelay) {
35705
+ const relays = /* @__PURE__ */ new Set();
35706
+ for (const l of v.leaves) if (l.basic && l.identity !== null && isVRelay(l.identity)) relays.add(l.identity);
35707
+ return relays;
35708
+ }
35709
+ function voiceRelayIdentity(v, isVRelay) {
35710
+ if (isVRelay === void 0) return void 0;
35711
+ const relays = vRelayIdentities(v, isVRelay);
35712
+ return relays.size === 1 ? [...relays][0] : void 0;
35713
+ }
35714
+ function parseVoiceResolution(channelId, status, body) {
35715
+ if (status === 404) return { kind: "absent" };
35716
+ if (status !== 200) return { kind: "error" };
35717
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return { kind: "error" };
35718
+ const b = body;
35719
+ if (b.channel_id !== channelId || typeof b.channel_kind !== "string") return { kind: "error" };
35720
+ if (b.channel_kind !== "voice") return { kind: "not_voice" };
35721
+ const parent3 = b.parent_channel_id;
35722
+ if (typeof parent3 !== "string" || parent3.length === 0 || parent3 === channelId) return { kind: "error" };
35723
+ return { kind: "voice", parentChannelId: parent3 };
35724
+ }
35725
+ var VOICE_RESOLVE_RETRY_BASE_MS = 3e4;
35726
+ var VOICE_RESOLVE_RETRY_MAX_MS = 10 * 6e4;
35727
+ var VOICE_RESOLVE_ABSENT_HELD_MAX_MS = 6e4;
35728
+ var VOICE_RECEIPT_CAP = 1e4;
35729
+ var VOICE_RECEIPT_MAX_AGE_MS = 15 * 6e4;
35730
+ function voiceParentStamp(p) {
35731
+ return JSON.stringify([p.groupId, p.epoch]);
35732
+ }
35733
+ var VoiceChannelRegistry = class {
35734
+ parents = /* @__PURE__ */ new Map();
35735
+ notVoice = /* @__PURE__ */ new Set();
35736
+ failures = /* @__PURE__ */ new Map();
35737
+ bindings = /* @__PURE__ */ new Map();
35738
+ generations = /* @__PURE__ */ new Map();
35739
+ /** Fix I-1 / Task 20 R1: message id -> P's held position before the batch that first received it; null = not knowable. */
35740
+ receipts = /* @__PURE__ */ new Map();
35741
+ /** Final review M-d: arrivals not recorded because the map was full of young records. */
35742
+ capReached = 0;
35743
+ /** True when a lookup should run now: never once resolved; after a failure, only past its back-off. */
35744
+ resolveDue(channelId, now, opts = {}) {
35745
+ if (this.parents.has(channelId) || this.notVoice.has(channelId)) return false;
35746
+ const f = this.failures.get(channelId);
35747
+ if (f === void 0) return true;
35748
+ let wait = Math.min(VOICE_RESOLVE_RETRY_MAX_MS, VOICE_RESOLVE_RETRY_BASE_MS * 2 ** Math.min(f.attempts - 1, 20));
35749
+ if (f.kind === "absent" && opts.heldGroup === true) wait = Math.min(wait, VOICE_RESOLVE_ABSENT_HELD_MAX_MS);
35750
+ return now - f.at >= wait;
35751
+ }
35752
+ record(channelId, r, now) {
35753
+ if (r.kind === "voice") {
35754
+ this.parents.set(channelId, r.parentChannelId);
35755
+ this.failures.delete(channelId);
35756
+ } else if (r.kind === "not_voice") {
35757
+ this.notVoice.add(channelId);
35758
+ this.failures.delete(channelId);
35759
+ } else {
35760
+ const prev = this.failures.get(channelId);
35761
+ this.failures.set(channelId, { at: now, attempts: (prev?.attempts ?? 0) + 1, kind: r.kind });
35762
+ }
35763
+ }
35764
+ /** True once get_channel has answered for this channel (voice or not). */
35765
+ isResolved(channelId) {
35766
+ return this.parents.has(channelId) || this.notVoice.has(channelId);
35767
+ }
35768
+ /** Final review M-a: true once get_channel answered this channel's own row with a kind other than voice. */
35769
+ isNotVoice(channelId) {
35770
+ return this.notVoice.has(channelId);
35771
+ }
35772
+ /** Final review (R∈P): bound Vs whose admitted R is also a P leaf (the pre-stamp shape; a sunset signal). */
35773
+ relayInParentCount() {
35774
+ let n = 0;
35775
+ for (const [v, e] of this.bindings) {
35776
+ if (this.parents.has(v) && e.verdict.bound && e.relay !== void 0 && e.parentMembers?.has(e.relay) === true) n++;
35777
+ }
35778
+ return n;
35779
+ }
35780
+ /** True for a resolved V, bound or not. */
35781
+ isVoice(channelId) {
35782
+ return this.parents.has(channelId);
35783
+ }
35784
+ /** P of a resolved V, bound or not; undefined otherwise. Never use this to route: see {@link boundParentOf}. */
35785
+ parentOf(channelId) {
35786
+ return this.parents.get(channelId);
35787
+ }
35788
+ /** Every resolved V with its P. */
35789
+ voiceChannels() {
35790
+ return [...this.parents.entries()];
35791
+ }
35792
+ /** The gate for a channel the bridge's listing has never classified (`unlisted`), or for a resolved V. */
35793
+ gate(channelId, unlisted) {
35794
+ if (this.parents.has(channelId)) return "drop";
35795
+ if (!unlisted || this.notVoice.has(channelId)) return "normal";
35796
+ return this.failures.get(channelId)?.kind === "absent" ? "drop" : "hold";
35797
+ }
35798
+ bindingKey(channelId) {
35799
+ return this.bindings.get(channelId)?.key;
35800
+ }
35801
+ setBinding(channelId, key, verdict, positions = null, admitted = {}) {
35802
+ this.bindings.set(channelId, { key, positions, verdict, ...admitted });
35803
+ }
35804
+ /** Fix M-1: each evaluation of a V takes a generation; only the newest started may store its verdict. */
35805
+ beginEvaluation(channelId) {
35806
+ const g = (this.generations.get(channelId) ?? 0) + 1;
35807
+ this.generations.set(channelId, g);
35808
+ return g;
35809
+ }
35810
+ isLatestEvaluation(channelId, generation) {
35811
+ return this.generations.get(channelId) === generation;
35812
+ }
35813
+ /**
35814
+ * Fix I-1: records a frame's receipt stamp the FIRST time it is seen; a re-served frame keeps its first record.
35815
+ * Task 20 R2: at the cap nothing is evicted and the new arrival is not recorded (refused: no record, no parent).
35816
+ */
35817
+ noteReceipt(messageId, pStamp, now = Date.now()) {
35818
+ if (this.receipts.has(messageId)) return;
35819
+ if (this.receipts.size >= VOICE_RECEIPT_CAP) {
35820
+ for (const [id, r] of this.receipts) if (now - r.at > VOICE_RECEIPT_MAX_AGE_MS) this.receipts.delete(id);
35821
+ if (this.receipts.size >= VOICE_RECEIPT_CAP) {
35822
+ this.capReached++;
35823
+ return;
35824
+ }
35825
+ }
35826
+ this.receipts.set(messageId, { stamp: pStamp, at: now });
35827
+ }
35828
+ /** Final review M-d: arrivals refused because the receipt map was full (a number only). */
35829
+ receiptCapReached() {
35830
+ return this.capReached;
35831
+ }
35832
+ /** Test visibility: receipt records held. */
35833
+ receiptCount() {
35834
+ return this.receipts.size;
35835
+ }
35836
+ forgetReceipt(messageId) {
35837
+ this.receipts.delete(messageId);
35838
+ }
35839
+ /**
35840
+ * 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
35841
+ * frame's receipt. A frame received at other positions (or with none recorded) gets undefined: dropped, never held.
35842
+ */
35843
+ boundParentForFrame(channelId, messageId, openedAt) {
35844
+ return this.frameContext(channelId, messageId, openedAt)?.parent;
35845
+ }
35846
+ /**
35847
+ * Task 20: the per-frame verdict with what the accept filter needs. Bound now AND the frame's pre-batch P stamp equals
35848
+ * the verdict's P position AND (R1) the frame's own opening position equals the verdict's V position. A frame with
35849
+ * no record, a null record, or no `openedAt` is refused.
35850
+ */
35851
+ frameContext(channelId, messageId, openedAt) {
35852
+ const parent3 = this.boundParentOf(channelId);
35853
+ if (parent3 === void 0) return void 0;
35854
+ const entry = this.bindings.get(channelId);
35855
+ const at = this.receipts.get(messageId)?.stamp;
35856
+ if (entry?.positions == null || typeof at !== "string" || at !== entry.positions.p) return void 0;
35857
+ const v = entry.positions.v;
35858
+ if (v === null || openedAt == null || openedAt.groupId !== v.groupId || openedAt.epoch !== v.epoch) return void 0;
35859
+ return { parent: parent3, relay: entry.relay, parentMembers: entry.parentMembers ?? /* @__PURE__ */ new Set() };
35860
+ }
35861
+ /** The current verdict, or undefined when never evaluated. */
35862
+ binding(channelId) {
35863
+ return this.bindings.get(channelId)?.verdict;
35864
+ }
35865
+ /** P only while V is a resolved V whose last verdict is bound; undefined otherwise. */
35866
+ boundParentOf(channelId) {
35867
+ const parent3 = this.parents.get(channelId);
35868
+ return parent3 !== void 0 && this.bindings.get(channelId)?.verdict.bound === true ? parent3 : void 0;
35869
+ }
35870
+ };
35871
+
35872
+ // src/voice-channel-frame.ts
35873
+ function classifyVoiceFrame(f) {
35874
+ if (f.senderId === f.ownIdentityId) return "drop";
35875
+ const fromRelay = f.relay !== void 0 && f.senderId === f.relay;
35876
+ const fromMember = f.parentMembers.has(f.senderId);
35877
+ if (!fromRelay && !fromMember) return "drop";
35878
+ let body;
35879
+ try {
35880
+ body = JSON.parse(new TextDecoder().decode(f.plaintext));
35881
+ } catch {
35882
+ return "drop";
35883
+ }
35884
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return "drop";
35885
+ const b = body;
35886
+ if (b.type === "call_transcript") return "call_transcript";
35887
+ if (b.type !== void 0 && b.type !== "text" || typeof b.text !== "string") return "drop";
35888
+ if (fromRelay) return "relay_turn";
35889
+ return b.text.trim() === VOICE_CALL_START_MARKER ? "greeting" : "drop";
35890
+ }
35891
+
35178
35892
  // src/grants/owner-turn.ts
35179
35893
  import { ed25519 as ed255196 } from "@noble/curves/ed25519";
35180
35894
  var HEX = /^(?:[0-9a-fA-F]{2})+$/;
@@ -35270,6 +35984,7 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
35270
35984
  var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
35271
35985
  Object.freeze({ tool: "current_time", mode: "read" })
35272
35986
  ]);
35987
+ var BRIDGE_CAPABILITIES = Object.freeze(["voice_channel_v1"]);
35273
35988
  var RELAYED_VOICE_GRANTS = Object.freeze([
35274
35989
  Object.freeze({ tool: "current_time", mode: "read" }),
35275
35990
  Object.freeze({ tool: "web_search", mode: "read" })
@@ -35544,12 +36259,19 @@ var RoomClassChangedSendError = class extends SendForbiddenError {
35544
36259
  this.message = `reply dropped for channel ${channelId}: the room changed after its tools were granted; no retry`;
35545
36260
  }
35546
36261
  };
36262
+ function voiceVerdictPositions(v, p) {
36263
+ return {
36264
+ v: v.groupId !== null && v.epoch !== null ? { groupId: v.groupId, epoch: v.epoch } : null,
36265
+ p: voiceParentStamp(p)
36266
+ };
36267
+ }
35547
36268
  function rosterFreshAt(entry, heldEpoch, heldGroupId) {
35548
36269
  return entry !== void 0 && typeof heldEpoch === "number" && entry.epoch === heldEpoch && typeof heldGroupId === "string" && entry.groupId === heldGroupId;
35549
36270
  }
35550
36271
  var RoomGrantEpochMovedError = class extends Error {
35551
36272
  };
35552
36273
  var ROOM_GRANT_TRACK_MAX = 4096;
36274
+ var VOICE_FRAME_PARENT_MAX = 4096;
35553
36275
  var SessionSupersededError = class extends Error {
35554
36276
  constructor() {
35555
36277
  super("session_superseded: another copy of this agent took its session during start()");
@@ -35981,6 +36703,13 @@ var BridgeAgent = class _BridgeAgent {
35981
36703
  supersedeRefundable = /* @__PURE__ */ new Set();
35982
36704
  /** M-1: channels this process has used as a FileSafe vault channel. */
35983
36705
  vaultChannelIds = /* @__PURE__ */ new Set();
36706
+ /**
36707
+ * ADR-0024 step 2a Task 19 (spec §9): hidden voice channels V resolved to their conversation P, lookup back-off, and
36708
+ * each V's binding verdict. In memory only: a restart re-resolves (one get_channel per V) and re-evaluates.
36709
+ */
36710
+ voiceChannels = new VoiceChannelRegistry();
36711
+ /** Fix M-3: channels a listing row marked channel_kind "voice"; never recorded as listed. */
36712
+ listedAsVoice = /* @__PURE__ */ new Set();
35984
36713
  /** Phase 3 F3: consecutive agent turns per channel. */
35985
36714
  agentHops = new AgentHopTracker();
35986
36715
  maxAgentHops;
@@ -36011,6 +36740,12 @@ var BridgeAgent = class _BridgeAgent {
36011
36740
  * counter is incremented. Surfaced on health()/metrics().
36012
36741
  */
36013
36742
  failedAttachments = 0;
36743
+ /** Task 20 R4: V frames dropped by the per-frame verdict (not bound, or not at the verdict's positions). A number only. */
36744
+ voiceFramesRefused = 0;
36745
+ /** Task 20: frames on a bound V the accept filter dropped (not R's voice frame, a greeting marker or a transcript). */
36746
+ voiceFramesFiltered = 0;
36747
+ /** Task 20: message id of an admitted V frame -> its P (B.8 keys). Bounded; an entry lives one ingest cycle in practice. */
36748
+ voiceFrameParent = /* @__PURE__ */ new Map();
36014
36749
  /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
36015
36750
  refusedVaultRefs = 0;
36016
36751
  /**
@@ -36424,7 +37159,9 @@ var BridgeAgent = class _BridgeAgent {
36424
37159
  // Fix round MEDIUM 2: a channel this process used as a FileSafe vault
36425
37160
  // channel is never swept (the sweeper also refuses any channel no listing
36426
37161
  // has shown, which covers a vault channel after a restart).
36427
- conversations: () => this.store.memoryConversationIds().filter((c) => !this.vaultChannelIds.has(c)),
37162
+ // 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
37163
+ // when P is, and purgeConversation(P) purges every V of P. P is swept even when only its V holds memory.
37164
+ conversations: () => [...new Set(this.store.memoryConversationIds().filter((c) => !this.vaultChannelIds.has(c)).map((c) => this.store.voiceChannelParent(c) ?? c))],
36428
37165
  enclaveOf: (c) => this.store.getChannelMeta(c)?.enclaveId,
36429
37166
  // Audit r1 M6: the persisted flag, never the channelOwner cache. Set only
36430
37167
  // from a listing, so undefined means no listing has ever shown the channel.
@@ -36844,6 +37581,8 @@ var BridgeAgent = class _BridgeAgent {
36844
37581
  // the actual protected-memory adapter. It is an enum, never a path or
36845
37582
  // provider credential.
36846
37583
  memory_provider: this.cfg.memoryProvider ?? "securecoms",
37584
+ // Spec §2 (Task 18): the per-device capability declaration; a fixed enum list, one body for every report path.
37585
+ capabilities: [...BRIDGE_CAPABILITIES],
36847
37586
  // Spec §11.1-11.2 (OQ-E): flat keys; the string `memory` above is
36848
37587
  // unchanged. memory_notes is true only when notes are actually on (the
36849
37588
  // provider is securecoms and the parsed payload.memory keeps them on);
@@ -38091,6 +38830,7 @@ var BridgeAgent = class _BridgeAgent {
38091
38830
  * across a supersede), and the group must not move around the reads.
38092
38831
  */
38093
38832
  async reclassifyForReply(channelId, grant) {
38833
+ if (this.voiceChannels.isVoice(channelId)) return false;
38094
38834
  const startedAt = Date.now();
38095
38835
  await this.refreshChannelRoster(channelId);
38096
38836
  const held = await this.heldPosition(channelId);
@@ -38107,8 +38847,9 @@ var BridgeAgent = class _BridgeAgent {
38107
38847
  this.enforceRateFloor("a2a", channelId);
38108
38848
  return;
38109
38849
  }
38110
- const meta = this.freshMeta(channelId);
38111
- if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
38850
+ const capChannel = this.capChannelFor(channelId);
38851
+ const meta = this.freshMeta(capChannel);
38852
+ if (meta && !meta.is_dm) this.enforceRateFloor("room", capChannel);
38112
38853
  }
38113
38854
  /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
38114
38855
  afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture) {
@@ -38590,8 +39331,289 @@ var BridgeAgent = class _BridgeAgent {
38590
39331
  return probeResultFor("threw");
38591
39332
  }
38592
39333
  }
39334
+ // ── ADR-0024 step 2a Task 19: the hidden voice channel V (spec §9, B.8) ──────
39335
+ //
39336
+ // The rule and the cache live in voice-channel-binding.ts; this is the wiring: the get_channel lookup, the bridge's
39337
+ // own held ratchet trees and positions, V's member list (is_relay only), and the receive-cycle gate.
39338
+ /**
39339
+ * Task 20's accessor: P's channel id ONLY while V is bound under the spec §9 rule at both groups' held positions as
39340
+ * of the last evaluation (every receive cycle, before any frame is processed; or {@link voiceParentNow}); undefined
39341
+ * for an unbound or refused V, and for any channel that is not a resolved V.
39342
+ */
39343
+ voiceParentOf(channelId) {
39344
+ return this.voiceChannels.boundParentOf(channelId);
39345
+ }
39346
+ /** True for a channel resolved as a V, bound or not. Such a channel is never processed as a normal channel. */
39347
+ isKnownVoiceChannel(channelId) {
39348
+ return this.voiceChannels.isVoice(channelId);
39349
+ }
39350
+ /** Re-evaluates one V at the held positions now, then answers as {@link voiceParentOf}. */
39351
+ async voiceParentNow(channelId) {
39352
+ const parent3 = this.voiceChannels.parentOf(channelId);
39353
+ if (parent3 === void 0) return void 0;
39354
+ await this.reevaluateVoiceBinding(channelId, parent3);
39355
+ return this.voiceChannels.boundParentOf(channelId);
39356
+ }
39357
+ /** A channel no listing has ever classified (in memory or persisted). V is hidden from the listing, so it is one. */
39358
+ isUnlistedChannel(channelId) {
39359
+ return this.channelMeta.get(channelId) === void 0 && this.store.getChannelMeta(channelId)?.isDm === void 0;
39360
+ }
39361
+ voiceChannelGate(channelId) {
39362
+ if (this.vaultChannelIds.has(channelId)) return "normal";
39363
+ return this.voiceChannels.gate(channelId, this.isUnlistedChannel(channelId));
39364
+ }
39365
+ /**
39366
+ * Looks up one channel with get_channel (the voice arm, OQ-5 (a)). Only the classification is kept. Never throws; a
39367
+ * failure is recorded and retried (30 s doubling to 10 min). Called only from {@link resolveUnlistedChannels}, which
39368
+ * selects unlisted channels whose back-off allows.
39369
+ */
39370
+ async resolveVoiceChannel(channelId) {
39371
+ let status = "threw";
39372
+ let body;
39373
+ try {
39374
+ const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(channelId)}?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
39375
+ const res = await this.fetchBounded("get_channel", "read", url, { headers: { authorization: `Bearer ${this.cfg.apiKey}` } });
39376
+ if (res.ok) {
39377
+ body = await res.json();
39378
+ } else {
39379
+ try {
39380
+ await res.body?.cancel();
39381
+ } catch {
39382
+ }
39383
+ }
39384
+ status = res.status;
39385
+ } catch {
39386
+ status = "threw";
39387
+ }
39388
+ const r = parseVoiceResolution(channelId, status, body);
39389
+ this.voiceChannels.record(channelId, r, Date.now());
39390
+ if (r.kind === "voice") {
39391
+ try {
39392
+ this.store.recordVoiceChannelParent(channelId, r.parentChannelId);
39393
+ } catch {
39394
+ }
39395
+ }
39396
+ if (r.kind === "voice") writeServiceLog2("info", "voice.channel_lookup", { result: ServiceLogEnum.of("voice", ["voice", "absent", "error"]) });
39397
+ else if (r.kind !== "not_voice") writeServiceLog2("warn", "voice.channel_lookup", { result: ServiceLogEnum.of(r.kind, ["voice", "absent", "error"]) });
39398
+ }
39399
+ /**
39400
+ * Looks up every unlisted channel among `channelIds` whose back-off allows. A channel is "unlisted" only after this
39401
+ * cycle's listing: if any candidate remains and the cycle has not listed yet, one listing runs first, so a channel
39402
+ * that is merely new (listed, not yet cached) is never looked up. Returns true when it ran that listing.
39403
+ */
39404
+ async resolveUnlistedChannels(channelIds, listed) {
39405
+ const ids = [...new Set(channelIds)].filter((c) => !this.vaultChannelIds.has(c));
39406
+ const held = /* @__PURE__ */ new Set();
39407
+ for (const c of ids) {
39408
+ if (!this.isUnlistedChannel(c) || this.voiceChannels.isResolved(c)) continue;
39409
+ try {
39410
+ if ((await this.heldPosition(c)).groupId !== null) held.add(c);
39411
+ } catch {
39412
+ }
39413
+ }
39414
+ const due = () => ids.filter((c) => this.isUnlistedChannel(c) && this.voiceChannels.resolveDue(c, Date.now(), { heldGroup: held.has(c) }));
39415
+ let candidates = due();
39416
+ if (candidates.length === 0) return false;
39417
+ let ran = false;
39418
+ if (!listed) {
39419
+ await this.refreshChannelMeta();
39420
+ ran = true;
39421
+ candidates = due();
39422
+ }
39423
+ for (const c of candidates) await this.resolveVoiceChannel(c);
39424
+ return ran;
39425
+ }
39426
+ /** Final review M-c: V -> consecutive failed member-list reads for the binding, and when the last one ran. */
39427
+ voiceRosterRetry = /* @__PURE__ */ new Map();
39428
+ /** The first retry after a failure is immediate (next cycle); then 30 s, doubling, capped at 10 min. */
39429
+ voiceRosterRetryDue(v) {
39430
+ const f = this.voiceRosterRetry.get(v);
39431
+ if (f === void 0 || f.attempts <= 1) return true;
39432
+ const wait = Math.min(VOICE_RESOLVE_RETRY_MAX_MS, VOICE_RESOLVE_RETRY_BASE_MS * 2 ** Math.min(f.attempts - 2, 20));
39433
+ return Date.now() - f.at >= wait;
39434
+ }
39435
+ noteVoiceRosterRead(v, ok) {
39436
+ if (ok) {
39437
+ this.voiceRosterRetry.delete(v);
39438
+ return;
39439
+ }
39440
+ this.voiceRosterRetry.set(v, { at: Date.now(), attempts: (this.voiceRosterRetry.get(v)?.attempts ?? 0) + 1 });
39441
+ }
39442
+ /** Every resolved V, re-evaluated where a position or a roster moved. */
39443
+ async reevaluateVoiceBindings() {
39444
+ for (const [v, p] of this.voiceChannels.voiceChannels()) await this.reevaluateVoiceBinding(v, p);
39445
+ }
39446
+ /**
39447
+ * The spec §9 binding rule over the bridge's OWN ratchet trees (never a server list of leaves), keyed by both
39448
+ * groups' held (group, epoch) and both rosters' fetch times: a change in any of them re-evaluates. V's member list
39449
+ * is read only for `is_relay`, and only when it is fresh at V's held position. Fail closed: a tree that moved while
39450
+ * it was read, or any exception, leaves V unbound and is re-evaluated next time.
39451
+ */
39452
+ async reevaluateVoiceBinding(v, p) {
39453
+ const generation = this.voiceChannels.beginEvaluation(v);
39454
+ try {
39455
+ const vPos = await this.heldPosition(v);
39456
+ const pPos = await this.heldPosition(p);
39457
+ const positions = voiceVerdictPositions(vPos, pPos);
39458
+ const parentNotConversation = this.voiceChannels.isVoice(p) || this.isUnlistedChannel(p);
39459
+ const keyOf = () => JSON.stringify([positions, this.freshRosterEntry(v)?.at ?? null, this.freshRosterEntry(p)?.at ?? null, parentNotConversation]);
39460
+ if (this.voiceChannels.bindingKey(v) === keyOf()) return;
39461
+ if (parentNotConversation) {
39462
+ if (this.voiceChannels.isLatestEvaluation(v, generation)) this.noteVoiceBinding(v, keyOf(), { bound: false, reason: "parent_not_conversation" }, positions);
39463
+ return;
39464
+ }
39465
+ const vTree = await this.provider.leafTreeSummary(v);
39466
+ const pTree = await this.provider.leafTreeSummary(p);
39467
+ const relayCheck = () => {
39468
+ const entry = this.freshRosterEntry(v);
39469
+ return entry !== void 0 && rosterFreshAt(entry, vPos.epoch, vPos.groupId) ? (id) => entry.members.get(id)?.is_relay === true : void 0;
39470
+ };
39471
+ if (relayCheck() === void 0 && this.voiceRosterRetryDue(v)) {
39472
+ await this.refreshChannelRoster(v);
39473
+ this.noteVoiceRosterRead(v, relayCheck() !== void 0);
39474
+ }
39475
+ const isVRelay = relayCheck();
39476
+ let verdict = evaluateVoiceBinding(vTree, pTree, isVRelay);
39477
+ const vAfter = await this.heldPosition(v);
39478
+ const pAfter = await this.heldPosition(p);
39479
+ const moved = vAfter.groupId !== vPos.groupId || vAfter.epoch !== vPos.epoch || pAfter.groupId !== pPos.groupId || pAfter.epoch !== pPos.epoch;
39480
+ if (moved) verdict = { bound: false, reason: "moved" };
39481
+ const retry = !verdict.bound && (verdict.reason === "relay_unknown" || verdict.reason === "moved") || verdict.bound && isVRelay === void 0;
39482
+ if (!this.voiceChannels.isLatestEvaluation(v, generation)) return;
39483
+ const parentMembers = /* @__PURE__ */ new Set();
39484
+ for (const l of pTree.leaves) if (l.basic && l.identity !== null) parentMembers.add(l.identity);
39485
+ this.noteVoiceBinding(v, retry ? "" : keyOf(), verdict, positions, void 0, {
39486
+ relay: verdict.bound ? voiceRelayIdentity(vTree, isVRelay) : void 0,
39487
+ parentMembers
39488
+ });
39489
+ } catch (e) {
39490
+ if (this.voiceChannels.isLatestEvaluation(v, generation)) this.noteVoiceBinding(v, "", { bound: false, reason: "moved" }, null, e);
39491
+ }
39492
+ }
39493
+ /**
39494
+ * 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
39495
+ * half is not stamped here: each frame carries the epoch that opened it (`openedAt`).
39496
+ */
39497
+ async voicePositionSnapshot() {
39498
+ const out = /* @__PURE__ */ new Map();
39499
+ for (const [v, p] of this.voiceChannels.voiceChannels()) {
39500
+ try {
39501
+ out.set(v, voiceParentStamp(await this.heldPosition(p)));
39502
+ } catch {
39503
+ }
39504
+ }
39505
+ return out;
39506
+ }
39507
+ /**
39508
+ * 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)
39509
+ * moved between that frame's first receipt and the verdict. Otherwise undefined: the frame is dropped (acked, never
39510
+ * held, never processed). A frame first received before V was resolved has no receipt positions and is refused.
39511
+ */
39512
+ voiceParentForFrame(channelId, messageId, openedAt) {
39513
+ return this.voiceChannels.boundParentForFrame(channelId, messageId, openedAt);
39514
+ }
39515
+ /** Stores a verdict; a change of verdict logs one content-free line. */
39516
+ noteVoiceBinding(v, key, verdict, positions, error, admitted = {}) {
39517
+ const prev = this.voiceChannels.binding(v);
39518
+ this.voiceChannels.setBinding(v, key, verdict, positions, admitted);
39519
+ const state = verdict.bound ? "bound" : verdict.reason;
39520
+ const prevState = prev === void 0 ? void 0 : prev.bound ? "bound" : prev.reason;
39521
+ if (state === prevState && error === void 0) return;
39522
+ writeServiceLog2(verdict.bound ? "info" : "warn", "voice.channel_binding", {
39523
+ 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"])
39524
+ }, error);
39525
+ }
39526
+ /**
39527
+ * The receive-cycle gate, before any per-frame work. A resolved V is never a normal channel: a frame passes only when
39528
+ * the per-frame verdict names P (bound now, its P stamp and its own opening epoch at the verdict's positions) AND the
39529
+ * accept filter takes it (Task 20: R's voice frame or a P member's greeting marker; a call_transcript is acked, not a
39530
+ * turn). Every other V frame is acked unprocessed and never recorded. A channel get_channel answered 404 is acked
39531
+ * unprocessed (fail closed). An unlisted channel whose lookup errored or has not run is held: neither processed nor
39532
+ * acked, so it is re-served once the lookup answers. Everything else passes unchanged.
39533
+ */
39534
+ async gateVoiceEntries(entries) {
39535
+ const keep = [];
39536
+ const drop = [];
39537
+ for (const m of entries) {
39538
+ const gate = this.voiceChannelGate(m.channelId);
39539
+ if (gate === "hold") continue;
39540
+ if (gate === "normal") {
39541
+ this.voiceChannels.forgetReceipt(m.messageId);
39542
+ keep.push(m);
39543
+ continue;
39544
+ }
39545
+ if (this.voiceChannels.isVoice(m.channelId)) {
39546
+ const admitted = this.admitVoiceFrame(m);
39547
+ this.voiceChannels.forgetReceipt(m.messageId);
39548
+ if (admitted) {
39549
+ keep.push(m);
39550
+ continue;
39551
+ }
39552
+ } else {
39553
+ this.voiceChannels.forgetReceipt(m.messageId);
39554
+ }
39555
+ drop.push(m.queueId);
39556
+ }
39557
+ if (drop.length) {
39558
+ try {
39559
+ await this.provider.ackDelivery(this.cfg.deviceId, drop);
39560
+ } catch {
39561
+ }
39562
+ }
39563
+ return keep;
39564
+ }
39565
+ /**
39566
+ * Task 20: the per-frame verdict and the accept filter for one frame on a resolved V. True only for a model turn
39567
+ * (R's voice frame, a P member's greeting marker), which is then keyed per B.8 through {@link voiceFrameParent}. A
39568
+ * call_transcript is accepted but is not a turn (acked, nothing more, as step 1 on P). Counters are numbers only.
39569
+ */
39570
+ admitVoiceFrame(m) {
39571
+ const parent3 = this.voiceParentForFrame(m.channelId, m.messageId, m.openedAt ?? null);
39572
+ const ctx = parent3 === void 0 ? void 0 : this.voiceChannels.frameContext(m.channelId, m.messageId, m.openedAt ?? null);
39573
+ if (ctx === void 0) {
39574
+ this.voiceFramesRefused++;
39575
+ return false;
39576
+ }
39577
+ const kind2 = classifyVoiceFrame({
39578
+ senderId: m.envelope.sender_id,
39579
+ ownIdentityId: this.cfg.identityId,
39580
+ relay: ctx.relay,
39581
+ parentMembers: ctx.parentMembers,
39582
+ plaintext: m.plaintext
39583
+ });
39584
+ if (kind2 === "call_transcript") return false;
39585
+ if (kind2 === "drop") {
39586
+ this.voiceFramesFiltered++;
39587
+ return false;
39588
+ }
39589
+ this.voiceFrameParent.set(m.messageId, ctx.parent);
39590
+ if (this.voiceFrameParent.size > VOICE_FRAME_PARENT_MAX) this.voiceFrameParent.delete(this.voiceFrameParent.keys().next().value);
39591
+ return true;
39592
+ }
39593
+ /**
39594
+ * Task 20 (B.8): the key channel for metadata, audience, owner, manifest and caps. For an admitted V frame, its P;
39595
+ * for any other frame, its own channel.
39596
+ */
39597
+ keyChannelForFrame(m) {
39598
+ return this.voiceFrameParent.get(m.messageId) ?? m.channelId;
39599
+ }
39600
+ /**
39601
+ * Task 20 (B.8 "per-room daily caps → P", the room rate floor): the channel a cap or floor counts against. A known V
39602
+ * counts against its bound P; an unbound V against itself (no grant can be issued there, so nothing is widened).
39603
+ */
39604
+ capChannelFor(channelId) {
39605
+ return this.voiceChannels.parentOf(channelId) ?? channelId;
39606
+ }
39607
+ /** Phase 3 F1 pause, for a voice channel V also read on its P (a paused agent runs no inference on the call either). */
39608
+ pausedFor(channelId) {
39609
+ return this.pausedChannels.has(channelId) || this.pausedChannels.has(this.capChannelFor(channelId));
39610
+ }
38593
39611
  /** §5.4: one conversation's turns, facts, notes and recall, inside the memory mutex, with every in-memory copy. */
38594
39612
  async purgeConversation(channelId) {
39613
+ await this.purgeOneConversation(channelId);
39614
+ for (const v of this.store.voiceChannelsOf(channelId)) await this.purgeOneConversation(v);
39615
+ }
39616
+ async purgeOneConversation(channelId) {
38595
39617
  const scopes = this.unifiedOn ? this.store.ownerDmScopeIds().filter((s) => parseOwnerDmScopeId(s)?.channelId === channelId) : [];
38596
39618
  await this.notes.purgeChannel(channelId);
38597
39619
  for (const s of scopes) this.store.clearConversationAbsence(s);
@@ -39448,6 +40470,12 @@ var BridgeAgent = class _BridgeAgent {
39448
40470
  * frozen at receipt) AND a window-eligible turn (voiceWindowEligible), whatever the window size. Two call sites: the voice
39449
40471
  * 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;
39450
40472
  * otherwise nothing is written (H-4). Logs are content-free (U17). Never throws.
40473
+ *
40474
+ * Bridge 0.33.1 (architect Q2, C5 not accepted): a turn on a voice channel V carries `parent`, its P from the
40475
+ * per-frame verdict. The scope is ownerScopeForWrite(P) (P's pin and roster, as for a turn on P), the item keeps
40476
+ * channelId = V (so V's own capture still guards it in the store), and P's receipt capture is checked at commit,
40477
+ * inside the memory mutex: a purge or room clear of P since receipt writes nothing. No P capture at receipt, or no
40478
+ * persisted V -> P link (the purge of P reaches V's items only through it): nothing is written.
39451
40479
  */
39452
40480
  async tapUnifiedVoice(m, c) {
39453
40481
  const skip = (reason) => this.logUnifiedTapSkip(reason);
@@ -39456,9 +40484,22 @@ var BridgeAgent = class _BridgeAgent {
39456
40484
  if (m.authorization?.relay_speaker !== "owner" || m.speaker === void 0) return skip("audience");
39457
40485
  if (c.stamp === void 0) return skip("stamp_missing");
39458
40486
  if (c.capture.purgeEpoch < 0) return skip("purged");
39459
- const scopeId = await this.ownerScopeForWrite(m.channel_id);
40487
+ const parent3 = c.parent;
40488
+ const parentCapture = parent3?.capture;
40489
+ if (parent3 === void 0 && this.voiceChannels.isVoice(m.channel_id)) return skip("scope");
40490
+ if (parent3 !== void 0) {
40491
+ if (parentCapture === void 0 || parentCapture.purgeEpoch < 0) return skip("scope");
40492
+ if (this.store.voiceChannelParent(m.channel_id) !== parent3.channelId) return skip("scope");
40493
+ }
40494
+ const scopeId = await this.ownerScopeForWrite(parent3?.channelId ?? m.channel_id);
39460
40495
  if (scopeId === null) return skip("scope");
39461
40496
  if (parseOwnerDmScopeId(scopeId)?.ownerIdentityId !== m.speaker.identityId) return skip("scope");
40497
+ const precommit = parent3 === void 0 || parentCapture === void 0 ? void 0 : () => {
40498
+ const now = this.store.itemCapture(parent3.channelId);
40499
+ if (now.purgeEpoch !== parentCapture.purgeEpoch) return "purged";
40500
+ if (now.roomClearGeneration !== parentCapture.roomClearGeneration) return "room_cleared";
40501
+ return null;
40502
+ };
39462
40503
  const r = await this.items.commit({
39463
40504
  scopeKind: "owner_dm",
39464
40505
  scopeId,
@@ -39469,7 +40510,8 @@ var BridgeAgent = class _BridgeAgent {
39469
40510
  source: "owner_voice",
39470
40511
  trust: OWNER_VOICE_TRUST,
39471
40512
  text: typeof m.text === "string" ? m.text : "",
39472
- expect: { ...c.capture, clearGeneration: c.stamp }
40513
+ expect: { ...c.capture, clearGeneration: c.stamp },
40514
+ ...precommit ? { precommit } : {}
39473
40515
  });
39474
40516
  return r.ok ? { ok: true } : { ok: false, reason: r.reason };
39475
40517
  } catch {
@@ -39831,17 +40873,17 @@ var BridgeAgent = class _BridgeAgent {
39831
40873
  async applyMemoryNote(raw, ctx) {
39832
40874
  if (this.unifiedOn) return this.applyUnifiedNote(raw, ctx);
39833
40875
  const ids = echoIds(raw);
39834
- const refuse = (reason, echo = ids) => {
40876
+ const refuse2 = (reason, echo = ids) => {
39835
40877
  writeServiceLog2("info", "memory.note_refused", {
39836
40878
  reason_class: ServiceLogEnum.of(reason, ["mode", "invalid", "message", "audience", "provenance", "secret", "cap", "cleared", "store", "budget", "purged", "not_owner_words"])
39837
40879
  });
39838
40880
  return { type: "memory_note_result", ...echo, ok: false, reason };
39839
40881
  };
39840
- if (!this.notesProviderOn()) return refuse("mode");
40882
+ if (!this.notesProviderOn()) return refuse2("mode");
39841
40883
  const f = parseMemoryNoteFrame(raw);
39842
- if (!f) return refuse("invalid");
39843
- if (ctx.pendingRequests.has(f.request_id)) return refuse("invalid", { message_id: f.message_id });
39844
- if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse("invalid", {});
40884
+ if (!f) return refuse2("invalid");
40885
+ if (ctx.pendingRequests.has(f.request_id)) return refuse2("invalid", { message_id: f.message_id });
40886
+ if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse2("invalid", {});
39845
40887
  ctx.pendingRequests.add(f.request_id);
39846
40888
  try {
39847
40889
  const w2 = () => {
@@ -39853,13 +40895,13 @@ var BridgeAgent = class _BridgeAgent {
39853
40895
  return cur.record;
39854
40896
  };
39855
40897
  const rec = w2();
39856
- if (rec === void 0) return refuse("message");
40898
+ if (rec === void 0) return refuse2("message");
39857
40899
  const channelId = rec.channel_id;
39858
- if (rec.authorization?.memory_write !== true) return refuse("audience");
39859
- if (!this.noteTurnClean(rec.message_id)) return refuse("audience");
40900
+ if (rec.authorization?.memory_write !== true) return refuse2("audience");
40901
+ if (!this.noteTurnClean(rec.message_id)) return refuse2("audience");
39860
40902
  const held = await this.heldPosition(channelId);
39861
40903
  const now = this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false });
39862
- if (now.memory_write !== true) return refuse("audience");
40904
+ if (now.memory_write !== true) return refuse2("audience");
39863
40905
  const spend = async () => {
39864
40906
  const rw = await this.store.prepareRuntimeInboxRewrite(rec.message_id, bumpW9);
39865
40907
  if (rw === "declined") return "budget";
@@ -39875,44 +40917,44 @@ var BridgeAgent = class _BridgeAgent {
39875
40917
  };
39876
40918
  if (f.op === "forget" && typeof f.id === "object") {
39877
40919
  const shown = ctx.shownRecall(f.message_id, ctx.token);
39878
- if (!shown || !shown.has(f.id.recall)) return refuse("invalid");
40920
+ if (!shown || !shown.has(f.id.recall)) return refuse2("invalid");
39879
40921
  const rw = await spend();
39880
- if (typeof rw === "string") return refuse(rw);
39881
- if (w2() !== rec) return refuse("message");
39882
- if (!this.notesProviderOn()) return refuse("mode");
40922
+ if (typeof rw === "string") return refuse2(rw);
40923
+ if (w2() !== rec) return refuse2("message");
40924
+ if (!this.notesProviderOn()) return refuse2("mode");
39883
40925
  const r2 = await this.recall.forget(channelId, f.id.recall, rw, precommit(rw));
39884
- if (r2 === "unknown_id") return refuse("invalid");
39885
- if (r2 !== "ok") return refuse(r2);
40926
+ if (r2 === "unknown_id") return refuse2("invalid");
40927
+ if (r2 !== "ok") return refuse2(r2);
39886
40928
  writeServiceLog2("info", "memory.recall_forgotten");
39887
40929
  return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true };
39888
40930
  }
39889
40931
  let mutation;
39890
40932
  let rewrite;
39891
40933
  if (f.op === "forget") {
39892
- if (typeof f.id !== "number") return refuse("invalid");
40934
+ if (typeof f.id !== "number") return refuse2("invalid");
39893
40935
  const rw = await spend();
39894
- if (typeof rw === "string") return refuse(rw);
40936
+ if (typeof rw === "string") return refuse2(rw);
39895
40937
  rewrite = rw;
39896
40938
  mutation = { op: "forget", id: f.id, rewrite: rw };
39897
40939
  } else {
39898
40940
  const p = prepareStoredNote(f.text, { text: rec.text, ...rec.transcribed === true ? { transcribed: true } : {} });
39899
- if (!p.ok) return refuse(p.reason);
40941
+ if (!p.ok) return refuse2(p.reason);
39900
40942
  if (f.op === "replace") {
39901
- if (typeof f.id !== "number") return refuse("invalid");
40943
+ if (typeof f.id !== "number") return refuse2("invalid");
39902
40944
  const rw = await spend();
39903
- if (typeof rw === "string") return refuse(rw);
40945
+ if (typeof rw === "string") return refuse2(rw);
39904
40946
  rewrite = rw;
39905
40947
  mutation = { op: "replace", id: f.id, text: p.text, rewrite: rw };
39906
40948
  } else {
39907
40949
  mutation = { op: "add", text: p.text };
39908
40950
  }
39909
40951
  }
39910
- if (w2() !== rec) return refuse("message");
39911
- if (!this.notesProviderOn()) return refuse("mode");
40952
+ if (w2() !== rec) return refuse2("message");
40953
+ if (!this.notesProviderOn()) return refuse2("mode");
39912
40954
  const stamp = this.ingestGeneration.get(rec.message_id);
39913
- if (stamp === void 0) return refuse("cleared");
40955
+ if (stamp === void 0) return refuse2("cleared");
39914
40956
  const r = await this.notes.write(channelId, mutation, stamp, precommit(rewrite));
39915
- if (!r.ok) return refuse(r.reason);
40957
+ if (!r.ok) return refuse2(r.reason);
39916
40958
  writeServiceLog2("info", "memory.note_written", {
39917
40959
  op: ServiceLogEnum.of(f.op, ["add", "replace", "forget"]),
39918
40960
  notes_count: r.notesCount,
@@ -39921,7 +40963,7 @@ var BridgeAgent = class _BridgeAgent {
39921
40963
  return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true, id: r.id };
39922
40964
  } catch (e) {
39923
40965
  writeServiceLog2("warn", "memory.note_write_failed", {}, e);
39924
- return refuse("store");
40966
+ return refuse2("store");
39925
40967
  } finally {
39926
40968
  ctx.pendingRequests.delete(f.request_id);
39927
40969
  }
@@ -39935,17 +40977,17 @@ var BridgeAgent = class _BridgeAgent {
39935
40977
  */
39936
40978
  async applyUnifiedNote(raw, ctx) {
39937
40979
  const ids = echoIds(raw);
39938
- const refuse = (reason, echo = ids) => {
40980
+ const refuse2 = (reason, echo = ids) => {
39939
40981
  writeServiceLog2("info", "memory.note_refused", {
39940
40982
  reason_class: ServiceLogEnum.of(reason, ["mode", "invalid", "message", "audience", "provenance", "secret", "cap", "cleared", "store", "budget", "purged", "not_owner_words"])
39941
40983
  });
39942
40984
  return { type: "memory_note_result", ...echo, ok: false, reason };
39943
40985
  };
39944
- if (!this.unifiedProviderOn()) return refuse("mode");
40986
+ if (!this.unifiedProviderOn()) return refuse2("mode");
39945
40987
  const f = parseMemoryNoteFrame(raw);
39946
- if (!f) return refuse("invalid");
39947
- if (ctx.pendingRequests.has(f.request_id)) return refuse("invalid", { message_id: f.message_id });
39948
- if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse("invalid", {});
40988
+ if (!f) return refuse2("invalid");
40989
+ if (ctx.pendingRequests.has(f.request_id)) return refuse2("invalid", { message_id: f.message_id });
40990
+ if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse2("invalid", {});
39949
40991
  ctx.pendingRequests.add(f.request_id);
39950
40992
  try {
39951
40993
  const w2 = () => {
@@ -39957,25 +40999,25 @@ var BridgeAgent = class _BridgeAgent {
39957
40999
  return cur.record;
39958
41000
  };
39959
41001
  const rec = w2();
39960
- if (rec === void 0) return refuse("message");
41002
+ if (rec === void 0) return refuse2("message");
39961
41003
  const channelId = rec.channel_id;
39962
- if (rec.authorization?.memory_write !== true) return refuse("audience");
39963
- if (!this.noteTurnClean(rec.message_id)) return refuse("audience");
41004
+ if (rec.authorization?.memory_write !== true) return refuse2("audience");
41005
+ if (!this.noteTurnClean(rec.message_id)) return refuse2("audience");
39964
41006
  const held = await this.heldPosition(channelId);
39965
- if (this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false }).memory_write !== true) return refuse("audience");
41007
+ if (this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false }).memory_write !== true) return refuse2("audience");
39966
41008
  const scopeId = this.ownerScopeNow(channelId);
39967
- if (scopeId === null) return refuse("audience");
41009
+ if (scopeId === null) return refuse2("audience");
39968
41010
  const precommit = () => !this.unifiedProviderOn() ? "mode" : w2() !== rec ? "message" : null;
39969
- if (f.op === "replace") return refuse("invalid");
39970
- if (f.op === "forget") return await this.forgetUnified(f, rec, channelId, ctx, refuse, precommit);
41011
+ if (f.op === "replace") return refuse2("invalid");
41012
+ if (f.op === "forget") return await this.forgetUnified(f, rec, channelId, ctx, refuse2, precommit);
39971
41013
  const stripped = allowlistPin(f.text);
39972
- if (stripped.trim().length === 0) return refuse("invalid");
41014
+ if (stripped.trim().length === 0) return refuse2("invalid");
39973
41015
  const shown = ctx.shownUnified(f.message_id, ctx.token) ?? /* @__PURE__ */ new Set();
39974
- if (!pinCoverage(stripped, rec.text, this.unifiedReads.coverageTexts(f.message_id, shown)).ok) return refuse("not_owner_words");
39975
- if (scanNote(stripped) !== null) return refuse("secret");
39976
- if (stripped.length > PIN_MAX_CHARS) return refuse("cap");
41016
+ if (!pinCoverage(stripped, rec.text, this.unifiedReads.coverageTexts(f.message_id, shown)).ok) return refuse2("not_owner_words");
41017
+ if (scanNote(stripped) !== null) return refuse2("secret");
41018
+ if (stripped.length > PIN_MAX_CHARS) return refuse2("cap");
39977
41019
  const stamp = this.ingestGeneration.get(rec.message_id);
39978
- if (stamp === void 0) return refuse("cleared");
41020
+ if (stamp === void 0) return refuse2("cleared");
39979
41021
  const capture = this.store.itemCapture(channelId);
39980
41022
  const r = await this.items.commit({
39981
41023
  scopeKind: "owner_dm",
@@ -39993,7 +41035,7 @@ var BridgeAgent = class _BridgeAgent {
39993
41035
  });
39994
41036
  if (!r.ok) {
39995
41037
  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" };
39996
- return refuse(map[r.reason] ?? "store");
41038
+ return refuse2(map[r.reason] ?? "store");
39997
41039
  }
39998
41040
  await this.notes.exclusive(async () => this.unifiedReads.bumpGeneration());
39999
41041
  void this.send(channelId, pinnedLine(stripped), void 0, { agentTurn: false }).catch((e) => writeServiceLog2("warn", "memory.pinned_line_failed", {}, e));
@@ -40001,7 +41043,7 @@ var BridgeAgent = class _BridgeAgent {
40001
41043
  return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true, id: r.itemIds[0] };
40002
41044
  } catch (e) {
40003
41045
  writeServiceLog2("warn", "memory.note_write_failed", {}, e);
40004
- return refuse("store");
41046
+ return refuse2("store");
40005
41047
  } finally {
40006
41048
  ctx.pendingRequests.delete(f.request_id);
40007
41049
  }
@@ -40015,16 +41057,16 @@ var BridgeAgent = class _BridgeAgent {
40015
41057
  * is invalidated in the same mutex hold, the item leaves the ranking hot set, then the bridge publishes `Forgot:`
40016
41058
  * (OQ-P2: afterPublish keeps the line out of history and the items). Every reason is a key of UNIFIED_REFUSAL.
40017
41059
  */
40018
- async forgetUnified(f, rec, channelId, ctx, refuse, precommit) {
40019
- if (typeof f.id !== "object") return refuse("invalid");
41060
+ async forgetUnified(f, rec, channelId, ctx, refuse2, precommit) {
41061
+ if (typeof f.id !== "object") return refuse2("invalid");
40020
41062
  const itemId = f.id.recall;
40021
41063
  const shown = ctx.shownUnified(f.message_id, ctx.token);
40022
- if (!shown || !shown.has(itemId)) return refuse("invalid");
41064
+ if (!shown || !shown.has(itemId)) return refuse2("invalid");
40023
41065
  const text = this.unifiedReads.shownText(f.message_id, itemId);
40024
- if (text === void 0) return refuse("invalid");
41066
+ if (text === void 0) return refuse2("invalid");
40025
41067
  const rw = await this.store.prepareRuntimeInboxRewrite(rec.message_id, bumpW9);
40026
- if (rw === "declined") return refuse("budget");
40027
- if (rw === "no_row") return refuse("message");
41068
+ if (rw === "declined") return refuse2("budget");
41069
+ if (rw === "no_row") return refuse2("message");
40028
41070
  const r = await this.notes.exclusive(async () => {
40029
41071
  const veto = precommit();
40030
41072
  if (veto !== null) return { ok: false, reason: veto };
@@ -40037,7 +41079,7 @@ var BridgeAgent = class _BridgeAgent {
40037
41079
  }
40038
41080
  return out;
40039
41081
  });
40040
- if (!r.ok) return refuse(r.reason === "unknown_id" ? "invalid" : r.reason);
41082
+ if (!r.ok) return refuse2(r.reason === "unknown_id" ? "invalid" : r.reason);
40041
41083
  this.rankingNote(() => this.ranking.removed(r.itemIds));
40042
41084
  void this.send(channelId, forgotLine(text), void 0, { agentTurn: false }).catch((e) => writeServiceLog2("warn", "memory.forgot_line_failed", {}, e));
40043
41085
  writeServiceLog2("info", "memory.item_forgotten");
@@ -40111,8 +41153,9 @@ var BridgeAgent = class _BridgeAgent {
40111
41153
  this.fastReplyChains.set(channelId, next);
40112
41154
  }
40113
41155
  /** Count a fast reply against FAST_REPLY_MAX_JOBS and reserve its room send-floor token; returns the release. */
40114
- reserveFastReply(channelId) {
41156
+ reserveFastReply(replyChannel) {
40115
41157
  this.fastReplyJobs++;
41158
+ const channelId = this.capChannelFor(replyChannel);
40116
41159
  this.fastReplyReserved.set(channelId, (this.fastReplyReserved.get(channelId) ?? 0) + 1);
40117
41160
  return () => {
40118
41161
  this.fastReplyJobs--;
@@ -40336,6 +41379,7 @@ var BridgeAgent = class _BridgeAgent {
40336
41379
  });
40337
41380
  if (!res.ok) throw new Error(`list_channels failed: ${res.status}`);
40338
41381
  const rows = await res.json();
41382
+ for (const r of rows) if (r.channel_kind === "voice") this.listedAsVoice.add(r.channel_id);
40339
41383
  for (const r of rows) this.store.recordChannelEnclave(r.channel_id, r.enclave_id);
40340
41384
  return rows.map((r) => ({
40341
41385
  channel_id: r.channel_id,
@@ -40419,6 +41463,7 @@ var BridgeAgent = class _BridgeAgent {
40419
41463
  */
40420
41464
  recordListedChannelMeta(channels, at) {
40421
41465
  for (const c of channels) {
41466
+ if (this.listedAsVoice.has(c.channel_id)) continue;
40422
41467
  this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at });
40423
41468
  this.store.recordChannelIsDm(c.channel_id, c.is_dm);
40424
41469
  }
@@ -40602,7 +41647,8 @@ var BridgeAgent = class _BridgeAgent {
40602
41647
  }
40603
41648
  const self = members.get(this.cfg.identityId);
40604
41649
  if (self?.name) this.lastOwnDisplayName = self.name;
40605
- await this.resolveAndPinOwner(channelId, liveRows);
41650
+ const unclassified = this.isUnlistedChannel(channelId) && !this.voiceChannels.isNotVoice(channelId);
41651
+ if (!this.voiceChannels.isVoice(channelId) && !unclassified) await this.resolveAndPinOwner(channelId, liveRows);
40606
41652
  } catch {
40607
41653
  }
40608
41654
  }
@@ -40638,7 +41684,7 @@ var BridgeAgent = class _BridgeAgent {
40638
41684
  * held group (never the backend's row label, M-2). NEVER throws (M-4): any
40639
41685
  * exception is `failed verify_error`, and the row is acked like any other turn.
40640
41686
  */
40641
- async verifyOwnerTurn(m) {
41687
+ async verifyOwnerTurn(m, keyChannel = m.channelId) {
40642
41688
  const pin = this.cfg.ownerGrantPin?.pin ?? null;
40643
41689
  if (!pin || m.envelope.sender_id !== pin.ownerIdentityId) return OWNER_TURN_UNCHECKED;
40644
41690
  try {
@@ -40650,9 +41696,9 @@ var BridgeAgent = class _BridgeAgent {
40650
41696
  }
40651
41697
  if (v.sender_id !== pin.ownerIdentityId) return this.ownerTurnFailed(m, "malformed");
40652
41698
  let leafClass = "ok";
40653
- if (this.freshMeta(m.channelId)?.is_dm === true) {
41699
+ if (this.freshMeta(keyChannel)?.is_dm === true) {
40654
41700
  const signedDm = this.toolGrantManifest?.owner_dm_channel_id;
40655
- if (signedDm !== void 0 && signedDm !== m.channelId) return this.ownerTurnFailed(m, "wrong_dm_channel");
41701
+ if (signedDm !== void 0 && signedDm !== keyChannel) return this.ownerTurnFailed(m, "wrong_dm_channel");
40656
41702
  leafClass = classifyOwnerDmLeaves((await this.provider.leafTreeSummary(m.channelId)).leaves, {
40657
41703
  ownerIdentityId: pin.ownerIdentityId,
40658
41704
  agentIdentityId: this.cfg.identityId,
@@ -40816,9 +41862,14 @@ var BridgeAgent = class _BridgeAgent {
40816
41862
  });
40817
41863
  }
40818
41864
  /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
40819
- async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId, voiceScoped) {
41865
+ async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId, voiceScoped, voiceParent) {
40820
41866
  const held = await this.heldPosition(channelId);
40821
- const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId, { ownerVerdict, voiceScoped });
41867
+ const keyHeld = voiceParent === void 0 ? held : await this.heldPosition(voiceParent);
41868
+ const auth = this.authorizationFor(voiceParent ?? channelId, senderId, relaySpeakerId, keyHeld.epoch, keyHeld.groupId, {
41869
+ ownerVerdict,
41870
+ voiceScoped,
41871
+ ...voiceParent === void 0 ? {} : { voice: { channelId, heldEpoch: held.epoch, heldGroupId: held.groupId } }
41872
+ });
40822
41873
  if (!(auth.audience === "team_room" && auth.tool_grants.length > 0)) return auth;
40823
41874
  if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
40824
41875
  writeServiceLog2("warn", "room_grant.untracked");
@@ -40871,7 +41922,10 @@ var BridgeAgent = class _BridgeAgent {
40871
41922
  const meta = this.freshMeta(channelId);
40872
41923
  const entry = this.freshRosterEntry(channelId);
40873
41924
  const roster = entry?.members;
40874
- const sender = roster?.get(senderId);
41925
+ const vEntry = opts.voice === void 0 ? void 0 : this.freshRosterEntry(opts.voice.channelId);
41926
+ const vSender = vEntry?.members.get(senderId);
41927
+ const sender = opts.voice === void 0 ? roster?.get(senderId) : vSender === void 0 ? void 0 : roster?.get(senderId) ?? vSender;
41928
+ const senderIsRelay = opts.voice === void 0 ? sender?.is_relay === true : vSender?.is_relay === true;
40875
41929
  let audience = "unknown";
40876
41930
  if (meta && sender) {
40877
41931
  if (sender.is_agent) audience = "agent_to_agent";
@@ -40883,15 +41937,15 @@ var BridgeAgent = class _BridgeAgent {
40883
41937
  let tool_grants = NO_TOOL_GRANTS;
40884
41938
  let relaySpeaker;
40885
41939
  let memoryWrite = false;
40886
- const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId);
40887
- const ownerDmFresh = epochFresh || this.ownerDmCycleFresh.has(channelId);
41940
+ const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId) && (opts.voice === void 0 || rosterFreshAt(vEntry, opts.voice.heldEpoch, opts.voice.heldGroupId));
41941
+ const ownerDmFresh = epochFresh || opts.voice === void 0 && this.ownerDmCycleFresh.has(channelId);
40888
41942
  if (ownerDmFresh && this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
40889
41943
  const pinned = this.channelOwner.get(channelId);
40890
41944
  const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
40891
41945
  if (ownerId !== void 0) {
40892
41946
  const ownerDirect = audience === "owner_dm" && senderId === ownerId;
40893
41947
  const owner = roster?.get(ownerId);
40894
- const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
41948
+ const ownerRelayed = relaySpeakerId !== void 0 && senderIsRelay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
40895
41949
  if (ownerDirect) tool_grants = opts.voiceScoped === true ? RELAYED_VOICE_GRANTS : OWNER_DM_DEFAULT_READ_GRANTS;
40896
41950
  if (ownerRelayed) tool_grants = RELAYED_VOICE_GRANTS;
40897
41951
  if (ownerRelayed) relaySpeaker = "owner";
@@ -41570,6 +42624,9 @@ var BridgeAgent = class _BridgeAgent {
41570
42624
  async ingestCycleBody(receiptIds) {
41571
42625
  if (this.closingDown) return;
41572
42626
  const cycleStart = performance.now();
42627
+ let voiceListed = false;
42628
+ const cycleStartedAt = Date.now();
42629
+ const joinedInPhase0 = /* @__PURE__ */ new Set();
41573
42630
  try {
41574
42631
  const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
41575
42632
  this.recordRecvLag(rows);
@@ -41579,9 +42636,11 @@ var BridgeAgent = class _BridgeAgent {
41579
42636
  }
41580
42637
  for (const cid of channelIds) {
41581
42638
  if (this.joinState.get(cid) !== "JOINED") {
42639
+ joinedInPhase0.add(cid);
41582
42640
  await this.reconcileJoin(cid);
41583
42641
  }
41584
42642
  }
42643
+ if (await this.resolveUnlistedChannels(channelIds, false)) voiceListed = true;
41585
42644
  for (const cid of channelIds) {
41586
42645
  if (this.freshRoster(cid) === void 0) {
41587
42646
  await this.refreshChannelRoster(cid);
@@ -41606,6 +42665,13 @@ var BridgeAgent = class _BridgeAgent {
41606
42665
  }
41607
42666
  } catch {
41608
42667
  }
42668
+ const voiceStamp = await this.voicePositionSnapshot();
42669
+ const receiveStartedAt = Date.now();
42670
+ const stampsFor = (m) => {
42671
+ if (m.receivedAt >= receiveStartedAt) return true;
42672
+ const p = this.voiceChannels.parentOf(m.channelId);
42673
+ return m.receivedAt >= cycleStartedAt && p !== void 0 && !joinedInPhase0.has(p);
42674
+ };
41609
42675
  let entries;
41610
42676
  try {
41611
42677
  entries = await this.latency.time("recv.decrypt", () => this.provider.receiveOnce(this.cfg.deviceId));
@@ -41615,29 +42681,56 @@ var BridgeAgent = class _BridgeAgent {
41615
42681
  for (const cid of entries.supersededChannelIds ?? []) {
41616
42682
  this.joinState.set(cid, "JOINED");
41617
42683
  }
41618
- if (!entries.length) return;
42684
+ if (!entries.length) {
42685
+ await this.reevaluateVoiceBindings();
42686
+ return;
42687
+ }
41619
42688
  await this.maybePruneOwnerReplay();
41620
42689
  const receivedAt = Date.now();
41621
42690
  const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
41622
42691
  const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
41623
42692
  const recallModeEpochAtReceipt = this.store.recallState().modeEpoch;
42693
+ const unifiedVoiceParentAt = (p) => {
42694
+ const cap = itemCaptureAtReceipt.get(p);
42695
+ return { channelId: p, capture: cap === void 0 ? void 0 : { ...cap, purgeEpoch: purgeEpochAtReceipt.get(p) ?? -1 } };
42696
+ };
41624
42697
  for (const m of entries) {
41625
42698
  this.lastInboundAt.set(m.channelId, receivedAt);
41626
42699
  if (m.voiceMode === true) this.heartbeat.noteVoiceActivity();
41627
42700
  if (!purgeEpochAtReceipt.has(m.channelId)) purgeEpochAtReceipt.set(m.channelId, this.store.conversationPurgeEpoch(m.channelId));
41628
42701
  if (this.unifiedOn && !itemCaptureAtReceipt.has(m.channelId)) itemCaptureAtReceipt.set(m.channelId, this.store.itemCapture(m.channelId));
42702
+ const receiptParent = this.voiceChannels.parentOf(m.channelId);
42703
+ if (receiptParent !== void 0) {
42704
+ if (!purgeEpochAtReceipt.has(receiptParent)) purgeEpochAtReceipt.set(receiptParent, this.store.conversationPurgeEpoch(receiptParent));
42705
+ if (this.unifiedOn && !itemCaptureAtReceipt.has(receiptParent)) itemCaptureAtReceipt.set(receiptParent, this.store.itemCapture(receiptParent));
42706
+ }
41629
42707
  this.recallReceipt.set(m.messageId, { purgeEpoch: purgeEpochAtReceipt.get(m.channelId), modeEpoch: recallModeEpochAtReceipt });
41630
42708
  receiptIds.push(m.messageId);
42709
+ const stamp = voiceStamp.get(m.channelId);
42710
+ if (stamp !== void 0) this.voiceChannels.noteReceipt(m.messageId, stampsFor(m) ? stamp : null);
42711
+ else if (this.voiceChannels.isVoice(m.channelId) || this.isUnlistedChannel(m.channelId) && !this.voiceChannels.isResolved(m.channelId)) {
42712
+ this.voiceChannels.noteReceipt(m.messageId, null);
42713
+ }
41631
42714
  }
41632
- let metaRefreshed = false;
41633
- if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
42715
+ if (await this.resolveUnlistedChannels(entries.map((m) => m.channelId), voiceListed)) voiceListed = true;
42716
+ await this.reevaluateVoiceBindings();
42717
+ entries = await this.gateVoiceEntries(entries);
42718
+ let metaRefreshed = voiceListed;
42719
+ if (!voiceListed && entries.some((m) => this.freshMeta(this.keyChannelForFrame(m)) === void 0)) {
41634
42720
  await this.refreshChannelMeta();
41635
42721
  metaRefreshed = true;
41636
42722
  }
41637
- await this.refreshRostersForCycle(entries.map((m) => m.channelId));
42723
+ await this.refreshRostersForCycle(entries.flatMap((m) => {
42724
+ const key = this.keyChannelForFrame(m);
42725
+ return key === m.channelId ? [m.channelId] : [m.channelId, key];
42726
+ }));
41638
42727
  const relayMissRefreshed = /* @__PURE__ */ new Set();
41639
42728
  const acked = [];
41640
42729
  for (const m of entries) {
42730
+ if (this.voiceChannels.isVoice(m.channelId) && !this.voiceFrameParent.has(m.messageId)) {
42731
+ acked.push(m.queueId);
42732
+ continue;
42733
+ }
41641
42734
  try {
41642
42735
  if (m.voiceMode === false) this.callBuffer.reset(m.channelId);
41643
42736
  const encId = m.envelope.enclave_id;
@@ -41798,7 +42891,9 @@ var BridgeAgent = class _BridgeAgent {
41798
42891
  }
41799
42892
  if (typeof parsed.text === "string") {
41800
42893
  if (parsed.text.trim() === VOICE_CALL_START_MARKER) this.callBuffer.reset(m.channelId);
41801
- const meta = this.freshMeta(m.channelId);
42894
+ const keyChannel = this.keyChannelForFrame(m);
42895
+ const voiceParent = keyChannel !== m.channelId ? keyChannel : void 0;
42896
+ const meta = this.freshMeta(keyChannel);
41802
42897
  const senderType = m.envelope.sender_type;
41803
42898
  const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
41804
42899
  const claimsRelaySpeaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string";
@@ -41845,14 +42940,15 @@ var BridgeAgent = class _BridgeAgent {
41845
42940
  senderIsRelay: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.is_relay === true,
41846
42941
  text: parsed.text
41847
42942
  });
41848
- const ownerVerdict = await this.verifyOwnerTurn(m);
42943
+ const ownerVerdict = await this.verifyOwnerTurn(m, keyChannel);
41849
42944
  const turnAuthorization = await this.authorizeTurn(
41850
42945
  m.channelId,
41851
42946
  m.messageId,
41852
42947
  m.envelope.sender_id,
41853
42948
  ownerVerdict,
41854
42949
  speaker?.identityId,
41855
- voiceScoped
42950
+ voiceScoped,
42951
+ voiceParent
41856
42952
  );
41857
42953
  await this.prepareNotesForTurn(m.channelId, turnAuthorization);
41858
42954
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
@@ -41879,11 +42975,12 @@ var BridgeAgent = class _BridgeAgent {
41879
42975
  }
41880
42976
  let fastPathRoomBlocked = false;
41881
42977
  if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
41882
- const reserved = this.fastReplyReserved.get(m.channelId) ?? 0;
41883
- if (this.rateFloor.available(`room:${m.channelId}`) - reserved < 2) fastPathRoomBlocked = true;
42978
+ const floorKey = this.capChannelFor(m.channelId);
42979
+ const reserved = this.fastReplyReserved.get(floorKey) ?? 0;
42980
+ if (this.rateFloor.available(`room:${floorKey}`) - reserved < 2) fastPathRoomBlocked = true;
41884
42981
  }
41885
42982
  if (fastPathRoomBlocked) this.fastPathSkipped++;
41886
- const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedChannels.has(m.channelId);
42983
+ const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedFor(m.channelId);
41887
42984
  const fastGreeting = isCallStart && fastPathFiring;
41888
42985
  const runtimeVoice = (ackSent) => m.voiceMode ? {
41889
42986
  voice: true,
@@ -41952,6 +43049,7 @@ var BridgeAgent = class _BridgeAgent {
41952
43049
  pending: Promise.resolve(),
41953
43050
  purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
41954
43051
  itemCapture: itemCaptureAtReceipt.get(m.channelId),
43052
+ ...voiceParent !== void 0 ? { voiceParent: unifiedVoiceParentAt(voiceParent) } : {},
41955
43053
  bufferGeneration
41956
43054
  };
41957
43055
  this.voiceCoalescing.set(windowKey, win);
@@ -41982,18 +43080,19 @@ var BridgeAgent = class _BridgeAgent {
41982
43080
  const merged = { ...held, text, memory: windowMemory };
41983
43081
  if (this.unifiedOn) {
41984
43082
  const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
41985
- const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
43083
+ const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 }, ...win.voiceParent ? { parent: win.voiceParent } : {} });
41986
43084
  this.unifiedWork.add(work);
41987
43085
  void work.finally(() => this.unifiedWork.delete(work));
41988
43086
  }
41989
43087
  void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice", win.purgeEpoch);
41990
43088
  let roomBlocked = false;
41991
43089
  if (fastPathEligible && isRoom) {
41992
- const reserved = this.fastReplyReserved.get(channelId) ?? 0;
41993
- if (this.rateFloor.available(`room:${channelId}`) - reserved < 2) roomBlocked = true;
43090
+ const floorKey = this.capChannelFor(channelId);
43091
+ const reserved = this.fastReplyReserved.get(floorKey) ?? 0;
43092
+ if (this.rateFloor.available(`room:${floorKey}`) - reserved < 2) roomBlocked = true;
41994
43093
  }
41995
43094
  if (roomBlocked) this.fastPathSkipped++;
41996
- const plan = fastPathEligible && !roomBlocked && !this.pausedChannels.has(channelId) ? this.planFastPath(firstId, text, false) : void 0;
43095
+ const plan = fastPathEligible && !roomBlocked && !this.pausedFor(channelId) ? this.planFastPath(firstId, text, false) : void 0;
41997
43096
  if (!plan || plan.kind === "dup" || plan.kind === "skip") {
41998
43097
  if (plan?.kind === "skip" && plan.full) this.fastPathSkipped++;
41999
43098
  await this.settleHeldRuntimeMessage(merged, true);
@@ -42071,7 +43170,11 @@ var BridgeAgent = class _BridgeAgent {
42071
43170
  );
42072
43171
  if (windowEligible && turnAuthorization.relay_speaker === "owner") {
42073
43172
  const snap = itemCaptureAtReceipt.get(m.channelId) ?? { ...this.store.itemCapture(m.channelId), purgeEpoch: -1 };
42074
- const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), { stamp: this.ingestGeneration.get(m.messageId), capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 } });
43173
+ const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
43174
+ stamp: this.ingestGeneration.get(m.messageId),
43175
+ capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 },
43176
+ ...voiceParent !== void 0 ? { parent: unifiedVoiceParentAt(voiceParent) } : {}
43177
+ });
42075
43178
  this.unifiedWork.add(work);
42076
43179
  void work.finally(() => this.unifiedWork.delete(work));
42077
43180
  }
@@ -42097,6 +43200,7 @@ var BridgeAgent = class _BridgeAgent {
42097
43200
  }
42098
43201
  }
42099
43202
  for (const m of entries) this.recallReceipt.delete(m.messageId);
43203
+ for (const m of entries) this.voiceFrameParent.delete(m.messageId);
42100
43204
  if (acked.length) {
42101
43205
  try {
42102
43206
  await this.latency.time("recv.ack", () => this.provider.ackDelivery(this.cfg.deviceId, acked));
@@ -42377,8 +43481,9 @@ var BridgeAgent = class _BridgeAgent {
42377
43481
  }
42378
43482
  }
42379
43483
  /** Spec §7.5 (T-14c, M-4): the runtime reports an executed-call cap for a room. Never throws. */
42380
- noteRoomCapReached(channelId) {
42381
- if (typeof channelId !== "string" || this.closingDown) return;
43484
+ noteRoomCapReached(reported) {
43485
+ if (typeof reported !== "string" || this.closingDown) return;
43486
+ const channelId = this.capChannelFor(reported);
42382
43487
  if (this.roomSearchDailyCap === 0) return;
42383
43488
  if (this.freshMeta(channelId)?.is_dm !== false || this.channelRoster.get(channelId)?.crossOrg !== false) return;
42384
43489
  const day = utcDayOf(Date.now());
@@ -42435,8 +43540,9 @@ ${channelId}`;
42435
43540
  const a = entry.authorization;
42436
43541
  if (!a || a.audience !== "team_room" || !a.tool_grants.some((g) => g.tool === "web_search")) return entry;
42437
43542
  const day = utcDayOf(Date.now());
43543
+ const room = this.capChannelFor(entry.channel_id);
42438
43544
  if (this.roomSearchDailyCap === 0) {
42439
- this.logRoomCap(day, entry.channel_id, "operator");
43545
+ this.logRoomCap(day, room, "operator");
42440
43546
  return this.withoutWebSearch(entry);
42441
43547
  }
42442
43548
  if (this.agentRoomSearchDailyCap === 0) {
@@ -42444,9 +43550,9 @@ ${channelId}`;
42444
43550
  return this.withoutWebSearch(entry);
42445
43551
  }
42446
43552
  try {
42447
- const n = this.store.countRoomSearchGrant(entry.channel_id, day);
42448
- if (n === this.roomSearchDailyCap && this.store.markRoomSearchCapReached(entry.channel_id, day, "bridge")) {
42449
- this.logRoomCap(day, entry.channel_id, "bridge");
43553
+ const n = this.store.countRoomSearchGrant(room, day);
43554
+ if (n === this.roomSearchDailyCap && this.store.markRoomSearchCapReached(room, day, "bridge")) {
43555
+ this.logRoomCap(day, room, "bridge");
42450
43556
  }
42451
43557
  if (n > this.roomSearchDailyCap) return this.withoutWebSearch(entry);
42452
43558
  const total = this.store.countRoomSearchGrant(AGENT_TOTAL_KEY, day);
@@ -42543,9 +43649,15 @@ ${channelId}`;
42543
43649
  * PAUSE_RECHECK_MS window, so release does not depend on new traffic.
42544
43650
  */
42545
43651
  heldForPause(channelId) {
42546
- const at = this.pausedChannels.get(channelId);
43652
+ let key = channelId;
43653
+ let at = this.pausedChannels.get(channelId);
43654
+ const parent3 = at === void 0 ? this.voiceChannels.parentOf(channelId) : void 0;
43655
+ if (parent3 !== void 0) {
43656
+ key = parent3;
43657
+ at = this.pausedChannels.get(parent3);
43658
+ }
42547
43659
  if (at === void 0) return false;
42548
- this.schedulePauseRecheck(channelId, at);
43660
+ this.schedulePauseRecheck(key, at);
42549
43661
  return true;
42550
43662
  }
42551
43663
  schedulePauseRecheck(channelId, confirmedAt) {
@@ -42574,7 +43686,7 @@ ${channelId}`;
42574
43686
  if (await this.probeOwnPause(channelId) === true) {
42575
43687
  const now = Date.now();
42576
43688
  this.pausedChannels.set(channelId, now);
42577
- if (this.buffer.some((m) => m.channel_id === channelId)) this.schedulePauseRecheck(channelId, now);
43689
+ if (this.buffer.some((m) => m.channel_id === channelId || this.voiceChannels.parentOf(m.channel_id) === channelId)) this.schedulePauseRecheck(channelId, now);
42578
43690
  return;
42579
43691
  }
42580
43692
  this.clearPause(channelId);
@@ -43053,6 +44165,10 @@ ${channelId}`;
43053
44165
  custody_store_unavailable: this.provider.custodyHealth().storeUnavailable,
43054
44166
  failed_attachments: this.failedAttachments,
43055
44167
  refused_vault_refs: this.refusedVaultRefs,
44168
+ voice_frames_refused: this.voiceFramesRefused,
44169
+ voice_frames_filtered: this.voiceFramesFiltered,
44170
+ voice_receipt_cap_reached: this.voiceChannels.receiptCapReached(),
44171
+ voice_relay_in_parent: this.voiceChannels.relayInParentCount(),
43056
44172
  fast_path_sent: this.fastPathSent,
43057
44173
  fast_path_failed: this.fastPathFailed,
43058
44174
  fast_path_skipped: this.fastPathSkipped,