@securecoms/agent-bridge 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17477,7 +17477,17 @@ var CODES = [
17477
17477
  // missed and before any write, so nothing landed. Definite: the committer's
17478
17478
  // work cannot name an agent in a humans-only enclave (admission refuses
17479
17479
  // one), so dropping the commit starts no resend loop.
17480
- { 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" }
17481
17491
  ];
17482
17492
  var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
17483
17493
  var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
@@ -18524,6 +18534,31 @@ function ownLeafIndexOf(mgr) {
18524
18534
  function tagOf(mgr) {
18525
18535
  return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
18526
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
+ }
18527
18562
  function tagOfState(state) {
18528
18563
  const m = new MLSGroupManager(state.ciphersuite);
18529
18564
  m.importState(state);
@@ -18550,6 +18585,8 @@ function isEpochConflict(e) {
18550
18585
  var LABEL = "agentvault-connect-v1";
18551
18586
  var DEFAULT_STUCK_THRESHOLD = 2;
18552
18587
  var DEFAULT_QUARANTINE_THRESHOLD = 8;
18588
+ var KEPT_WELCOME_COMMIT_DEFER_MS = 24 * 60 * 60 * 1e3;
18589
+ var KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS = 32;
18553
18590
  var DELIVERY_PULL_PAGE = 100;
18554
18591
  var DELIVERY_MAX_RETRIES = 5;
18555
18592
  var DrainPageFullError = class extends Error {
@@ -18638,6 +18675,12 @@ var SelfAddRefusedError = class extends Error {
18638
18675
  this.name = "SelfAddRefusedError";
18639
18676
  }
18640
18677
  };
18678
+ var VoiceMirrorAddRefusedError = class extends Error {
18679
+ constructor() {
18680
+ super("addMemberToLiveGroup: refusing a voice-channel Add of a device already in the roster mirror (it would mint a duplicate leaf)");
18681
+ this.name = "VoiceMirrorAddRefusedError";
18682
+ }
18683
+ };
18641
18684
  var VaultCredentialMismatchError = class extends Error {
18642
18685
  expected;
18643
18686
  got;
@@ -18748,6 +18791,15 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
18748
18791
  * {@link errorStats}; mutated only through {@link recordDecryptError} /
18749
18792
  * {@link recordDecryptSuccess}.
18750
18793
  */
18794
+ /** {@link KEPT_WELCOME_COMMIT_DEFER_MS}: channel -> when its first commit was
18795
+ * deferred behind the kept Welcome `welcomeId`. Restarted for a different
18796
+ * Welcome; cleared when the channel decrypts, or when a pass that saw the
18797
+ * whole queue kept no Welcome for it (fix round 1, L2: the Welcome
18798
+ * resolved, so a later re-Welcome gets a fresh, bounded window). An entry
18799
+ * past its bound stays (expired) until then, so the bound cannot restart
18800
+ * itself pass after pass. In memory: a reload restarts it, and the
18801
+ * per-pass row cap is what bounds the page cost across reloads. */
18802
+ keptWelcomeDeferSince = /* @__PURE__ */ new Map();
18751
18803
  errors = /* @__PURE__ */ new Map();
18752
18804
  stuckThreshold;
18753
18805
  /** Consecutive-error threshold at which a wedged channel's row is quarantined
@@ -18966,8 +19018,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
18966
19018
  * channel resumes normal processing and can quarantine again later if it
18967
19019
  * re-wedges (the onWedged guard is reset so the hook can fire again).
18968
19020
  */
19021
+ /** See {@link MlsGroupKeyProviderOpts.deferCommitsWhileWelcomePending}. The
19022
+ * caller has already established that this pass KEPT a no-state Welcome
19023
+ * for `channelId`. True: defer the commit row. Never throws. */
19024
+ deferKeptWelcomeCommit(channelId, welcomeId) {
19025
+ const pred = this.o.deferCommitsWhileWelcomePending;
19026
+ if (!pred)
19027
+ return false;
19028
+ let boundMs = 0;
19029
+ try {
19030
+ const v = pred(channelId);
19031
+ boundMs = v === true ? KEPT_WELCOME_COMMIT_DEFER_MS : typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.min(v, KEPT_WELCOME_COMMIT_DEFER_MS) : 0;
19032
+ } catch {
19033
+ boundMs = 0;
19034
+ }
19035
+ if (boundMs <= 0)
19036
+ return false;
19037
+ const now = this.nowMs();
19038
+ const entry = this.keptWelcomeDeferSince.get(channelId);
19039
+ if (entry === void 0 || entry.welcomeId !== welcomeId) {
19040
+ this.keptWelcomeDeferSince.set(channelId, { since: now, welcomeId });
19041
+ return true;
19042
+ }
19043
+ return now - entry.since < boundMs;
19044
+ }
18969
19045
  recordDecryptSuccess(channelId) {
18970
19046
  this.lastDecryptAt.set(channelId, Date.now());
19047
+ this.keptWelcomeDeferSince.delete(channelId);
18971
19048
  const e = this.errors.get(channelId);
18972
19049
  if (!e)
18973
19050
  return;
@@ -19363,6 +19440,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19363
19440
  * server's REJOIN_THRESHOLD, there was nothing to apply, or a commit would
19364
19441
  * not apply (which means the state is genuinely FORKED, not merely behind,
19365
19442
  * and replay cannot fix a fork).
19443
+ *
19444
+ * THROWS (retryable, never `false`, #1290): CustodyPersistError when the
19445
+ * channel is under the custody hold (nothing applied), and the C2 classes
19446
+ * (PendingCommitUnresolvedError from the guarded MAIN write) after evicting
19447
+ * the advanced cache, so the persisted state is unchanged. A /sync request
19448
+ * that fails also throws, before anything is applied.
19366
19449
  */
19367
19450
  async resyncByReplay(channelId, groupId) {
19368
19451
  if (!await this.hasPersistedChannelState(channelId))
@@ -19382,12 +19465,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19382
19465
  let applied = 0;
19383
19466
  try {
19384
19467
  await this.locks.run(channelId, async () => {
19468
+ if (await this.custodyHeld(channelId))
19469
+ throw new CustodyPersistError(channelId);
19385
19470
  const mgr = await this.manager(channelId);
19386
19471
  try {
19387
19472
  for (const c of commits) {
19388
19473
  if (c.epoch <= Number(mgr.epoch))
19389
19474
  continue;
19390
- await mgr.decrypt(hexToBytes22(c.payload));
19475
+ const commitBytes = hexToBytes22(c.payload);
19476
+ try {
19477
+ await mgr.decrypt(commitBytes);
19478
+ } catch (e) {
19479
+ if (await probeOwnRemoval(mgr, commitBytes) !== null) {
19480
+ this.noteOwnLeafRemoved(channelId, {
19481
+ group_id: groupId,
19482
+ epoch: c.epoch,
19483
+ message_id: c.id
19484
+ });
19485
+ }
19486
+ throw e;
19487
+ }
19391
19488
  applied++;
19392
19489
  }
19393
19490
  if (applied > 0)
@@ -19399,6 +19496,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19399
19496
  }
19400
19497
  });
19401
19498
  } catch (e) {
19499
+ if (isSdkError(e, "CustodyPersistError") || e instanceof CustodyPersistError || e instanceof PendingCommitUnresolvedError || e instanceof GroupSupersededError || e instanceof AttestedOrphanRefusedError) {
19500
+ writeServiceLog("warn", "resync.replay_held", { applied }, e);
19501
+ throw e;
19502
+ }
19402
19503
  writeServiceLog("warn", "resync.replay_commit_failed", { applied }, e);
19403
19504
  return false;
19404
19505
  }
@@ -20127,10 +20228,28 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20127
20228
  * The row is deliberately NOT acked here — the subsequent ensureSession(join) /
20128
20229
  * joinAndReconcile consumes the same Welcome and acks it after joining.
20129
20230
  */
20130
- async peekWelcomeChannel(deviceId) {
20231
+ async peekWelcomeChannel(deviceId, opts = {}) {
20131
20232
  const rows = await this.o.backend.pullDelivery(deviceId);
20132
- const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
20133
- return w?.connect_channel_id ?? null;
20233
+ const asked = /* @__PURE__ */ new Set();
20234
+ for (const r of rows) {
20235
+ if (r.message_type !== "welcome" || r.connect_channel_id == null)
20236
+ continue;
20237
+ const ch = r.connect_channel_id;
20238
+ if (!opts.accept)
20239
+ return ch;
20240
+ if (asked.has(ch))
20241
+ continue;
20242
+ asked.add(ch);
20243
+ let ok = false;
20244
+ try {
20245
+ ok = await opts.accept(ch) === true;
20246
+ } catch {
20247
+ ok = false;
20248
+ }
20249
+ if (ok)
20250
+ return ch;
20251
+ }
20252
+ return null;
20134
20253
  }
20135
20254
  /**
20136
20255
  * Publish this device's KeyPackage if it holds none; otherwise a no-op that
@@ -20236,10 +20355,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20236
20355
  }
20237
20356
  const attempted = [];
20238
20357
  const failed = [];
20358
+ const discarded = [];
20239
20359
  for (const channelId of channelIds) {
20240
20360
  attempted.push(channelId);
20241
20361
  try {
20242
- await this.ensureSession(channelId, { enclaveId: "welcome-drain", bootstrap: false });
20362
+ await this.ensureSession(channelId, {
20363
+ enclaveId: "welcome-drain",
20364
+ bootstrap: false,
20365
+ onUnopenableWelcome: (groupId) => {
20366
+ discarded.push({ channelId, groupId });
20367
+ }
20368
+ });
20243
20369
  } catch (error) {
20244
20370
  failed.push({ channelId, error });
20245
20371
  }
@@ -20249,7 +20375,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20249
20375
  receipt = new DrainReceipt(DRAIN_RECEIPT_TOKEN);
20250
20376
  this.drainReceipts.set(receipt, this.nowMs());
20251
20377
  }
20252
- return { attempted, failed, skipped, unknown, pageFull, receipt };
20378
+ return { attempted, failed, skipped, unknown, pageFull, receipt, discarded };
20253
20379
  }
20254
20380
  /**
20255
20381
  * Resolve the channel's pending commit record, under the channel lock, and
@@ -20722,7 +20848,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20722
20848
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
20723
20849
  return MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
20724
20850
  });
20725
- const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
20851
+ const creatorLeafSignatureKey = bundle ? bytesToHex22(bundle.publicPackage.leafNode.signaturePublicKey) : void 0;
20852
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId], creatorLeafSignatureKey !== void 0 ? { creatorLeafSignatureKey } : void 0);
20726
20853
  if (init.created === false) {
20727
20854
  if (!this.isAdoptableOrphan(init, others)) {
20728
20855
  if (this.isOrphanShape(init, others))
@@ -20742,7 +20869,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20742
20869
  commit_epoch: Number(mgr.epoch),
20743
20870
  expected_epoch: init.current_epoch,
20744
20871
  member_device_ids: [this.o.deviceId, ...others],
20745
- welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : []
20872
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : [],
20873
+ // Task 24b (ruling §3 A): exact cover of the Welcome targets ({} when none).
20874
+ added_leaf_signature_keys: leafKeyClaims(welcome ? others : [], welcome ? pkgs : [])
20746
20875
  }, mgr, null);
20747
20876
  const res = await this.commitWithPendingRecord(channelId, rec, mgr);
20748
20877
  if (!res.landed) {
@@ -20756,15 +20885,32 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20756
20885
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
20757
20886
  if (!w)
20758
20887
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
20888
+ const aborted = () => opts.shouldAbort?.() === true;
20889
+ if (aborted())
20890
+ throw new Error("ensureSession(join): aborted by the caller");
20759
20891
  let mgr;
20760
20892
  try {
20761
20893
  mgr = await this.openWelcome(hexToBytes22(w.payload), [bundle, joinSlot]);
20762
20894
  } catch (err) {
20895
+ if (aborted())
20896
+ throw new Error("ensureSession(join): aborted by the caller");
20763
20897
  this.recordDecryptError(w, err);
20764
20898
  writeServiceLog("warn", "session.unopenable_welcome_discarded", {}, err);
20765
20899
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
20900
+ try {
20901
+ opts.onUnopenableWelcome?.(w.group_id);
20902
+ } catch (e) {
20903
+ writeServiceLog("warn", "session.unopenable_welcome_report_failed", {}, e);
20904
+ }
20905
+ try {
20906
+ this.o.onUnopenableWelcomeDiscarded?.({ channelId, groupId: w.group_id });
20907
+ } catch (e) {
20908
+ writeServiceLog("warn", "session.unopenable_welcome_report_failed", {}, e);
20909
+ }
20766
20910
  return;
20767
20911
  }
20912
+ if (aborted())
20913
+ throw new Error("ensureSession(join): aborted by the caller");
20768
20914
  this.managers.set(channelId, mgr);
20769
20915
  await this.saveMain(channelId, mgr);
20770
20916
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
@@ -20975,6 +21121,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20975
21121
  await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "addMemberToLiveGroup");
20976
21122
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
20977
21123
  groupId = init.group_id;
21124
+ if (this.o.isVoiceChannel?.(channelId) === true) {
21125
+ const mirror = init.member_device_ids;
21126
+ if (!Array.isArray(mirror) || mirror.includes(newDeviceId)) {
21127
+ writeServiceLog("warn", "admission.voice_device_already_in_mirror");
21128
+ throw new VoiceMirrorAddRefusedError();
21129
+ }
21130
+ }
20978
21131
  members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
20979
21132
  }
20980
21133
  if (new TextDecoder().decode(mgr.groupId) !== groupId) {
@@ -20993,7 +21146,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20993
21146
  expected_epoch: priorEpoch,
20994
21147
  member_device_ids: members,
20995
21148
  op: "add",
20996
- welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : []
21149
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : [],
21150
+ // Task 24b (ruling §3 A): the exact Welcome cover's leaf keys.
21151
+ added_leaf_signature_keys: leafKeyClaims(welcome ? [newDeviceId] : [], welcome ? [pkg] : [])
20997
21152
  }, mgr, prior), mgr);
20998
21153
  if (!res.landed) {
20999
21154
  const e = res.error;
@@ -21314,6 +21469,181 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21314
21469
  return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
21315
21470
  }
21316
21471
  }
21472
+ /**
21473
+ * ADR-0024 step 2a, round 2 F4 (Task 24): the committer sweep's DEVICE-level
21474
+ * Remove of one leaf the server's committer-work lists in `remove_devices`
21475
+ * (on the hidden voice channel V: a stale device of an agent or relay
21476
+ * identity that is still on V's roster).
21477
+ *
21478
+ * ⛔ WHY NOT {@link removeDeviceFromLiveGroup}. That path resolves a FOREIGN
21479
+ * identity's leaf by add-order position, which RFC 9420's leftmost-blank
21480
+ * refill permutes. Here the identity keeps a CURRENT device, so a wrong guess
21481
+ * evicts the current device's leaf while the distribute's `removed_device_id`
21482
+ * makes the server drop the STALE device from its mirror. The current device
21483
+ * is then in the roster with no leaf, outside the adds delta forever: an
21484
+ * unrecoverable wedge. So this resolves the leaf by EXACT evidence only:
21485
+ *
21486
+ * - the target device's PUBLISHED KeyPackage, whose LeafNode signature key
21487
+ * is matched against THIS committer's live tree (the device-level leaf
21488
+ * predicate, {@link leafIndicesForKeyPackage}). `generateKeyPackage`
21489
+ * mints a fresh signature keypair per call, so a match names exactly the
21490
+ * leaf that KeyPackage was Added as;
21491
+ * - exactly ONE matching leaf, or nothing is committed;
21492
+ * - that leaf's credential identity is read from the tree, never from the
21493
+ * server, and is what the distribute names as `removed_identity_id`.
21494
+ *
21495
+ * Outcomes. `"removed"`: one leaf evicted and the distribute landed.
21496
+ * `"unresolved"`: no KeyPackage is published for the device, or it matches no
21497
+ * leaf (the device rotated it after its Add, or the leaf is already gone).
21498
+ * Nothing is committed: the leaf, if any, stays, and is reported, never
21499
+ * guessed. The server's next delta lists it again (the caller's throttle
21500
+ * bounds the retries).
21501
+ *
21502
+ * Refusals (thrown before any commit is built, so nothing is distributed):
21503
+ * the committer's own device (`own_device`), any leaf of the committer's own
21504
+ * identity, the committer's own leaf, or an unknown own leaf
21505
+ * (`own_leaf` / `own_leaf_unknown`, {@link OwnLeafRemoveRefusedError}); two
21506
+ * or more matching leaves; a held group that is not the channel's active
21507
+ * group (#757 binding, checked BEFORE init).
21508
+ */
21509
+ async removeStaleDeviceLeafFromLiveGroup(channelId, deviceId, bound) {
21510
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
21511
+ await this.requirePendingResolvedUnlocked(channelId);
21512
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, 0, bound);
21513
+ }));
21514
+ }
21515
+ async removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt, bound) {
21516
+ if (await this.custodyHeld(channelId))
21517
+ throw new CustodyPersistError(channelId);
21518
+ if (deviceId === this.o.deviceId)
21519
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
21520
+ const mgr = await this.manager(channelId);
21521
+ const resolved = bound ? this.resolveBoundDeviceLeaf(mgr, bound) : await this.resolveServedKeyPackageLeaf(mgr, deviceId);
21522
+ if (resolved === "unresolved")
21523
+ return "unresolved";
21524
+ const { leafIndex, leafIdentity } = resolved;
21525
+ await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "removeStaleDeviceLeafFromLiveGroup");
21526
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
21527
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
21528
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: group binding mismatch");
21529
+ }
21530
+ const priorEpoch = Number(mgr.epoch);
21531
+ const prior = tagOf(mgr);
21532
+ const { commit } = await mgr.removeMembers([leafIndex]);
21533
+ if (Number(mgr.epoch) !== priorEpoch + 1) {
21534
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: unexpected epoch drift before distribute");
21535
+ }
21536
+ const members = init.member_device_ids ?? [this.o.deviceId];
21537
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
21538
+ device_id: this.o.deviceId,
21539
+ commit: bytesToHex22(commit),
21540
+ commit_epoch: Number(mgr.epoch),
21541
+ expected_epoch: priorEpoch,
21542
+ member_device_ids: members,
21543
+ op: "remove",
21544
+ removed_identity_id: leafIdentity,
21545
+ removed_device_id: deviceId,
21546
+ welcomes: []
21547
+ }, mgr, prior), mgr);
21548
+ if (!res.landed) {
21549
+ const e = res.error;
21550
+ if (isSupersededRefusal(e))
21551
+ throw new GroupSupersededError(channelId, init.group_id);
21552
+ if (_attempt >= 2 || !isEpochConflict(e))
21553
+ throw e;
21554
+ try {
21555
+ await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
21556
+ } catch (e2) {
21557
+ if (e2 instanceof CustodyPersistError)
21558
+ throw e2;
21559
+ }
21560
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt + 1, bound);
21561
+ }
21562
+ return "removed";
21563
+ }
21564
+ /** F4 (Task 24): the target's PUBLISHED KeyPackage, matched by key against
21565
+ * the committer's tree. Unchanged from Task 24 and its fix round 1. */
21566
+ async resolveServedKeyPackageLeaf(mgr, deviceId) {
21567
+ const kps = await this.o.backend.fetchKeyPackages([deviceId]);
21568
+ const hex = kps[deviceId];
21569
+ if (!hex) {
21570
+ writeServiceLog("info", "committer.remove_device_unresolved", { no_key_package: true });
21571
+ return "unresolved";
21572
+ }
21573
+ const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
21574
+ const leaves = leafIndicesForKeyPackage(mgr, pkg);
21575
+ if (leaves.length === 0) {
21576
+ writeServiceLog("info", "committer.remove_device_unresolved", { no_matching_leaf: true });
21577
+ return "unresolved";
21578
+ }
21579
+ if (leaves.length > 1) {
21580
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous key match (more than one leaf)");
21581
+ }
21582
+ const leafIndex = leaves[0];
21583
+ const tree = mgr.state?.ratchetTree;
21584
+ if (!tree)
21585
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
21586
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
21587
+ if (cred.credentialType !== "basic") {
21588
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: leaf carries no basic credential");
21589
+ }
21590
+ const leafIdentity = new TextDecoder().decode(cred.identity);
21591
+ const ownIdentity = new TextDecoder().decode(this.o.identity);
21592
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
21593
+ if (!leafIdentity || leafIdentity === ownIdentity) {
21594
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf");
21595
+ }
21596
+ if (mgr.leafIndicesForIdentity(leafIdentity).length <= 1) {
21597
+ writeServiceLog("warn", "committer.remove_device_refused_last_leaf", {});
21598
+ return "unresolved";
21599
+ }
21600
+ return { leafIndex, leafIdentity };
21601
+ }
21602
+ /**
21603
+ * ADR-0024 step 2a Task 24b (architect ruling "carry A", §3 B and §4 R5):
21604
+ * resolve the leaf by the SERVER-BOUND leaf signature key (committer work's
21605
+ * `remove_device_leaves`). The server recorded the key when the leaf was
21606
+ * born, from the KeyPackage it vended, so it survives the target's
21607
+ * KeyPackage rotation, revocation and deletion, which the served-KeyPackage
21608
+ * path cannot.
21609
+ *
21610
+ * - Leaves are matched by the bound key ONLY: exactly one match, else
21611
+ * `unresolved` (zero) or a throw (two or more). ⛔ NEVER a fall back to
21612
+ * the served KeyPackage: two evidence sources that disagree fail closed.
21613
+ * - The matched leaf's basic-credential identity must equal the server's
21614
+ * `identityId` for the device (R4), else a throw.
21615
+ * - The committer's own leaf index (or an unknown one) is refused
21616
+ * ({@link refuseOwnLeafRemove}); the own device was refused by the caller.
21617
+ * - R5: a leaf of the committer's OWN identity is admitted (a sibling
21618
+ * device: a revoked laptop). The brick class the own-identity refusal
21619
+ * guards came from positional or identity-level resolution, which this
21620
+ * exact-key path cannot reach, and the own leaf index is still refused.
21621
+ * - No last-leaf refusal: the binding is exact evidence, and an identity's
21622
+ * last leaf is exactly the revoked single-device case (and carry A's
21623
+ * Remove before its re-Add).
21624
+ * Synchronous and before any commit is built: a refusal distributes nothing.
21625
+ */
21626
+ resolveBoundDeviceLeaf(mgr, bound) {
21627
+ const leaves = leafIndicesForSignatureKey(mgr, bound.leafSignatureKeyHex);
21628
+ if (leaves.length === 0) {
21629
+ writeServiceLog("info", "committer.remove_device_unresolved", { no_bound_leaf: true });
21630
+ return "unresolved";
21631
+ }
21632
+ if (leaves.length > 1) {
21633
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous bound-key match (more than one leaf)");
21634
+ }
21635
+ const leafIndex = leaves[0];
21636
+ const tree = mgr.state?.ratchetTree;
21637
+ if (!tree)
21638
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
21639
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
21640
+ const leafIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
21641
+ if (!leafIdentity || leafIdentity !== bound.identityId) {
21642
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: the bound leaf's credential identity is not the device's identity");
21643
+ }
21644
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
21645
+ return { leafIndex, leafIdentity };
21646
+ }
21317
21647
  /**
21318
21648
  * Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
21319
21649
  * targeted by its owner-observed leaf `signaturePublicKey` against the live
@@ -21870,6 +22200,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21870
22200
  out.push({
21871
22201
  queueId: `replay-${m.id}`,
21872
22202
  messageId: envelope.message_id,
22203
+ // #1246 C-1: the group-messages row id is the same mls_messages key.
22204
+ ...typeof m.id === "string" && m.id ? { serverMessageId: m.id } : {},
21873
22205
  channelId,
21874
22206
  plaintext,
21875
22207
  envelope,
@@ -22770,6 +23102,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22770
23102
  const heldBundleRefs = () => heldBundleRefsMemo ??= this.readHeldBundleRefs();
22771
23103
  const w2Acked = /* @__PURE__ */ new Map();
22772
23104
  const w2Kept = /* @__PURE__ */ new Set();
23105
+ const keptWelcomeId = /* @__PURE__ */ new Map();
23106
+ const deferredThisPass = /* @__PURE__ */ new Map();
22773
23107
  const unpinnedRefusals = /* @__PURE__ */ new Map();
22774
23108
  const welcomes = rows.filter((r) => r.message_type === "welcome");
22775
23109
  const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
@@ -22928,6 +23262,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22928
23262
  writeServiceLog("warn", "welcome.unopenable");
22929
23263
  } else {
22930
23264
  w2Kept.add(wcid);
23265
+ if (!keptWelcomeId.has(wcid))
23266
+ keptWelcomeId.set(wcid, row.queue_id);
22931
23267
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
22932
23268
  writeServiceLog("debug", "delivery.welcome_no_local_state");
22933
23269
  }
@@ -22938,6 +23274,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22938
23274
  const cid = row.connect_channel_id;
22939
23275
  if (!cid)
22940
23276
  continue;
23277
+ if (w2Kept.has(cid) && (deferredThisPass.get(cid) ?? 0) < KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS && this.deferKeptWelcomeCommit(cid, keptWelcomeId.get(cid) ?? "")) {
23278
+ deferredThisPass.set(cid, (deferredThisPass.get(cid) ?? 0) + 1);
23279
+ continue;
23280
+ }
22941
23281
  let advancedEpoch;
22942
23282
  let custodyHeld = false;
22943
23283
  await this.locks.run(cid, async () => {
@@ -23094,6 +23434,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23094
23434
  this.leftStreak.set(cid, { pulls: 1, since: pullNow });
23095
23435
  this.scheduleAutoResolve(cid);
23096
23436
  }
23437
+ if (rows.length < DELIVERY_PULL_PAGE) {
23438
+ for (const cid of [...this.keptWelcomeDeferSince.keys()]) {
23439
+ if (!w2Kept.has(cid))
23440
+ this.keptWelcomeDeferSince.delete(cid);
23441
+ }
23442
+ }
23097
23443
  for (const [cid, gid] of w2Acked) {
23098
23444
  if (w2Kept.has(cid))
23099
23445
  continue;
@@ -23134,6 +23480,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23134
23480
  if (supersededChannelIds.length) {
23135
23481
  Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
23136
23482
  }
23483
+ if (w2Kept.size) {
23484
+ Object.defineProperty(appEntries, "pendingWelcomeChannelIds", { value: [...w2Kept], enumerable: false });
23485
+ }
23137
23486
  return appEntries;
23138
23487
  }
23139
23488
  /**
@@ -23370,7 +23719,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23370
23719
  voiceDefaultResponder: row.voice_default_responder ?? void 0,
23371
23720
  ...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
23372
23721
  payloadHex: row.payload,
23373
- openedAt
23722
+ openedAt,
23723
+ // #1246 C-1: the server row id, beside the sender-chosen envelope id.
23724
+ ...typeof row.message_id === "string" && row.message_id ? { serverMessageId: row.message_id } : {}
23374
23725
  };
23375
23726
  const custodiedAt = this.nowMs();
23376
23727
  this.custody.set(row.queue_id, entry);
@@ -24925,9 +25276,9 @@ var RestMlsBackend = class {
24925
25276
  current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
24926
25277
  };
24927
25278
  }
24928
- async initConnectChannelGroup(channelId, memberDeviceIds) {
25279
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
24929
25280
  const path = `/api/v1/mls/connect-channels/${channelId}/init`;
24930
- const body = { member_device_ids: memberDeviceIds };
25281
+ const body = opts?.creatorLeafSignatureKey !== void 0 ? { member_device_ids: memberDeviceIds, creator_leaf_signature_key: opts.creatorLeafSignatureKey } : { member_device_ids: memberDeviceIds };
24931
25282
  let j;
24932
25283
  for (let attempt = 0; ; attempt++) {
24933
25284
  try {
@@ -25169,6 +25520,22 @@ var RestMlsBackend = class {
25169
25520
  throw new Error(`unrequested-removals: reports is ${describeType(rows)}`);
25170
25521
  return rows;
25171
25522
  }
25523
+ async signalVoiceLeafUnusable(groupId, deviceId) {
25524
+ 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 }) });
25525
+ const text = await res.text().catch(() => "");
25526
+ if (res.ok)
25527
+ return { status: res.status };
25528
+ let parsed = null;
25529
+ if (text) {
25530
+ try {
25531
+ parsed = JSON.parse(text);
25532
+ } catch {
25533
+ parsed = null;
25534
+ }
25535
+ }
25536
+ const code = parseErrorCode(parsed);
25537
+ return code === void 0 ? { status: res.status } : { status: res.status, code };
25538
+ }
25172
25539
  async getCommitterWork(channelId) {
25173
25540
  const j = await this.get("mls_committer_work", `/api/v1/mls/connect-channels/${channelId}/committer-work`);
25174
25541
  const requireList = (field) => {
@@ -25178,7 +25545,29 @@ var RestMlsBackend = class {
25178
25545
  }
25179
25546
  return v.map(String);
25180
25547
  };
25181
- return { adds: requireList("adds"), removes: requireList("removes") };
25548
+ const work = {
25549
+ adds: requireList("adds"),
25550
+ removes: requireList("removes")
25551
+ };
25552
+ if (j?.remove_devices !== void 0) {
25553
+ work.removeDevices = requireList("remove_devices");
25554
+ }
25555
+ const rawLeaves = j?.remove_device_leaves;
25556
+ if (rawLeaves !== void 0) {
25557
+ if (!Array.isArray(rawLeaves))
25558
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves", describeType(rawLeaves));
25559
+ const seen = /* @__PURE__ */ new Set();
25560
+ work.removeDeviceLeaves = rawLeaves.map((e) => {
25561
+ const o = e !== null && typeof e === "object" && !Array.isArray(e) ? e : null;
25562
+ const key = typeof o?.leaf_signature_key === "string" ? o.leaf_signature_key.toLowerCase() : null;
25563
+ 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)) {
25564
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves[]", describeType(e));
25565
+ }
25566
+ seen.add(o.device_id);
25567
+ return { deviceId: o.device_id, identityId: o.identity_id, leafSignatureKeyHex: key };
25568
+ });
25569
+ }
25570
+ return work;
25182
25571
  }
25183
25572
  async acquireCommitLease(channelId) {
25184
25573
  try {
@@ -25228,6 +25617,48 @@ var RestMlsBackend = class {
25228
25617
 
25229
25618
  // ../connect-sdk-ts/dist/mls/fake-backend.js
25230
25619
  var DELIVERY_PULL_LIMIT = 100;
25620
+ function keyPackageSignatureKeyHex(kpHex) {
25621
+ const b = hexToBytes22(kpHex);
25622
+ let at = 0;
25623
+ const u16 = () => {
25624
+ if (at + 2 > b.length)
25625
+ throw new Error("t");
25626
+ const v = b[at] << 8 | b[at + 1];
25627
+ at += 2;
25628
+ return v;
25629
+ };
25630
+ const opaque = () => {
25631
+ if (at >= b.length)
25632
+ throw new Error("t");
25633
+ const prefix = b[at] >> 6;
25634
+ if (prefix === 3)
25635
+ throw new Error("t");
25636
+ const size = 1 << prefix;
25637
+ if (at + size > b.length)
25638
+ throw new Error("t");
25639
+ let n = b[at] & 63;
25640
+ for (let i = 1; i < size; i++)
25641
+ n = n << 8 | b[at + i];
25642
+ at += size;
25643
+ if (at + n > b.length)
25644
+ throw new Error("t");
25645
+ const out = b.slice(at, at + n);
25646
+ at += n;
25647
+ return out;
25648
+ };
25649
+ try {
25650
+ if (u16() !== 1 || u16() !== 5)
25651
+ return null;
25652
+ u16();
25653
+ u16();
25654
+ opaque();
25655
+ opaque();
25656
+ const sig = opaque();
25657
+ return sig.length > 0 ? bytesToHex22(sig) : null;
25658
+ } catch {
25659
+ return null;
25660
+ }
25661
+ }
25231
25662
  var FakeConnectBackend = class _FakeConnectBackend {
25232
25663
  // Key-package store: deviceId → hex-encoded KeyPackage
25233
25664
  kps = /* @__PURE__ */ new Map();
@@ -25312,6 +25743,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25312
25743
  * server's purge_device_queue. */
25313
25744
  linkIdentityDevice(identityId, deviceId) {
25314
25745
  this.deviceByIdentity.set(identityId, deviceId);
25746
+ this.identityByDevice.set(deviceId, identityId);
25315
25747
  }
25316
25748
  async publishKeyPackage(deviceId, keyPackageHex) {
25317
25749
  this.kps.set(deviceId, keyPackageHex);
@@ -25361,8 +25793,38 @@ var FakeConnectBackend = class _FakeConnectBackend {
25361
25793
  current_epoch: this.epochByChannel.get(channelId) ?? 0
25362
25794
  };
25363
25795
  }
25364
- async initConnectChannelGroup(channelId, memberDeviceIds) {
25796
+ /** ADR-0024 step 2a (P-7): channel ids the test registers as a hidden
25797
+ * voice channel V. Init on an EXISTING V returns it unchanged and merges
25798
+ * nothing, as the real backend does. */
25799
+ voiceChannelIds = /* @__PURE__ */ new Set();
25800
+ /** Task 24b (architect ruling "carry A", §3 A-C), modelled for a channel in
25801
+ * {@link voiceChannelIds} only, as the PR-E server does for V:
25802
+ * `channel -> device -> { key (hex), unusableSignaledAt }`, written ONLY
25803
+ * when a V leaf is born (the creator's init, an Add's Welcome) from the
25804
+ * verified claim, and deleted when a Remove of that device (or its
25805
+ * identity) lands. */
25806
+ voiceLeafBindings = /* @__PURE__ */ new Map();
25807
+ /** The device -> identity the server knows (the devices table). Written by
25808
+ * {@link linkIdentityDevice}; read for `remove_device_leaves`. */
25809
+ identityByDevice = /* @__PURE__ */ new Map();
25810
+ static voiceRefusal(path, code) {
25811
+ return new MlsBackendHttpError(path, 409, code);
25812
+ }
25813
+ /** The current vendable KeyPackage key of `deviceId`, or null. */
25814
+ currentLeafKey(deviceId) {
25815
+ const hex = this.kps.get(deviceId);
25816
+ return hex ? keyPackageSignatureKeyHex(hex) : null;
25817
+ }
25818
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
25365
25819
  const alreadyExists = this.groupIdByChannel.has(channelId);
25820
+ if (!alreadyExists && this.voiceChannelIds.has(channelId)) {
25821
+ const creator = memberDeviceIds[0];
25822
+ const current = creator ? this.currentLeafKey(creator) : null;
25823
+ if (!opts?.creatorLeafSignatureKey || current === null || opts.creatorLeafSignatureKey.toLowerCase() !== current) {
25824
+ throw new Error(`/api/v1/mls/connect-channels/${channelId}/init failed: 409 {"detail":"voice_stale_key_package"}`);
25825
+ }
25826
+ this.voiceLeafBindings.set(channelId, /* @__PURE__ */ new Map([[creator, { key: current, unusableSignaledAt: null }]]));
25827
+ }
25366
25828
  let groupId = this.groupIdByChannel.get(channelId);
25367
25829
  if (!groupId) {
25368
25830
  groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
@@ -25370,7 +25832,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25370
25832
  this.channelByGroup.set(groupId, channelId);
25371
25833
  this.epochByChannel.set(channelId, 0);
25372
25834
  }
25373
- const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
25835
+ const members = alreadyExists && this.voiceChannelIds.has(channelId) ? [...this.membersByChannel.get(channelId) ?? []] : [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
25374
25836
  this.membersByChannel.set(channelId, members);
25375
25837
  return {
25376
25838
  group_id: groupId,
@@ -25507,6 +25969,27 @@ var FakeConnectBackend = class _FakeConnectBackend {
25507
25969
  throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
25508
25970
  }
25509
25971
  }
25972
+ const vBindings = this.voiceChannelIds.has(channelId) ? this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map() : null;
25973
+ let bindNow = [];
25974
+ if (vBindings && req.commit && req.op !== "remove") {
25975
+ const targets = (req.welcomes ?? []).map((w) => w.target_device_id);
25976
+ const claims = req.added_leaf_signature_keys;
25977
+ const path = _FakeConnectBackend.distributePath(groupId);
25978
+ if (!claims)
25979
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
25980
+ const claimed = Object.keys(claims);
25981
+ if (claimed.length !== targets.length || targets.some((t) => !(t in claims))) {
25982
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
25983
+ }
25984
+ for (const t of targets) {
25985
+ const current = this.currentLeafKey(t);
25986
+ if (current === null || claims[t].toLowerCase() !== current)
25987
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
25988
+ if (vBindings.has(t))
25989
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_leaf_already_bound");
25990
+ }
25991
+ bindNow = targets.map((t) => [t, claims[t].toLowerCase()]);
25992
+ }
25510
25993
  if (req.commit) {
25511
25994
  const log = this.commitLog.get(groupId) ?? [];
25512
25995
  log.push({
@@ -25529,6 +26012,12 @@ var FakeConnectBackend = class _FakeConnectBackend {
25529
26012
  this.membersByChannel.set(channelId, remaining);
25530
26013
  if (evictedDevice)
25531
26014
  this.queues.delete(evictedDevice);
26015
+ if (vBindings) {
26016
+ for (const d of [...vBindings.keys()]) {
26017
+ if (d === evictedDevice || !req.removed_device_id && this.identityByDevice.get(d) === req.removed_identity_id)
26018
+ vBindings.delete(d);
26019
+ }
26020
+ }
25532
26021
  } else {
25533
26022
  const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
25534
26023
  this.membersByChannel.set(channelId, members);
@@ -25552,6 +26041,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
25552
26041
  });
25553
26042
  }
25554
26043
  }
26044
+ if (vBindings && bindNow.length > 0) {
26045
+ for (const [d, k] of bindNow)
26046
+ vBindings.set(d, { key: k, unusableSignaledAt: null });
26047
+ this.voiceLeafBindings.set(channelId, vBindings);
26048
+ }
25555
26049
  for (const w of req.welcomes ?? []) {
25556
26050
  this.q(w.target_device_id).push({
25557
26051
  queue_id: "q-" + this._seq++,
@@ -25612,6 +26106,51 @@ var FakeConnectBackend = class _FakeConnectBackend {
25612
26106
  async getCommitterWork(_channelId) {
25613
26107
  return { adds: [], removes: [] };
25614
26108
  }
26109
+ /** Task 24b (ruling §3 C): the carry-A signal, every call recorded. Gates
26110
+ * modelled: the group is a V (else 404), a binding exists (else 409), the
26111
+ * device's current vendable key differs from the bound one (409
26112
+ * `voice_leaf_key_current` when equal, `voice_no_current_key_package`
26113
+ * when it has none). An already-signalled row is an idempotent 202. The
26114
+ * bind and roster gates are the server's (backend tests). */
26115
+ signalVoiceLeafUnusableCalls = [];
26116
+ async signalVoiceLeafUnusable(groupId, deviceId) {
26117
+ this.signalVoiceLeafUnusableCalls.push({ groupId, deviceId });
26118
+ const channelId = this.channelByGroup.get(groupId);
26119
+ if (!channelId || !this.voiceChannelIds.has(channelId))
26120
+ return { status: 404, code: "Not Found" };
26121
+ const row = this.voiceLeafBindings.get(channelId)?.get(deviceId);
26122
+ if (!row)
26123
+ return { status: 409, code: "voice_leaf_binding_missing" };
26124
+ const current = this.currentLeafKey(deviceId);
26125
+ if (current === null)
26126
+ return { status: 409, code: "voice_no_current_key_package" };
26127
+ if (current === row.key)
26128
+ return { status: 409, code: "voice_leaf_key_current" };
26129
+ if (row.unusableSignaledAt === null)
26130
+ row.unusableSignaledAt = this.now();
26131
+ return { status: 202 };
26132
+ }
26133
+ /** Task 24b test seam: V's committer work as the PR-E server derives it,
26134
+ * for a test's `roster` (the server's roster devices): `adds` = roster
26135
+ * devices not in the mirror; `remove_devices` / `remove_device_leaves` =
26136
+ * roster devices whose binding is signalled unusable (NOT in `adds`: they
26137
+ * are still in the mirror). The re-Add follows only from a later call,
26138
+ * after the Remove landed and the mirror dropped the device. */
26139
+ voiceCommitterWork(channelId, roster) {
26140
+ const mirror = this.membersByChannel.get(channelId) ?? [];
26141
+ const bindings = this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map();
26142
+ const signalled = roster.filter((d) => mirror.includes(d) && bindings.get(d)?.unusableSignaledAt != null);
26143
+ return {
26144
+ adds: roster.filter((d) => !mirror.includes(d)),
26145
+ removes: [],
26146
+ removeDevices: signalled,
26147
+ removeDeviceLeaves: signalled.map((d) => ({
26148
+ deviceId: d,
26149
+ identityId: this.identityByDevice.get(d) ?? "",
26150
+ leafSignatureKeyHex: bindings.get(d).key
26151
+ }))
26152
+ };
26153
+ }
25615
26154
  async acquireCommitLease(_channelId) {
25616
26155
  return "fake-lease-token";
25617
26156
  }
@@ -25704,6 +26243,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
25704
26243
  // Owner-signed tool grants (spec §7.0): like Plan 0's backend, each row carries
25705
26244
  // the envelope's `identity_proof.signature` as `identity_signature` beside the
25706
26245
  // unsigned canonical payload. `omitSignature` models a pre-Plan-0 row.
26246
+ /** `rowMessageId` (#1246 C-1): the server row id to deliver, when it is NOT
26247
+ * the envelope's id. The REST send path keys the row by the envelope id
26248
+ * (connect_messages.py, `MLSMessage(id=message_id)`); the WS `message_mls`
26249
+ * path stores through `store_mls_message`, whose row id is server-generated,
26250
+ * so there the two differ and the envelope id is the sender's free choice. */
25707
26251
  async ingestApplication(envelope, opts) {
25708
26252
  const channelId = envelope.channel_id;
25709
26253
  const canonical = await canonicalEnvelopeBytes(envelope);
@@ -25715,7 +26259,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25715
26259
  continue;
25716
26260
  this.q(dev).push({
25717
26261
  queue_id: "q-" + this._seq++,
25718
- message_id: envelope.message_id,
26262
+ message_id: opts?.rowMessageId ?? envelope.message_id,
25719
26263
  group_id: groupId,
25720
26264
  message_type: "application",
25721
26265
  sender_device_id: senderDeviceId ?? "",
@@ -26099,6 +26643,7 @@ function openSchema(db) {
26099
26643
  ensureRuntimeInboxDeliveryAttempts(db);
26100
26644
  ensureRuntimeReplyIntent(db);
26101
26645
  ensureRuntimeReplyIntentEnvelopeGroup(db);
26646
+ ensureRuntimeReplyIntentCredit(db);
26102
26647
  ensureGroupLink(db);
26103
26648
  ensureMlsCustody(db);
26104
26649
  ensureToolGrantTables(db);
@@ -26179,6 +26724,19 @@ function ensureRuntimeReplyIntentEnvelopeGroup(db) {
26179
26724
  throw e;
26180
26725
  }
26181
26726
  }
26727
+ function ensureRuntimeReplyIntentCredit(db) {
26728
+ const hasColumn = () => db.prepare("PRAGMA table_info(runtime_reply_intent)").all().some((c) => c.name === "reply_credit");
26729
+ const cols = db.prepare("PRAGMA table_info(runtime_reply_intent)").all();
26730
+ if (cols.length === 0 || cols.some((c) => c.name === "reply_credit"))
26731
+ return;
26732
+ try {
26733
+ db.exec("ALTER TABLE runtime_reply_intent ADD COLUMN reply_credit TEXT");
26734
+ } catch (e) {
26735
+ if (hasColumn())
26736
+ return;
26737
+ throw e;
26738
+ }
26739
+ }
26182
26740
  var RECALL_STATE_COLUMNS = [
26183
26741
  ["recall_enabled", "INTEGER NOT NULL DEFAULT 0"],
26184
26742
  ["recall_mode_epoch", "INTEGER NOT NULL DEFAULT 0"],
@@ -27230,8 +27788,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27230
27788
  this.db.prepare("DELETE FROM runtime_reply_intent WHERE identity_id=? AND updated_at < ?").run(this.id, now - RUNTIME_REPLY_INTENT_RETENTION_MS);
27231
27789
  const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_reply_intent
27232
27790
  (identity_id, scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27233
- sealed_body, sealed_envelope, state, created_at, updated_at)
27234
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now);
27791
+ sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit)
27792
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now, input.replyCredit ?? null);
27235
27793
  const count = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_intent WHERE identity_id=?").get(this.id);
27236
27794
  if (count.n > RUNTIME_REPLY_INTENT_MAX) {
27237
27795
  this.db.prepare(`DELETE FROM runtime_reply_intent
@@ -27241,7 +27799,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27241
27799
  )`).run(this.id, this.id, RUNTIME_REPLY_INTENT_MAX);
27242
27800
  }
27243
27801
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27244
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
27802
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27245
27803
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, input.scope, input.part);
27246
27804
  if (!row)
27247
27805
  throw new Error("reply intent vanished after claim");
@@ -27250,7 +27808,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27250
27808
  /** SEC-06 #18: every intent answering `inReplyTo`, in claim order. */
27251
27809
  async listReplyIntents(inReplyTo) {
27252
27810
  const rows = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27253
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
27811
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27254
27812
  FROM runtime_reply_intent WHERE identity_id=? AND in_reply_to=?
27255
27813
  ORDER BY created_at ASC, rowid ASC`).all(this.id, inReplyTo);
27256
27814
  const out = [];
@@ -27261,7 +27819,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27261
27819
  /** SEC-06 #18: re-read one intent by its key (null when absent). */
27262
27820
  async getReplyIntent(scope, part) {
27263
27821
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27264
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
27822
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27265
27823
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, scope, part);
27266
27824
  return row ? this.openReplyIntent(row) : null;
27267
27825
  }
@@ -27335,7 +27893,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27335
27893
  envelope,
27336
27894
  envelopeGroupId: envelopeGroupFor(envelope, await openOrNull(row.sealed_envelope_group)),
27337
27895
  state: row.state === "sent" ? "sent" : row.state === "skipped" ? "skipped" : "pending",
27338
- createdAt: Number(row.created_at)
27896
+ createdAt: Number(row.created_at),
27897
+ replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null
27339
27898
  };
27340
27899
  }
27341
27900
  async recordMemory(input) {
@@ -29105,8 +29664,8 @@ function decodeReplyBody(bytes) {
29105
29664
  if (b?.kind === "a2a" && b.a2a && typeof b.a2a === "object") return b;
29106
29665
  throw new Error("reply intent body is malformed");
29107
29666
  }
29108
- function replyBodyMac(key, replyId, channelId, body) {
29109
- return createHmac("sha256", key).update(JSON.stringify(["reply-body", replyId, channelId])).update("\0").update(body).digest("hex");
29667
+ function replyBodyMac(key, replyId, channelId, body, credit) {
29668
+ return createHmac("sha256", key).update(JSON.stringify(credit === void 0 ? ["reply-body", replyId, channelId] : ["reply-body", replyId, channelId, credit])).update("\0").update(body).digest("hex");
29110
29669
  }
29111
29670
  function partOrdinal(part) {
29112
29671
  const m = /^p:(\d+)$/.exec(part);
@@ -34328,6 +34887,8 @@ function checkConfiguredPaths(skillsJson, statFn = (p) => {
34328
34887
  }
34329
34888
  const search = skillsJson.search;
34330
34889
  if (search?.keyFile) paths.push(search.keyFile);
34890
+ const weather = skillsJson.weather;
34891
+ if (weather?.keyFile) paths.push(weather.keyFile);
34331
34892
  return paths.map((path) => {
34332
34893
  let st;
34333
34894
  try {
@@ -34353,7 +34914,7 @@ function parseSecretPathsReport(raw) {
34353
34914
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
34354
34915
  const r = raw;
34355
34916
  const f = r.outside_by_field;
34356
- if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite)) return void 0;
34917
+ if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite) || f.weather_key_file !== void 0 && !isCount(f.weather_key_file)) return void 0;
34357
34918
  return {
34358
34919
  mode: r.mode,
34359
34920
  inside: r.inside,
@@ -34363,7 +34924,8 @@ function parseSecretPathsReport(raw) {
34363
34924
  headers_file: f.headers_file,
34364
34925
  postgres_url_file: f.postgres_url_file,
34365
34926
  search_key_file: f.search_key_file,
34366
- ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
34927
+ ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {},
34928
+ ...f.weather_key_file !== void 0 ? { weather_key_file: f.weather_key_file } : {}
34367
34929
  }
34368
34930
  };
34369
34931
  }
@@ -34443,6 +35005,7 @@ function readLocalReport(home, identityId) {
34443
35005
  has_search_key: hasSearchKey,
34444
35006
  has_llm_key: hasLlmKey,
34445
35007
  has_extract_provider: doc.has_extract_provider === true,
35008
+ has_weather_key: doc.has_weather_key === true,
34446
35009
  ...ownerDmCap !== void 0 ? { owner_dm_search_cap_effective: ownerDmCap } : {},
34447
35010
  legacy_memory: localLegacySources(doc).map(({ path: _path, content: _content, ...source }) => source),
34448
35011
  secret_paths: parseSecretPathsReport(doc.secret_paths)
@@ -34544,6 +35107,19 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
34544
35107
  return removed;
34545
35108
  }
34546
35109
 
35110
+ // src/reply-credit.ts
35111
+ var REPLY_CREDIT_FEATURE = "reply_credit_v1";
35112
+ var WEATHER_CREDIT_LINE = "Weather data: WeatherAPI.com. For general information only.";
35113
+ var WEATHER_CREDIT_SUFFIX = `
35114
+
35115
+ ${WEATHER_CREDIT_LINE}`;
35116
+ function isReplyCredit(v) {
35117
+ return v === "weatherapi";
35118
+ }
35119
+ function stripCreditSuffix(text) {
35120
+ return text.endsWith(WEATHER_CREDIT_SUFFIX) ? text.slice(0, text.length - WEATHER_CREDIT_SUFFIX.length) : text;
35121
+ }
35122
+
34547
35123
  // src/grants/binding.ts
34548
35124
  import { sha256 as sha25611 } from "@noble/hashes/sha256";
34549
35125
 
@@ -34632,9 +35208,10 @@ function loadOwnerGrantPin(opts) {
34632
35208
  // src/grants/manifest.ts
34633
35209
  var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
34634
35210
  var MANIFEST_KNOWN_TOOLS = Object.freeze({
34635
- owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query"]),
35211
+ owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query", "weather_forecast", "currency_convert"]),
34636
35212
  // web_extract: owner DM only, never team_room (spec §5.4, D5)
34637
- team_room: Object.freeze(["web_search"])
35213
+ // Weather/currency spec §6.3 (U5): both utility tools in both audiences, each held behind utility_tools_v1 (TOOL_FEATURE).
35214
+ team_room: Object.freeze(["web_search", "weather_forecast", "currency_convert"])
34638
35215
  });
34639
35216
  function manifestSignedBytes(manifest) {
34640
35217
  const domain = new TextEncoder().encode(TOOL_GRANTS_DOMAIN);
@@ -34958,20 +35535,30 @@ function bindTargets(raw, signed, equals) {
34958
35535
  }
34959
35536
  return { written, mismatches };
34960
35537
  }
34961
- function signedManifestTools(manifest, audience, searchProvidersCapable) {
34962
- return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && (t !== "web_extract" || searchProvidersCapable));
35538
+ var NO_FEATURES = Object.freeze(/* @__PURE__ */ new Set());
35539
+ var TOOL_FEATURE = Object.freeze({
35540
+ web_extract: "search_providers_v2",
35541
+ weather_forecast: "utility_tools_v1",
35542
+ currency_convert: "utility_tools_v1"
35543
+ });
35544
+ function toolAllowedByFeatures(tool, features) {
35545
+ if (!Object.hasOwn(TOOL_FEATURE, tool)) return true;
35546
+ return features.has(TOOL_FEATURE[tool]);
35547
+ }
35548
+ function signedManifestTools(manifest, audience, features) {
35549
+ return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && toolAllowedByFeatures(t, features));
34963
35550
  }
34964
- function manifestPolicyFor(manifest, switches, searchProvidersCapable) {
35551
+ function manifestPolicyFor(manifest, switches, features) {
34965
35552
  return {
34966
35553
  v: 1,
34967
35554
  manifest_tools: [.../* @__PURE__ */ new Set([
34968
- ...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", searchProvidersCapable) : [],
34969
- ...switches.room ? signedManifestTools(manifest, "team_room", searchProvidersCapable) : []
35555
+ ...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", features) : [],
35556
+ ...switches.room ? signedManifestTools(manifest, "team_room", features) : []
34970
35557
  ])].sort(),
34971
35558
  secret_path_mode: "enforce"
34972
35559
  };
34973
35560
  }
34974
- function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, searchProvidersCapable = false) {
35561
+ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, features = NO_FEATURES) {
34975
35562
  const mismatches = /* @__PURE__ */ new Set();
34976
35563
  const cfg = manifest.config;
34977
35564
  const out = {};
@@ -35000,7 +35587,8 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35000
35587
  for (const name of Object.keys(files)) {
35001
35588
  if (name !== LLM_SOURCE && !PERSONA_FILES.includes(name)) mismatches.add("files");
35002
35589
  }
35003
- const ownerDmTools = signedManifestTools(manifest, "owner_dm", searchProvidersCapable);
35590
+ const ownerDmTools = signedManifestTools(manifest, "owner_dm", features);
35591
+ const ownerDmSearchTools = ownerDmTools.filter((t) => t === "web_search" || t === "web_extract");
35004
35592
  const sj = isObj2(payload.skills_json) ? payload.skills_json : void 0;
35005
35593
  const eps = bindTargets(sj?.endpoints, manifest.endpoints, endpointEquals);
35006
35594
  const dbs = bindTargets(sj?.databases, manifest.databases, databaseEquals);
@@ -35021,17 +35609,20 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35021
35609
  const usablePrior = cfg.search !== void 0 && prior.search?.provider === cfg.search.provider ? prior.search : void 0;
35022
35610
  searchBlock = cfg.search === void 0 ? void 0 : usablePrior ?? Object.freeze({ provider: cfg.search.provider });
35023
35611
  }
35612
+ const pw = isObj2(sj?.weather) ? sj.weather : void 0;
35613
+ const weatherBlock = typeof pw?.keyFile === "string" && pw.keyFile !== "" ? { keyFile: pw.keyFile } : void 0;
35024
35614
  const skillsDoc = {
35025
35615
  endpoints: eps.written,
35026
35616
  databases: dbs.written,
35027
35617
  flags: [...cfg.flags],
35028
35618
  ...deny !== void 0 || cfg.tools_allow !== void 0 ? { tools: { ...deny !== void 0 ? { deny } : {}, ...cfg.tools_allow !== void 0 ? { allow: [...cfg.tools_allow] } : {} } } : {},
35029
35619
  ...searchBlock ? { search: searchBlock } : {},
35620
+ ...weatherBlock ? { weather: weatherBlock } : {},
35030
35621
  // D31: the marker the runtime keys D24 (iii) on; written under every manifest, true when config.search is signed.
35031
35622
  search_signed: cfg.search !== void 0,
35032
35623
  // D36, D39: the signed owner-DM cap, only when the owner-DM switch is on and the manifest grants an owner-DM tool;
35033
35624
  // otherwise none, so the runtime clamps a signed raise to the default. Never read from the payload.
35034
- ...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
35625
+ ...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmSearchTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
35035
35626
  };
35036
35627
  out["skills.json"] = JSON.stringify(skillsDoc, null, 2) + "\n";
35037
35628
  const signedMemory = { mode: cfg.memory.mode, ...cfg.memory.recall_days !== void 0 ? { recall_days: cfg.memory.recall_days } : {} };
@@ -35062,7 +35653,7 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35062
35653
  mismatches.add("legacy_assign");
35063
35654
  legacyDecisions = decisions.filter((d) => isObj2(d) && d.action === "delete");
35064
35655
  }
35065
- const policy = manifestPolicyFor(manifest, switches, searchProvidersCapable);
35656
+ const policy = manifestPolicyFor(manifest, switches, features);
35066
35657
  return {
35067
35658
  mismatches: CONFIG_BINDING_FIELDS.filter((f) => mismatches.has(f)).sort(),
35068
35659
  targetMismatches: { endpoint: eps.mismatches, database: dbs.mismatches },
@@ -35205,7 +35796,11 @@ var TOOL_ROOM_CLASSES = Object.freeze({
35205
35796
  // D5: spends the owner's key and sends URLs out
35206
35797
  fetch_url: Object.freeze(["egress"]),
35207
35798
  sql_query: Object.freeze(["owner_credential"]),
35208
- http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
35799
+ http_request: Object.freeze(["owner_credential", "egress", "side_effect"]),
35800
+ // Weather/currency (plan audit r1; supersedes spec §8's values): both send room words to an outside host, and weather
35801
+ // spends the owner's key.
35802
+ weather_forecast: Object.freeze(["owner_credential", "egress"]),
35803
+ currency_convert: Object.freeze(["egress"])
35209
35804
  });
35210
35805
  var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
35211
35806
  function isLiveRow(r) {
@@ -37019,7 +37614,7 @@ var BridgeAgent = class _BridgeAgent {
37019
37614
  }
37020
37615
  let bound;
37021
37616
  try {
37022
- bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable());
37617
+ bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures());
37023
37618
  } catch (e) {
37024
37619
  bound = null;
37025
37620
  return this.bindFailed(manifest, payload.memory, e);
@@ -37040,7 +37635,7 @@ var BridgeAgent = class _BridgeAgent {
37040
37635
  try {
37041
37636
  this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, bound.files);
37042
37637
  if (manifest.config.llm === void 0) this.grantWriters.removeManagedLlmSource(this.home, this.cfg.identityId);
37043
- this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable()));
37638
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures()));
37044
37639
  } catch (e) {
37045
37640
  this.manifestBinding = null;
37046
37641
  throw e;
@@ -37094,6 +37689,7 @@ var BridgeAgent = class _BridgeAgent {
37094
37689
  async postRuntimeConfigReport(base, version, skillsJson) {
37095
37690
  const local = readLocalReport(this.home, this.cfg.identityId);
37096
37691
  const reportedSearchProviders = this.searchProvidersCapable();
37692
+ const reportedUtilityTools = this.utilityToolsCapable();
37097
37693
  const report = {
37098
37694
  applied_version: version,
37099
37695
  paths: checkConfiguredPaths(skillsJson),
@@ -37111,6 +37707,9 @@ var BridgeAgent = class _BridgeAgent {
37111
37707
  // Final review I-2 (controller ruling): AND the current search_providers_v2, so the web never shows extract while
37112
37708
  // the bridge withholds web_extract from the runtime.
37113
37709
  has_extract_provider: (local?.has_extract_provider ?? false) && reportedSearchProviders,
37710
+ // Weather/currency L-1, §8 item 5: AND the current utility_tools_v1, so the web never shows a usable weather key
37711
+ // while the bridge withholds weather_forecast from the runtime.
37712
+ has_weather_key: (local?.has_weather_key ?? false) && reportedUtilityTools,
37114
37713
  // D39: the owner-DM daily cap the runtime enforces, passed through when it reported a valid one; never the signed value.
37115
37714
  ...local?.owner_dm_search_cap_effective !== void 0 ? { owner_dm_search_cap_effective: local.owner_dm_search_cap_effective } : {},
37116
37715
  // Same idea, for the BYOK LLM key file: only the runtime can see
@@ -37158,7 +37757,9 @@ var BridgeAgent = class _BridgeAgent {
37158
37757
  // D28 (§11.1 step 3): a separate key, never a value in the capability enum. False with no runtime attached.
37159
37758
  search_providers_v2: reportedSearchProviders,
37160
37759
  // D19 item 4: content-free; the signed provider is one the attached runtime(s) cannot honour.
37161
- search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders)
37760
+ search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders),
37761
+ // Weather/currency §8 item 5: a separate key, false with no runtime attached.
37762
+ utility_tools_v1: reportedUtilityTools
37162
37763
  }
37163
37764
  };
37164
37765
  const reportedVerifyFailures = this.ownerVerifyFailures;
@@ -37180,6 +37781,7 @@ var BridgeAgent = class _BridgeAgent {
37180
37781
  }
37181
37782
  this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
37182
37783
  this.lastReportedSearchProviders = reportedSearchProviders;
37784
+ this.lastReportedUtilityTools = reportedUtilityTools;
37183
37785
  if (version) writeServiceLog2("info", "runtime_config.applied", { version });
37184
37786
  else writeServiceLog2("info", "runtime_config.legacy_memory_reported");
37185
37787
  }
@@ -37249,7 +37851,7 @@ var BridgeAgent = class _BridgeAgent {
37249
37851
  this.startedAt = Date.now();
37250
37852
  await this.restoreRuntimeInbox();
37251
37853
  await this.loadToolGrantState();
37252
- this.recomputeManifestPolicy(false);
37854
+ this.recomputeManifestPolicy(NO_FEATURES);
37253
37855
  try {
37254
37856
  this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
37255
37857
  } catch (e) {
@@ -38232,13 +38834,13 @@ var BridgeAgent = class _BridgeAgent {
38232
38834
  * absent, because the id is derived.
38233
38835
  */
38234
38836
  async send(channelId, text, messageId, opts = {}) {
38235
- const body = { kind: "text", text };
38837
+ const body = { kind: "text", text: opts.credit !== void 0 ? text + WEATHER_CREDIT_SUFFIX : text };
38236
38838
  if (this.isIdempotentSend(messageId, opts)) return this.trackReplyWork(this.sendIdempotent(channelId, body, opts));
38237
38839
  const id = resolveMessageId(messageId);
38238
38840
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
38239
38841
  const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
38240
38842
  await this.publishBody(channelId, body, id);
38241
- this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
38843
+ this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, text);
38242
38844
  return { message_id: id, status: "sent" };
38243
38845
  }
38244
38846
  /** PR #716 review MEDIUM-5: register reply work so close() can wait for it. */
@@ -38257,7 +38859,7 @@ var BridgeAgent = class _BridgeAgent {
38257
38859
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
38258
38860
  const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
38259
38861
  await this.publishBody(channelId, body, id);
38260
- this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
38862
+ this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, null);
38261
38863
  return { message_id: id, status: "sent" };
38262
38864
  }
38263
38865
  /**
@@ -38390,18 +38992,24 @@ var BridgeAgent = class _BridgeAgent {
38390
38992
  const meta = this.freshMeta(capChannel);
38391
38993
  if (meta && !meta.is_dm) this.enforceRateFloor("room", capChannel);
38392
38994
  }
38393
- /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
38394
- afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture) {
38995
+ /**
38996
+ * Bookkeeping after a CONFIRMED publish of `body` under `id`. `recordText` (weather/currency Task 17) is what a text
38997
+ * reply is RECORDED as: the published text minus any credit line. Every record site below reads `recorded`, never
38998
+ * `body.text`. Every production caller passes it (pinned by reply-credit.test.ts: text, or null for an a2a body);
38999
+ * undefined is left only for direct test calls of an uncredited body, where the published and record texts are equal.
39000
+ */
39001
+ afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture, recordText) {
39002
+ const recorded = recordText ?? (recordText === void 0 && body.kind === "text" ? body.text : "");
38395
39003
  if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
38396
39004
  const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
38397
- const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(body.text);
39005
+ const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(recorded);
38398
39006
  if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
38399
- if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(body.text) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
38400
- this.callBuffer.appendAgent(channelId, body.text, "reply");
39007
+ if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(recorded) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
39008
+ this.callBuffer.appendAgent(channelId, recorded, "reply");
38401
39009
  }
38402
39010
  const origin = this.spokenReplyIds.has(id) ? "fast_path" : !this.taintScope(channelId) ? "reply" : opts.inReplyTo !== void 0 && this.cleanTurns.has(opts.inReplyTo) && !this.taintedTurns.has(opts.inReplyTo) ? "reply" : "untrusted";
38403
- if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, body.text, id, modality, purgeEpoch, origin);
38404
- if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, body.text, id, replyCapture ?? null, origin);
39011
+ if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, recorded, id, modality, purgeEpoch, origin);
39012
+ if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, recorded, id, replyCapture ?? null, origin);
38405
39013
  }
38406
39014
  /**
38407
39015
  * Final review bridge I-1 (ledger T25): the agent reply's U11 capture, taken BEFORE the publish await. The clear generation
@@ -38479,14 +39087,14 @@ var BridgeAgent = class _BridgeAgent {
38479
39087
  const k = replyIntentKey({ inReplyTo: opts.inReplyTo, idempotencyKey: opts.idempotencyKey, ordinal: () => 0 });
38480
39088
  if (!k) throw new Error("idempotent send without a key");
38481
39089
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
38482
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
38483
- const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
39090
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit);
39091
+ const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null });
38484
39092
  if (intent.bodyMac !== mac2 || intent.channelId !== channelId || intent.state === "skipped") {
38485
39093
  throw new IdempotencyConflictError();
38486
39094
  }
38487
39095
  return k;
38488
39096
  }
38489
- return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true);
39097
+ return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null);
38490
39098
  });
38491
39099
  return this.publishIntent(key, opts);
38492
39100
  }
@@ -38495,22 +39103,23 @@ var BridgeAgent = class _BridgeAgent {
38495
39103
  * the reply to `inReplyTo`. Caller holds the scope lock. `matchRetry` false
38496
39104
  * after a slot collision: the body must go to a NEW part.
38497
39105
  */
38498
- async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry) {
39106
+ async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit) {
39107
+ const credit = replyCredit ?? void 0;
38499
39108
  const scope = `reply:${inReplyTo}`;
38500
39109
  let latest = null;
38501
39110
  for (const intent of await this.store.listReplyIntents(inReplyTo)) {
38502
39111
  const n = intent.scope === scope ? partOrdinal(intent.part) : null;
38503
39112
  if (n !== null && (latest === null || n > latest.ordinal)) latest = { ordinal: n, intent };
38504
39113
  }
38505
- if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes)) {
39114
+ if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes, credit)) {
38506
39115
  return { scope, part: latest.intent.part, inReplyTo };
38507
39116
  }
38508
39117
  let ordinal = latest ? latest.ordinal + 1 : 0;
38509
39118
  for (let i = 0; i < REPLY_MAX_PART_ADVANCES; i++, ordinal++) {
38510
39119
  const k = { scope, part: `p:${ordinal}`, inReplyTo };
38511
39120
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
38512
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
38513
- const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
39121
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit);
39122
+ const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit });
38514
39123
  if (created) return k;
38515
39124
  if (matchRetry && intent.state !== "skipped" && intent.bodyMac === mac2 && intent.channelId === channelId) return k;
38516
39125
  writeServiceLog2("info", "reply.part_slot_taken", { attempt: i + 1 });
@@ -38555,6 +39164,12 @@ var BridgeAgent = class _BridgeAgent {
38555
39164
  }
38556
39165
  const bytes = intent.body;
38557
39166
  const body = decodeReplyBody(bytes);
39167
+ const recordText = this.recordTextOf(intent.replyCredit, body);
39168
+ if (recordText === void 0) {
39169
+ writeServiceLog2("error", "reply.intent_integrity_refused");
39170
+ this.store.deletePendingReplyIntent(key.scope, key.part);
39171
+ throw new ReplyIntentIntegrityError();
39172
+ }
38558
39173
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(intent.channelId);
38559
39174
  const replyCapture = this.unifiedReplyCapture(intent.channelId, intent.inReplyTo ?? void 0);
38560
39175
  try {
@@ -38583,14 +39198,17 @@ var BridgeAgent = class _BridgeAgent {
38583
39198
  const inReplyTo = intent.inReplyTo;
38584
39199
  const next = await this.withReplyLock(
38585
39200
  JSON.stringify(["scope", key.scope]),
38586
- () => this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false)
39201
+ () => (
39202
+ // r2 BLOCKER 1: the colliding row's OWN credit (verified above), never `opts`.
39203
+ this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null)
39204
+ )
38587
39205
  );
38588
39206
  return this.publishIntent(next, opts, advances + 1);
38589
39207
  }
38590
39208
  throw e;
38591
39209
  }
38592
39210
  this.store.markReplyIntentSent(key.scope, key.part);
38593
- this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture);
39211
+ this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture, recordText);
38594
39212
  return { message_id: intent.replyMessageId, status: "sent" };
38595
39213
  });
38596
39214
  }
@@ -38600,9 +39218,21 @@ var BridgeAgent = class _BridgeAgent {
38600
39218
  */
38601
39219
  intentVerifies(intent) {
38602
39220
  if (!intent.body) return false;
39221
+ const credit = intent.replyCredit ?? null;
39222
+ if (credit !== null && !isReplyCredit(credit)) return false;
38603
39223
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, intent.channelId, intent);
38604
39224
  if (id !== intent.replyMessageId) return false;
38605
- return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body) === intent.bodyMac;
39225
+ return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0) === intent.bodyMac;
39226
+ }
39227
+ /**
39228
+ * Weather/currency Task 17: what a verified row's reply is RECORDED as. Null credit: the decoded text unchanged (null
39229
+ * for an a2a body). "weatherapi": the text minus the exact credit suffix. Undefined: the row is refused (an unknown
39230
+ * credit, a credited a2a body, or a credited text without the exact suffix).
39231
+ */
39232
+ recordTextOf(credit, body) {
39233
+ if (credit === null) return body.kind === "text" ? body.text : null;
39234
+ if (!isReplyCredit(credit) || body.kind !== "text" || !body.text.endsWith(WEATHER_CREDIT_SUFFIX)) return void 0;
39235
+ return body.text.slice(0, body.text.length - WEATHER_CREDIT_SUFFIX.length);
38606
39236
  }
38607
39237
  /**
38608
39238
  * SEC-06 #18 item 3: a rate-limited reply retries the PUBLISH in-process
@@ -40009,6 +40639,12 @@ var BridgeAgent = class _BridgeAgent {
40009
40639
  * frozen at receipt) AND a window-eligible turn (voiceWindowEligible), whatever the window size. Two call sites: the voice
40010
40640
  * 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;
40011
40641
  * otherwise nothing is written (H-4). Logs are content-free (U17). Never throws.
40642
+ *
40643
+ * Bridge 0.33.1 (architect Q2, C5 not accepted): a turn on a voice channel V carries `parent`, its P from the
40644
+ * per-frame verdict. The scope is ownerScopeForWrite(P) (P's pin and roster, as for a turn on P), the item keeps
40645
+ * channelId = V (so V's own capture still guards it in the store), and P's receipt capture is checked at commit,
40646
+ * inside the memory mutex: a purge or room clear of P since receipt writes nothing. No P capture at receipt, or no
40647
+ * persisted V -> P link (the purge of P reaches V's items only through it): nothing is written.
40012
40648
  */
40013
40649
  async tapUnifiedVoice(m, c) {
40014
40650
  const skip = (reason) => this.logUnifiedTapSkip(reason);
@@ -40017,9 +40653,22 @@ var BridgeAgent = class _BridgeAgent {
40017
40653
  if (m.authorization?.relay_speaker !== "owner" || m.speaker === void 0) return skip("audience");
40018
40654
  if (c.stamp === void 0) return skip("stamp_missing");
40019
40655
  if (c.capture.purgeEpoch < 0) return skip("purged");
40020
- const scopeId = await this.ownerScopeForWrite(m.channel_id);
40656
+ const parent3 = c.parent;
40657
+ const parentCapture = parent3?.capture;
40658
+ if (parent3 === void 0 && this.voiceChannels.isVoice(m.channel_id)) return skip("scope");
40659
+ if (parent3 !== void 0) {
40660
+ if (parentCapture === void 0 || parentCapture.purgeEpoch < 0) return skip("scope");
40661
+ if (this.store.voiceChannelParent(m.channel_id) !== parent3.channelId) return skip("scope");
40662
+ }
40663
+ const scopeId = await this.ownerScopeForWrite(parent3?.channelId ?? m.channel_id);
40021
40664
  if (scopeId === null) return skip("scope");
40022
40665
  if (parseOwnerDmScopeId(scopeId)?.ownerIdentityId !== m.speaker.identityId) return skip("scope");
40666
+ const precommit = parent3 === void 0 || parentCapture === void 0 ? void 0 : () => {
40667
+ const now = this.store.itemCapture(parent3.channelId);
40668
+ if (now.purgeEpoch !== parentCapture.purgeEpoch) return "purged";
40669
+ if (now.roomClearGeneration !== parentCapture.roomClearGeneration) return "room_cleared";
40670
+ return null;
40671
+ };
40023
40672
  const r = await this.items.commit({
40024
40673
  scopeKind: "owner_dm",
40025
40674
  scopeId,
@@ -40030,7 +40679,8 @@ var BridgeAgent = class _BridgeAgent {
40030
40679
  source: "owner_voice",
40031
40680
  trust: OWNER_VOICE_TRUST,
40032
40681
  text: typeof m.text === "string" ? m.text : "",
40033
- expect: { ...c.capture, clearGeneration: c.stamp }
40682
+ expect: { ...c.capture, clearGeneration: c.stamp },
40683
+ ...precommit ? { precommit } : {}
40034
40684
  });
40035
40685
  return r.ok ? { ok: true } : { ok: false, reason: r.reason };
40036
40686
  } catch {
@@ -42209,11 +42859,20 @@ var BridgeAgent = class _BridgeAgent {
42209
42859
  const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
42210
42860
  const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
42211
42861
  const recallModeEpochAtReceipt = this.store.recallState().modeEpoch;
42862
+ const unifiedVoiceParentAt = (p) => {
42863
+ const cap = itemCaptureAtReceipt.get(p);
42864
+ return { channelId: p, capture: cap === void 0 ? void 0 : { ...cap, purgeEpoch: purgeEpochAtReceipt.get(p) ?? -1 } };
42865
+ };
42212
42866
  for (const m of entries) {
42213
42867
  this.lastInboundAt.set(m.channelId, receivedAt);
42214
42868
  if (m.voiceMode === true) this.heartbeat.noteVoiceActivity();
42215
42869
  if (!purgeEpochAtReceipt.has(m.channelId)) purgeEpochAtReceipt.set(m.channelId, this.store.conversationPurgeEpoch(m.channelId));
42216
42870
  if (this.unifiedOn && !itemCaptureAtReceipt.has(m.channelId)) itemCaptureAtReceipt.set(m.channelId, this.store.itemCapture(m.channelId));
42871
+ const receiptParent = this.voiceChannels.parentOf(m.channelId);
42872
+ if (receiptParent !== void 0) {
42873
+ if (!purgeEpochAtReceipt.has(receiptParent)) purgeEpochAtReceipt.set(receiptParent, this.store.conversationPurgeEpoch(receiptParent));
42874
+ if (this.unifiedOn && !itemCaptureAtReceipt.has(receiptParent)) itemCaptureAtReceipt.set(receiptParent, this.store.itemCapture(receiptParent));
42875
+ }
42217
42876
  this.recallReceipt.set(m.messageId, { purgeEpoch: purgeEpochAtReceipt.get(m.channelId), modeEpoch: recallModeEpochAtReceipt });
42218
42877
  receiptIds.push(m.messageId);
42219
42878
  const stamp = voiceStamp.get(m.channelId);
@@ -42559,6 +43218,7 @@ var BridgeAgent = class _BridgeAgent {
42559
43218
  pending: Promise.resolve(),
42560
43219
  purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
42561
43220
  itemCapture: itemCaptureAtReceipt.get(m.channelId),
43221
+ ...voiceParent !== void 0 ? { voiceParent: unifiedVoiceParentAt(voiceParent) } : {},
42562
43222
  bufferGeneration
42563
43223
  };
42564
43224
  this.voiceCoalescing.set(windowKey, win);
@@ -42589,7 +43249,7 @@ var BridgeAgent = class _BridgeAgent {
42589
43249
  const merged = { ...held, text, memory: windowMemory };
42590
43250
  if (this.unifiedOn) {
42591
43251
  const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
42592
- const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
43252
+ const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 }, ...win.voiceParent ? { parent: win.voiceParent } : {} });
42593
43253
  this.unifiedWork.add(work);
42594
43254
  void work.finally(() => this.unifiedWork.delete(work));
42595
43255
  }
@@ -42679,7 +43339,11 @@ var BridgeAgent = class _BridgeAgent {
42679
43339
  );
42680
43340
  if (windowEligible && turnAuthorization.relay_speaker === "owner") {
42681
43341
  const snap = itemCaptureAtReceipt.get(m.channelId) ?? { ...this.store.itemCapture(m.channelId), purgeEpoch: -1 };
42682
- const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), { stamp: this.ingestGeneration.get(m.messageId), capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 } });
43342
+ const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
43343
+ stamp: this.ingestGeneration.get(m.messageId),
43344
+ capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 },
43345
+ ...voiceParent !== void 0 ? { parent: unifiedVoiceParentAt(voiceParent) } : {}
43346
+ });
42683
43347
  this.unifiedWork.add(work);
42684
43348
  void work.finally(() => this.unifiedWork.delete(work));
42685
43349
  }
@@ -42692,7 +43356,7 @@ var BridgeAgent = class _BridgeAgent {
42692
43356
  this.memory,
42693
43357
  m.channelId,
42694
43358
  m.envelope.sender_id,
42695
- parsed.text,
43359
+ m.envelope.sender_id === this.cfg.identityId ? stripCreditSuffix(parsed.text) : parsed.text,
42696
43360
  m.messageId,
42697
43361
  m.voiceMode ? "voice" : "text",
42698
43362
  purgeEpochAtTap
@@ -43490,21 +44154,49 @@ ${channelId}`;
43490
44154
  */
43491
44155
  searchProvidersSockets = /* @__PURE__ */ new Map();
43492
44156
  /**
43493
- * The D28 value the files were last recomputed for; null until the first socket observation, so that observation
43494
- * always recomputes (final review I-1), even when it is non-capable.
44157
+ * Weather/currency U6 (H-1): the same AND-over-sockets state for utility_tools_v1, kept separately per socket, so a
44158
+ * runtime that declares search_providers_v2 but not utility_tools_v1 (0.14.0, 0.14.1) keeps web_extract and never
44159
+ * sees a utility name.
44160
+ */
44161
+ utilityToolsSockets = /* @__PURE__ */ new Map();
44162
+ /**
44163
+ * The (search, utility) tuple the files were last recomputed for; null until the first socket observation, so that
44164
+ * observation always recomputes (final review I-1), even when it is non-capable. One trigger keyed on BOTH values.
43495
44165
  */
43496
- searchProvidersState = null;
44166
+ capabilityState = null;
43497
44167
  searchCapabilityRefetchTimer = null;
43498
44168
  /** When the current burst's first change happened (valid while the timer is set). */
43499
44169
  searchCapabilityBurstStart = 0;
43500
44170
  /** The D28 value in the last report posted (null until one is); a change needs a refetch to reach the web. */
43501
44171
  lastReportedSearchProviders = null;
44172
+ /** U6: the utility_tools_v1 value in the last report posted (null until one is). */
44173
+ lastReportedUtilityTools = null;
43502
44174
  noteSearchProvidersSocket(token, declared) {
43503
44175
  this.searchProvidersSockets.set(token, declared);
43504
- this.onSearchProvidersMaybeChanged();
44176
+ this.onCapabilityMaybeChanged();
43505
44177
  }
43506
44178
  clearSearchProvidersSocket(token) {
43507
- if (this.searchProvidersSockets.delete(token)) this.onSearchProvidersMaybeChanged();
44179
+ if (this.searchProvidersSockets.delete(token)) this.onCapabilityMaybeChanged();
44180
+ }
44181
+ noteUtilityToolsSocket(token, declared) {
44182
+ this.utilityToolsSockets.set(token, declared);
44183
+ this.onCapabilityMaybeChanged();
44184
+ }
44185
+ clearUtilityToolsSocket(token) {
44186
+ if (this.utilityToolsSockets.delete(token)) this.onCapabilityMaybeChanged();
44187
+ }
44188
+ /** U6: every attached runtime socket declared utility_tools_v1; no socket attached means absent (false). */
44189
+ utilityToolsCapable() {
44190
+ if (this.utilityToolsSockets.size === 0) return false;
44191
+ for (const v of this.utilityToolsSockets.values()) if (!v) return false;
44192
+ return true;
44193
+ }
44194
+ /** U6: the features every attached runtime socket declared, for the policy functions. */
44195
+ capabilityFeatures() {
44196
+ const out = /* @__PURE__ */ new Set();
44197
+ if (this.searchProvidersCapable()) out.add("search_providers_v2");
44198
+ if (this.utilityToolsCapable()) out.add("utility_tools_v1");
44199
+ return out;
43508
44200
  }
43509
44201
  /** D28: every attached runtime socket declared search_providers_v2; no socket attached means absent (false). */
43510
44202
  searchProvidersCapable() {
@@ -43531,14 +44223,14 @@ ${channelId}`;
43531
44223
  * D35 (P-4): on a capability change, rewrite manifest-policy.json at once from the stored manifest, the kill
43532
44224
  * switches and the new capability, then a debounced refetch (one per burst) only when needed.
43533
44225
  */
43534
- onSearchProvidersMaybeChanged() {
43535
- const capable = this.searchProvidersCapable();
43536
- const prev = this.searchProvidersState;
43537
- if (capable === prev) return;
43538
- this.searchProvidersState = capable;
43539
- this.recomputeManifestPolicy(capable);
44226
+ onCapabilityMaybeChanged() {
44227
+ const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable() };
44228
+ const prev = this.capabilityState;
44229
+ if (prev !== null && prev.search === next.search && prev.utility === next.utility) return;
44230
+ this.capabilityState = next;
44231
+ this.recomputeManifestPolicy(this.capabilityFeatures());
43540
44232
  if (this.closingDown) return;
43541
- if (prev === null && !capable) return;
44233
+ if (prev === null && !next.search && !next.utility) return;
43542
44234
  const now = Date.now();
43543
44235
  if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
43544
44236
  else this.searchCapabilityBurstStart = now;
@@ -43556,7 +44248,7 @@ ${channelId}`;
43556
44248
  searchCapabilityRefetchNeeded() {
43557
44249
  if (!this.bootConfigSettled || this.closingDown) return false;
43558
44250
  const inForce = (this.cfg.ownerGrantPin?.pin ?? null) !== null && this.toolGrantManifest !== null;
43559
- return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable();
44251
+ return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable();
43560
44252
  }
43561
44253
  /**
43562
44254
  * D35: the local recompute. Widening (web_extract back in) only while the binding for the in-force manifest stands,
@@ -43564,15 +44256,16 @@ ${channelId}`;
43564
44256
  * lists web_extract loses it at once, so an older runtime never reads it as unreadable (which drops every grant).
43565
44257
  * Content-free; a write failure is logged and the debounced refetch tries again.
43566
44258
  */
43567
- recomputeManifestPolicy(capable) {
44259
+ recomputeManifestPolicy(features) {
43568
44260
  const pin = this.cfg.ownerGrantPin?.pin ?? null;
43569
44261
  const manifest = pin !== null ? this.toolGrantManifest : null;
43570
44262
  try {
43571
44263
  if (manifest !== null && this.manifestBinding !== null && this.manifestBinding.counter === manifest.counter) {
43572
- this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, capable));
44264
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, features));
43573
44265
  return;
43574
44266
  }
43575
- if (capable) return;
44267
+ const withheld = new Set(Object.keys(TOOL_FEATURE).filter((t) => !toolAllowedByFeatures(t, features)));
44268
+ if (withheld.size === 0) return;
43576
44269
  const path = join17(runtimeConfigDir(this.home, this.cfg.identityId), MANIFEST_POLICY_FILE);
43577
44270
  if (!existsSync5(path)) return;
43578
44271
  let doc;
@@ -43582,10 +44275,10 @@ ${channelId}`;
43582
44275
  return;
43583
44276
  }
43584
44277
  const tools = doc?.manifest_tools;
43585
- if (!Array.isArray(tools) || !tools.includes("web_extract")) return;
44278
+ if (!Array.isArray(tools) || !tools.some((t) => typeof t === "string" && withheld.has(t))) return;
43586
44279
  this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, {
43587
44280
  v: 1,
43588
- manifest_tools: tools.filter((t) => typeof t === "string" && t !== "web_extract"),
44281
+ manifest_tools: tools.filter((t) => typeof t === "string" && !withheld.has(t)),
43589
44282
  secret_path_mode: "enforce"
43590
44283
  });
43591
44284
  } catch (e) {
@@ -44122,6 +44815,9 @@ var BRIDGE_ERRORS = {
44122
44815
  // C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
44123
44816
  // session (WS 4409), so this copy stopped. Every local route but /healthz.
44124
44817
  session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
44818
+ // Weather/currency Task 17 (OQ-P1): a send frame's credit is not the closed value, or rides an a2a frame. TERMINAL:
44819
+ // the inbound is retired, so the model (and the weather call) never runs again for it.
44820
+ reply_credit_unknown: { http: 400, message: "reply credit not recognised; not sent" },
44125
44821
  internal: { http: 500, message: "internal error" }
44126
44822
  };
44127
44823
  function errorBody(code, detail) {
@@ -44151,6 +44847,7 @@ function withConsumerGrants(m, perToolConsumer) {
44151
44847
  }
44152
44848
  var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
44153
44849
  var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
44850
+ var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
44154
44851
  var CONSTANT_TOOL_NAMES = Object.freeze({
44155
44852
  owner_dm: Object.freeze(["current_time", "web_search"]),
44156
44853
  team_room: Object.freeze(["current_time"])
@@ -44182,6 +44879,15 @@ function withSearchCapability(m, consumerHasFeature, signedProvider) {
44182
44879
  if (kept.length === a.tool_grants.length) return m;
44183
44880
  return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
44184
44881
  }
44882
+ var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
44883
+ function withUtilityCapability(m, consumerHasFeature) {
44884
+ if (consumerHasFeature) return m;
44885
+ const a = m.authorization;
44886
+ if (!a) return m;
44887
+ const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
44888
+ if (kept.length === a.tool_grants.length) return m;
44889
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
44890
+ }
44185
44891
 
44186
44892
  // src/http-server.ts
44187
44893
  function writeSse(res, msgs, onWriteError) {
@@ -44205,7 +44911,7 @@ data: ${JSON.stringify(msgs[i])}
44205
44911
  function deliverSseBatch(res, batch, agent, onWriteError) {
44206
44912
  const withheld = agent.harnessGrantsWithheld();
44207
44913
  const provider = agent.signedSearchProvider();
44208
- const left3 = writeSse(res, batch.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)), onWriteError);
44914
+ const left3 = writeSse(res, batch.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)), onWriteError);
44209
44915
  const leftIds = new Set(left3.map((message) => message.message_id));
44210
44916
  agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
44211
44917
  if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
@@ -44237,7 +44943,7 @@ var OTHER_CAP = 8 * 1024;
44237
44943
  var a2aSchema = z3.object({
44238
44944
  a2a_version: z3.string().min(1),
44239
44945
  kind: z3.enum(["message", "task", "artifact"]),
44240
- object: z3.record(z3.unknown())
44946
+ object: z3.record(z3.string(), z3.unknown())
44241
44947
  });
44242
44948
  var sendAttachmentSchema = z3.object({
44243
44949
  channel_id: z3.string().min(1),
@@ -44479,7 +45185,7 @@ function createHttpServer(agent, opts) {
44479
45185
  const drained = await agent.drain(channel, wait);
44480
45186
  const withheld = agent.harnessGrantsWithheld();
44481
45187
  const provider = agent.signedSearchProvider();
44482
- return json(res, 200, drained.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)));
45188
+ return json(res, 200, drained.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)));
44483
45189
  }
44484
45190
  if (method === "GET" && path === "/v1/events") {
44485
45191
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -44654,6 +45360,7 @@ function attachAgentWebSocket(server, agent, opts) {
44654
45360
  perToolGrants: false,
44655
45361
  manifestGrants: false,
44656
45362
  searchProviders: false,
45363
+ utilityTools: false,
44657
45364
  // helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
44658
45365
  deliveryOpen: helloWaitMs <= 0,
44659
45366
  deliveryToken: /* @__PURE__ */ new Map(),
@@ -44707,14 +45414,14 @@ function attachAgentWebSocket(server, agent, opts) {
44707
45414
  continue;
44708
45415
  }
44709
45416
  const out = withConsumerNotes(
44710
- withSearchCapability(
45417
+ withUtilityCapability(withSearchCapability(
44711
45418
  withManifestCapability(
44712
45419
  withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
44713
45420
  session.manifestGrants
44714
45421
  ),
44715
45422
  session.searchProviders,
44716
45423
  agent.signedSearchProvider()
44717
- ),
45424
+ ), session.utilityTools),
44718
45425
  batch[i],
44719
45426
  session,
44720
45427
  agent
@@ -44771,6 +45478,7 @@ function attachAgentWebSocket(server, agent, opts) {
44771
45478
  if (session.perToolGrants) agent.clearPerToolConsumer(session);
44772
45479
  if (session.manifestGrants) agent.clearManifestConsumer(session);
44773
45480
  agent.clearSearchProvidersSocket(session);
45481
+ agent.clearUtilityToolsSocket(session);
44774
45482
  };
44775
45483
  current = { sock, cleanup, sweepStale };
44776
45484
  sock.on("close", cleanup);
@@ -44811,7 +45519,10 @@ function sendJson(sock, body) {
44811
45519
  } catch {
44812
45520
  }
44813
45521
  }
45522
+ var ReplyCreditUnknownError = class extends Error {
45523
+ };
44814
45524
  function sendErrorCode(e) {
45525
+ if (e instanceof ReplyCreditUnknownError) return "reply_credit_unknown";
44815
45526
  if (e instanceof ChannelPendingError) return "channel_pending";
44816
45527
  if (e instanceof RateLimitedError) return "rate_limited";
44817
45528
  if (e instanceof AgentPausedSendError) return "agent_paused";
@@ -44872,6 +45583,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
44872
45583
  agent.noteConsumerCapability(manifest ? "ok" : session.harnessClaimed ? "harness_consumer" : "missing");
44873
45584
  session.searchProviders = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(SEARCH_PROVIDERS_FEATURE);
44874
45585
  agent.noteSearchProvidersSocket(session, session.searchProviders);
45586
+ session.utilityTools = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(UTILITY_TOOLS_FEATURE);
45587
+ agent.noteUtilityToolsSocket(session, session.utilityTools);
44875
45588
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
44876
45589
  agent.noteNotesConsumer();
44877
45590
  }
@@ -44879,7 +45592,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
44879
45592
  if (session.recallConsumer) agent.noteRecallConsumer();
44880
45593
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
44881
45594
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
44882
- sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE] });
45595
+ sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE, REPLY_CREDIT_FEATURE] });
44883
45596
  if (!session.deliveryOpen) {
44884
45597
  session.deliveryOpen = true;
44885
45598
  flush();
@@ -44908,7 +45621,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
44908
45621
  }
44909
45622
  const replyFailed = (e, inReplyTo) => {
44910
45623
  const code = sendErrorCode(e);
44911
- if ((code === "agent_paused" || code === "send_forbidden") && typeof inReplyTo === "string") {
45624
+ const terminal = code === "agent_paused" || code === "send_forbidden" || code === "reply_credit_unknown";
45625
+ if (terminal && typeof inReplyTo === "string") {
44912
45626
  inflight.delete(inReplyTo);
44913
45627
  session.deliveryToken.delete(inReplyTo);
44914
45628
  session.shownRecall.delete(inReplyTo);
@@ -44916,7 +45630,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
44916
45630
  agent.ackRuntimeMessages([inReplyTo]);
44917
45631
  }
44918
45632
  sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
44919
- if (code === "agent_paused" || code === "send_forbidden") flush();
45633
+ if (terminal) flush();
44920
45634
  };
44921
45635
  if (m.type === "send") {
44922
45636
  if (typeof m.channel_id !== "string" || !m.channel_id) {
@@ -44933,8 +45647,14 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
44933
45647
  }
44934
45648
  const record = typeof m.in_reply_to === "string" ? inflight.get(m.in_reply_to) : void 0;
44935
45649
  const callBufferReply = session.consumer === "runtime" && session.perToolGrants === true && session.harnessClaimed !== true && typeof m.in_reply_to === "string" && record !== void 0 && session.deliveryToken.get(m.in_reply_to) !== void 0 && record.channel_id === m.channel_id && !agent.isRuntimeMessageRetired(m.in_reply_to);
45650
+ if (m.credit !== void 0 && (m.a2a !== void 0 || !isReplyCredit(m.credit))) {
45651
+ writeServiceLog2("error", "reply.credit_unknown");
45652
+ replyFailed(new ReplyCreditUnknownError(), m.in_reply_to);
45653
+ return;
45654
+ }
44936
45655
  const sendOpts = {
44937
45656
  agentTurn: isAgentTurn(agent, m),
45657
+ ...isReplyCredit(m.credit) ? { credit: m.credit } : {},
44938
45658
  ...typeof m.in_reply_to === "string" ? { inReplyTo: m.in_reply_to } : {},
44939
45659
  ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {},
44940
45660
  ...callBufferReply ? { callBufferReply: true } : {}