@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/bin.js CHANGED
@@ -18223,7 +18223,17 @@ var CODES = [
18223
18223
  // missed and before any write, so nothing landed. Definite: the committer's
18224
18224
  // work cannot name an agent in a humans-only enclave (admission refuses
18225
18225
  // one), so dropping the commit starts no resend loop.
18226
- { status: 403, code: "humans_only_enclave" }
18226
+ { status: 403, code: "humans_only_enclave" },
18227
+ // ADR-0024 step 2a PR-E (carry-A ruling §3 A): a V Add or Welcome whose
18228
+ // leaf-key claim map is missing, does not cover exactly the Welcome
18229
+ // targets, or names a key that is not the target's current vendable
18230
+ // KeyPackage's. Refused before any write; definite, so the committer drops
18231
+ // the commit, refetches the KeyPackages and rebuilds instead of resending
18232
+ // the same stale bytes forever.
18233
+ { status: 409, code: "voice_stale_key_package" },
18234
+ // PR-E: a Welcome target already holds a bound leaf in this V group (the
18235
+ // DS-level duplicate-leaf guard). Nothing written; a resend cannot land.
18236
+ { status: 409, code: "voice_leaf_already_bound" }
18227
18237
  ];
18228
18238
  var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
18229
18239
  var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
@@ -19270,6 +19280,31 @@ function ownLeafIndexOf(mgr) {
19270
19280
  function tagOf(mgr) {
19271
19281
  return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
19272
19282
  }
19283
+ function leafKeyClaims(targets, pkgs) {
19284
+ if (targets.length !== pkgs.length)
19285
+ throw new Error("leafKeyClaims: targets and KeyPackages differ in length");
19286
+ const out = {};
19287
+ targets.forEach((d, i) => {
19288
+ out[d] = bytesToHex22(pkgs[i].leafNode.signaturePublicKey);
19289
+ });
19290
+ return out;
19291
+ }
19292
+ function leafIndicesForSignatureKey(mgr, keyHex) {
19293
+ const tree = mgr.state?.ratchetTree;
19294
+ if (!tree)
19295
+ return [];
19296
+ const want = keyHex.toLowerCase();
19297
+ const out = [];
19298
+ const numLeaves = Math.floor((tree.length + 1) / 2);
19299
+ for (let i = 0; i < numLeaves; i++) {
19300
+ const node = tree[i * 2];
19301
+ if (!node || node.nodeType !== "leaf")
19302
+ continue;
19303
+ if (bytesToHex22(getSignaturePublicKeyFromLeafIndex2(tree, toLeafIndex2(i))) === want)
19304
+ out.push(i);
19305
+ }
19306
+ return out;
19307
+ }
19273
19308
  function tagOfState(state) {
19274
19309
  const m = new MLSGroupManager(state.ciphersuite);
19275
19310
  m.importState(state);
@@ -19296,6 +19331,8 @@ function isEpochConflict(e) {
19296
19331
  var LABEL = "agentvault-connect-v1";
19297
19332
  var DEFAULT_STUCK_THRESHOLD = 2;
19298
19333
  var DEFAULT_QUARANTINE_THRESHOLD = 8;
19334
+ var KEPT_WELCOME_COMMIT_DEFER_MS = 24 * 60 * 60 * 1e3;
19335
+ var KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS = 32;
19299
19336
  var DELIVERY_PULL_PAGE = 100;
19300
19337
  var DELIVERY_MAX_RETRIES = 5;
19301
19338
  var DrainPageFullError = class extends Error {
@@ -19384,6 +19421,12 @@ var SelfAddRefusedError = class extends Error {
19384
19421
  this.name = "SelfAddRefusedError";
19385
19422
  }
19386
19423
  };
19424
+ var VoiceMirrorAddRefusedError = class extends Error {
19425
+ constructor() {
19426
+ super("addMemberToLiveGroup: refusing a voice-channel Add of a device already in the roster mirror (it would mint a duplicate leaf)");
19427
+ this.name = "VoiceMirrorAddRefusedError";
19428
+ }
19429
+ };
19387
19430
  var VaultCredentialMismatchError = class extends Error {
19388
19431
  expected;
19389
19432
  got;
@@ -19494,6 +19537,15 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19494
19537
  * {@link errorStats}; mutated only through {@link recordDecryptError} /
19495
19538
  * {@link recordDecryptSuccess}.
19496
19539
  */
19540
+ /** {@link KEPT_WELCOME_COMMIT_DEFER_MS}: channel -> when its first commit was
19541
+ * deferred behind the kept Welcome `welcomeId`. Restarted for a different
19542
+ * Welcome; cleared when the channel decrypts, or when a pass that saw the
19543
+ * whole queue kept no Welcome for it (fix round 1, L2: the Welcome
19544
+ * resolved, so a later re-Welcome gets a fresh, bounded window). An entry
19545
+ * past its bound stays (expired) until then, so the bound cannot restart
19546
+ * itself pass after pass. In memory: a reload restarts it, and the
19547
+ * per-pass row cap is what bounds the page cost across reloads. */
19548
+ keptWelcomeDeferSince = /* @__PURE__ */ new Map();
19497
19549
  errors = /* @__PURE__ */ new Map();
19498
19550
  stuckThreshold;
19499
19551
  /** Consecutive-error threshold at which a wedged channel's row is quarantined
@@ -19712,8 +19764,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19712
19764
  * channel resumes normal processing and can quarantine again later if it
19713
19765
  * re-wedges (the onWedged guard is reset so the hook can fire again).
19714
19766
  */
19767
+ /** See {@link MlsGroupKeyProviderOpts.deferCommitsWhileWelcomePending}. The
19768
+ * caller has already established that this pass KEPT a no-state Welcome
19769
+ * for `channelId`. True: defer the commit row. Never throws. */
19770
+ deferKeptWelcomeCommit(channelId, welcomeId) {
19771
+ const pred = this.o.deferCommitsWhileWelcomePending;
19772
+ if (!pred)
19773
+ return false;
19774
+ let boundMs = 0;
19775
+ try {
19776
+ const v = pred(channelId);
19777
+ 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;
19778
+ } catch {
19779
+ boundMs = 0;
19780
+ }
19781
+ if (boundMs <= 0)
19782
+ return false;
19783
+ const now = this.nowMs();
19784
+ const entry = this.keptWelcomeDeferSince.get(channelId);
19785
+ if (entry === void 0 || entry.welcomeId !== welcomeId) {
19786
+ this.keptWelcomeDeferSince.set(channelId, { since: now, welcomeId });
19787
+ return true;
19788
+ }
19789
+ return now - entry.since < boundMs;
19790
+ }
19715
19791
  recordDecryptSuccess(channelId) {
19716
19792
  this.lastDecryptAt.set(channelId, Date.now());
19793
+ this.keptWelcomeDeferSince.delete(channelId);
19717
19794
  const e = this.errors.get(channelId);
19718
19795
  if (!e)
19719
19796
  return;
@@ -20109,6 +20186,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20109
20186
  * server's REJOIN_THRESHOLD, there was nothing to apply, or a commit would
20110
20187
  * not apply (which means the state is genuinely FORKED, not merely behind,
20111
20188
  * and replay cannot fix a fork).
20189
+ *
20190
+ * THROWS (retryable, never `false`, #1290): CustodyPersistError when the
20191
+ * channel is under the custody hold (nothing applied), and the C2 classes
20192
+ * (PendingCommitUnresolvedError from the guarded MAIN write) after evicting
20193
+ * the advanced cache, so the persisted state is unchanged. A /sync request
20194
+ * that fails also throws, before anything is applied.
20112
20195
  */
20113
20196
  async resyncByReplay(channelId, groupId) {
20114
20197
  if (!await this.hasPersistedChannelState(channelId))
@@ -20128,12 +20211,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20128
20211
  let applied = 0;
20129
20212
  try {
20130
20213
  await this.locks.run(channelId, async () => {
20214
+ if (await this.custodyHeld(channelId))
20215
+ throw new CustodyPersistError(channelId);
20131
20216
  const mgr = await this.manager(channelId);
20132
20217
  try {
20133
20218
  for (const c of commits) {
20134
20219
  if (c.epoch <= Number(mgr.epoch))
20135
20220
  continue;
20136
- await mgr.decrypt(hexToBytes22(c.payload));
20221
+ const commitBytes = hexToBytes22(c.payload);
20222
+ try {
20223
+ await mgr.decrypt(commitBytes);
20224
+ } catch (e) {
20225
+ if (await probeOwnRemoval(mgr, commitBytes) !== null) {
20226
+ this.noteOwnLeafRemoved(channelId, {
20227
+ group_id: groupId,
20228
+ epoch: c.epoch,
20229
+ message_id: c.id
20230
+ });
20231
+ }
20232
+ throw e;
20233
+ }
20137
20234
  applied++;
20138
20235
  }
20139
20236
  if (applied > 0)
@@ -20145,6 +20242,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20145
20242
  }
20146
20243
  });
20147
20244
  } catch (e) {
20245
+ if (isSdkError(e, "CustodyPersistError") || e instanceof CustodyPersistError || e instanceof PendingCommitUnresolvedError || e instanceof GroupSupersededError || e instanceof AttestedOrphanRefusedError) {
20246
+ writeServiceLog2("warn", "resync.replay_held", { applied }, e);
20247
+ throw e;
20248
+ }
20148
20249
  writeServiceLog2("warn", "resync.replay_commit_failed", { applied }, e);
20149
20250
  return false;
20150
20251
  }
@@ -20873,10 +20974,28 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20873
20974
  * The row is deliberately NOT acked here — the subsequent ensureSession(join) /
20874
20975
  * joinAndReconcile consumes the same Welcome and acks it after joining.
20875
20976
  */
20876
- async peekWelcomeChannel(deviceId) {
20977
+ async peekWelcomeChannel(deviceId, opts = {}) {
20877
20978
  const rows = await this.o.backend.pullDelivery(deviceId);
20878
- const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
20879
- return w?.connect_channel_id ?? null;
20979
+ const asked = /* @__PURE__ */ new Set();
20980
+ for (const r of rows) {
20981
+ if (r.message_type !== "welcome" || r.connect_channel_id == null)
20982
+ continue;
20983
+ const ch = r.connect_channel_id;
20984
+ if (!opts.accept)
20985
+ return ch;
20986
+ if (asked.has(ch))
20987
+ continue;
20988
+ asked.add(ch);
20989
+ let ok = false;
20990
+ try {
20991
+ ok = await opts.accept(ch) === true;
20992
+ } catch {
20993
+ ok = false;
20994
+ }
20995
+ if (ok)
20996
+ return ch;
20997
+ }
20998
+ return null;
20880
20999
  }
20881
21000
  /**
20882
21001
  * Publish this device's KeyPackage if it holds none; otherwise a no-op that
@@ -20982,10 +21101,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20982
21101
  }
20983
21102
  const attempted = [];
20984
21103
  const failed = [];
21104
+ const discarded = [];
20985
21105
  for (const channelId of channelIds) {
20986
21106
  attempted.push(channelId);
20987
21107
  try {
20988
- await this.ensureSession(channelId, { enclaveId: "welcome-drain", bootstrap: false });
21108
+ await this.ensureSession(channelId, {
21109
+ enclaveId: "welcome-drain",
21110
+ bootstrap: false,
21111
+ onUnopenableWelcome: (groupId) => {
21112
+ discarded.push({ channelId, groupId });
21113
+ }
21114
+ });
20989
21115
  } catch (error) {
20990
21116
  failed.push({ channelId, error });
20991
21117
  }
@@ -20995,7 +21121,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20995
21121
  receipt = new DrainReceipt(DRAIN_RECEIPT_TOKEN);
20996
21122
  this.drainReceipts.set(receipt, this.nowMs());
20997
21123
  }
20998
- return { attempted, failed, skipped, unknown, pageFull, receipt };
21124
+ return { attempted, failed, skipped, unknown, pageFull, receipt, discarded };
20999
21125
  }
21000
21126
  /**
21001
21127
  * Resolve the channel's pending commit record, under the channel lock, and
@@ -21468,7 +21594,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21468
21594
  throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
21469
21595
  return MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
21470
21596
  });
21471
- const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
21597
+ const creatorLeafSignatureKey = bundle ? bytesToHex22(bundle.publicPackage.leafNode.signaturePublicKey) : void 0;
21598
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId], creatorLeafSignatureKey !== void 0 ? { creatorLeafSignatureKey } : void 0);
21472
21599
  if (init.created === false) {
21473
21600
  if (!this.isAdoptableOrphan(init, others)) {
21474
21601
  if (this.isOrphanShape(init, others))
@@ -21488,7 +21615,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21488
21615
  commit_epoch: Number(mgr.epoch),
21489
21616
  expected_epoch: init.current_epoch,
21490
21617
  member_device_ids: [this.o.deviceId, ...others],
21491
- welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : []
21618
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : [],
21619
+ // Task 24b (ruling §3 A): exact cover of the Welcome targets ({} when none).
21620
+ added_leaf_signature_keys: leafKeyClaims(welcome ? others : [], welcome ? pkgs : [])
21492
21621
  }, mgr, null);
21493
21622
  const res = await this.commitWithPendingRecord(channelId, rec, mgr);
21494
21623
  if (!res.landed) {
@@ -21502,15 +21631,32 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21502
21631
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
21503
21632
  if (!w)
21504
21633
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
21634
+ const aborted = () => opts.shouldAbort?.() === true;
21635
+ if (aborted())
21636
+ throw new Error("ensureSession(join): aborted by the caller");
21505
21637
  let mgr;
21506
21638
  try {
21507
21639
  mgr = await this.openWelcome(hexToBytes22(w.payload), [bundle, joinSlot]);
21508
21640
  } catch (err) {
21641
+ if (aborted())
21642
+ throw new Error("ensureSession(join): aborted by the caller");
21509
21643
  this.recordDecryptError(w, err);
21510
21644
  writeServiceLog2("warn", "session.unopenable_welcome_discarded", {}, err);
21511
21645
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
21646
+ try {
21647
+ opts.onUnopenableWelcome?.(w.group_id);
21648
+ } catch (e) {
21649
+ writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
21650
+ }
21651
+ try {
21652
+ this.o.onUnopenableWelcomeDiscarded?.({ channelId, groupId: w.group_id });
21653
+ } catch (e) {
21654
+ writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
21655
+ }
21512
21656
  return;
21513
21657
  }
21658
+ if (aborted())
21659
+ throw new Error("ensureSession(join): aborted by the caller");
21514
21660
  this.managers.set(channelId, mgr);
21515
21661
  await this.saveMain(channelId, mgr);
21516
21662
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
@@ -21721,6 +21867,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21721
21867
  await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "addMemberToLiveGroup");
21722
21868
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
21723
21869
  groupId = init.group_id;
21870
+ if (this.o.isVoiceChannel?.(channelId) === true) {
21871
+ const mirror = init.member_device_ids;
21872
+ if (!Array.isArray(mirror) || mirror.includes(newDeviceId)) {
21873
+ writeServiceLog2("warn", "admission.voice_device_already_in_mirror");
21874
+ throw new VoiceMirrorAddRefusedError();
21875
+ }
21876
+ }
21724
21877
  members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
21725
21878
  }
21726
21879
  if (new TextDecoder().decode(mgr.groupId) !== groupId) {
@@ -21739,7 +21892,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
21739
21892
  expected_epoch: priorEpoch,
21740
21893
  member_device_ids: members,
21741
21894
  op: "add",
21742
- welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : []
21895
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : [],
21896
+ // Task 24b (ruling §3 A): the exact Welcome cover's leaf keys.
21897
+ added_leaf_signature_keys: leafKeyClaims(welcome ? [newDeviceId] : [], welcome ? [pkg] : [])
21743
21898
  }, mgr, prior), mgr);
21744
21899
  if (!res.landed) {
21745
21900
  const e = res.error;
@@ -22060,6 +22215,181 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22060
22215
  return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
22061
22216
  }
22062
22217
  }
22218
+ /**
22219
+ * ADR-0024 step 2a, round 2 F4 (Task 24): the committer sweep's DEVICE-level
22220
+ * Remove of one leaf the server's committer-work lists in `remove_devices`
22221
+ * (on the hidden voice channel V: a stale device of an agent or relay
22222
+ * identity that is still on V's roster).
22223
+ *
22224
+ * ⛔ WHY NOT {@link removeDeviceFromLiveGroup}. That path resolves a FOREIGN
22225
+ * identity's leaf by add-order position, which RFC 9420's leftmost-blank
22226
+ * refill permutes. Here the identity keeps a CURRENT device, so a wrong guess
22227
+ * evicts the current device's leaf while the distribute's `removed_device_id`
22228
+ * makes the server drop the STALE device from its mirror. The current device
22229
+ * is then in the roster with no leaf, outside the adds delta forever: an
22230
+ * unrecoverable wedge. So this resolves the leaf by EXACT evidence only:
22231
+ *
22232
+ * - the target device's PUBLISHED KeyPackage, whose LeafNode signature key
22233
+ * is matched against THIS committer's live tree (the device-level leaf
22234
+ * predicate, {@link leafIndicesForKeyPackage}). `generateKeyPackage`
22235
+ * mints a fresh signature keypair per call, so a match names exactly the
22236
+ * leaf that KeyPackage was Added as;
22237
+ * - exactly ONE matching leaf, or nothing is committed;
22238
+ * - that leaf's credential identity is read from the tree, never from the
22239
+ * server, and is what the distribute names as `removed_identity_id`.
22240
+ *
22241
+ * Outcomes. `"removed"`: one leaf evicted and the distribute landed.
22242
+ * `"unresolved"`: no KeyPackage is published for the device, or it matches no
22243
+ * leaf (the device rotated it after its Add, or the leaf is already gone).
22244
+ * Nothing is committed: the leaf, if any, stays, and is reported, never
22245
+ * guessed. The server's next delta lists it again (the caller's throttle
22246
+ * bounds the retries).
22247
+ *
22248
+ * Refusals (thrown before any commit is built, so nothing is distributed):
22249
+ * the committer's own device (`own_device`), any leaf of the committer's own
22250
+ * identity, the committer's own leaf, or an unknown own leaf
22251
+ * (`own_leaf` / `own_leaf_unknown`, {@link OwnLeafRemoveRefusedError}); two
22252
+ * or more matching leaves; a held group that is not the channel's active
22253
+ * group (#757 binding, checked BEFORE init).
22254
+ */
22255
+ async removeStaleDeviceLeafFromLiveGroup(channelId, deviceId, bound) {
22256
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
22257
+ await this.requirePendingResolvedUnlocked(channelId);
22258
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, 0, bound);
22259
+ }));
22260
+ }
22261
+ async removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt, bound) {
22262
+ if (await this.custodyHeld(channelId))
22263
+ throw new CustodyPersistError(channelId);
22264
+ if (deviceId === this.o.deviceId)
22265
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
22266
+ const mgr = await this.manager(channelId);
22267
+ const resolved = bound ? this.resolveBoundDeviceLeaf(mgr, bound) : await this.resolveServedKeyPackageLeaf(mgr, deviceId);
22268
+ if (resolved === "unresolved")
22269
+ return "unresolved";
22270
+ const { leafIndex, leafIdentity } = resolved;
22271
+ await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "removeStaleDeviceLeafFromLiveGroup");
22272
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
22273
+ if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
22274
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: group binding mismatch");
22275
+ }
22276
+ const priorEpoch = Number(mgr.epoch);
22277
+ const prior = tagOf(mgr);
22278
+ const { commit } = await mgr.removeMembers([leafIndex]);
22279
+ if (Number(mgr.epoch) !== priorEpoch + 1) {
22280
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: unexpected epoch drift before distribute");
22281
+ }
22282
+ const members = init.member_device_ids ?? [this.o.deviceId];
22283
+ const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
22284
+ device_id: this.o.deviceId,
22285
+ commit: bytesToHex22(commit),
22286
+ commit_epoch: Number(mgr.epoch),
22287
+ expected_epoch: priorEpoch,
22288
+ member_device_ids: members,
22289
+ op: "remove",
22290
+ removed_identity_id: leafIdentity,
22291
+ removed_device_id: deviceId,
22292
+ welcomes: []
22293
+ }, mgr, prior), mgr);
22294
+ if (!res.landed) {
22295
+ const e = res.error;
22296
+ if (isSupersededRefusal(e))
22297
+ throw new GroupSupersededError(channelId, init.group_id);
22298
+ if (_attempt >= 2 || !isEpochConflict(e))
22299
+ throw e;
22300
+ try {
22301
+ await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
22302
+ } catch (e2) {
22303
+ if (e2 instanceof CustodyPersistError)
22304
+ throw e2;
22305
+ }
22306
+ return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt + 1, bound);
22307
+ }
22308
+ return "removed";
22309
+ }
22310
+ /** F4 (Task 24): the target's PUBLISHED KeyPackage, matched by key against
22311
+ * the committer's tree. Unchanged from Task 24 and its fix round 1. */
22312
+ async resolveServedKeyPackageLeaf(mgr, deviceId) {
22313
+ const kps = await this.o.backend.fetchKeyPackages([deviceId]);
22314
+ const hex = kps[deviceId];
22315
+ if (!hex) {
22316
+ writeServiceLog2("info", "committer.remove_device_unresolved", { no_key_package: true });
22317
+ return "unresolved";
22318
+ }
22319
+ const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
22320
+ const leaves = leafIndicesForKeyPackage(mgr, pkg);
22321
+ if (leaves.length === 0) {
22322
+ writeServiceLog2("info", "committer.remove_device_unresolved", { no_matching_leaf: true });
22323
+ return "unresolved";
22324
+ }
22325
+ if (leaves.length > 1) {
22326
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous key match (more than one leaf)");
22327
+ }
22328
+ const leafIndex = leaves[0];
22329
+ const tree = mgr.state?.ratchetTree;
22330
+ if (!tree)
22331
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
22332
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
22333
+ if (cred.credentialType !== "basic") {
22334
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: leaf carries no basic credential");
22335
+ }
22336
+ const leafIdentity = new TextDecoder().decode(cred.identity);
22337
+ const ownIdentity = new TextDecoder().decode(this.o.identity);
22338
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
22339
+ if (!leafIdentity || leafIdentity === ownIdentity) {
22340
+ throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf");
22341
+ }
22342
+ if (mgr.leafIndicesForIdentity(leafIdentity).length <= 1) {
22343
+ writeServiceLog2("warn", "committer.remove_device_refused_last_leaf", {});
22344
+ return "unresolved";
22345
+ }
22346
+ return { leafIndex, leafIdentity };
22347
+ }
22348
+ /**
22349
+ * ADR-0024 step 2a Task 24b (architect ruling "carry A", §3 B and §4 R5):
22350
+ * resolve the leaf by the SERVER-BOUND leaf signature key (committer work's
22351
+ * `remove_device_leaves`). The server recorded the key when the leaf was
22352
+ * born, from the KeyPackage it vended, so it survives the target's
22353
+ * KeyPackage rotation, revocation and deletion, which the served-KeyPackage
22354
+ * path cannot.
22355
+ *
22356
+ * - Leaves are matched by the bound key ONLY: exactly one match, else
22357
+ * `unresolved` (zero) or a throw (two or more). ⛔ NEVER a fall back to
22358
+ * the served KeyPackage: two evidence sources that disagree fail closed.
22359
+ * - The matched leaf's basic-credential identity must equal the server's
22360
+ * `identityId` for the device (R4), else a throw.
22361
+ * - The committer's own leaf index (or an unknown one) is refused
22362
+ * ({@link refuseOwnLeafRemove}); the own device was refused by the caller.
22363
+ * - R5: a leaf of the committer's OWN identity is admitted (a sibling
22364
+ * device: a revoked laptop). The brick class the own-identity refusal
22365
+ * guards came from positional or identity-level resolution, which this
22366
+ * exact-key path cannot reach, and the own leaf index is still refused.
22367
+ * - No last-leaf refusal: the binding is exact evidence, and an identity's
22368
+ * last leaf is exactly the revoked single-device case (and carry A's
22369
+ * Remove before its re-Add).
22370
+ * Synchronous and before any commit is built: a refusal distributes nothing.
22371
+ */
22372
+ resolveBoundDeviceLeaf(mgr, bound) {
22373
+ const leaves = leafIndicesForSignatureKey(mgr, bound.leafSignatureKeyHex);
22374
+ if (leaves.length === 0) {
22375
+ writeServiceLog2("info", "committer.remove_device_unresolved", { no_bound_leaf: true });
22376
+ return "unresolved";
22377
+ }
22378
+ if (leaves.length > 1) {
22379
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous bound-key match (more than one leaf)");
22380
+ }
22381
+ const leafIndex = leaves[0];
22382
+ const tree = mgr.state?.ratchetTree;
22383
+ if (!tree)
22384
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
22385
+ const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
22386
+ const leafIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
22387
+ if (!leafIdentity || leafIdentity !== bound.identityId) {
22388
+ throw new Error("removeStaleDeviceLeafFromLiveGroup: the bound leaf's credential identity is not the device's identity");
22389
+ }
22390
+ this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
22391
+ return { leafIndex, leafIdentity };
22392
+ }
22063
22393
  /**
22064
22394
  * Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
22065
22395
  * targeted by its owner-observed leaf `signaturePublicKey` against the live
@@ -22616,6 +22946,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22616
22946
  out.push({
22617
22947
  queueId: `replay-${m.id}`,
22618
22948
  messageId: envelope.message_id,
22949
+ // #1246 C-1: the group-messages row id is the same mls_messages key.
22950
+ ...typeof m.id === "string" && m.id ? { serverMessageId: m.id } : {},
22619
22951
  channelId,
22620
22952
  plaintext,
22621
22953
  envelope,
@@ -23516,6 +23848,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23516
23848
  const heldBundleRefs = () => heldBundleRefsMemo ??= this.readHeldBundleRefs();
23517
23849
  const w2Acked = /* @__PURE__ */ new Map();
23518
23850
  const w2Kept = /* @__PURE__ */ new Set();
23851
+ const keptWelcomeId = /* @__PURE__ */ new Map();
23852
+ const deferredThisPass = /* @__PURE__ */ new Map();
23519
23853
  const unpinnedRefusals = /* @__PURE__ */ new Map();
23520
23854
  const welcomes = rows.filter((r) => r.message_type === "welcome");
23521
23855
  const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
@@ -23674,6 +24008,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23674
24008
  writeServiceLog2("warn", "welcome.unopenable");
23675
24009
  } else {
23676
24010
  w2Kept.add(wcid);
24011
+ if (!keptWelcomeId.has(wcid))
24012
+ keptWelcomeId.set(wcid, row.queue_id);
23677
24013
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
23678
24014
  writeServiceLog2("debug", "delivery.welcome_no_local_state");
23679
24015
  }
@@ -23684,6 +24020,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23684
24020
  const cid = row.connect_channel_id;
23685
24021
  if (!cid)
23686
24022
  continue;
24023
+ if (w2Kept.has(cid) && (deferredThisPass.get(cid) ?? 0) < KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS && this.deferKeptWelcomeCommit(cid, keptWelcomeId.get(cid) ?? "")) {
24024
+ deferredThisPass.set(cid, (deferredThisPass.get(cid) ?? 0) + 1);
24025
+ continue;
24026
+ }
23687
24027
  let advancedEpoch;
23688
24028
  let custodyHeld = false;
23689
24029
  await this.locks.run(cid, async () => {
@@ -23840,6 +24180,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23840
24180
  this.leftStreak.set(cid, { pulls: 1, since: pullNow });
23841
24181
  this.scheduleAutoResolve(cid);
23842
24182
  }
24183
+ if (rows.length < DELIVERY_PULL_PAGE) {
24184
+ for (const cid of [...this.keptWelcomeDeferSince.keys()]) {
24185
+ if (!w2Kept.has(cid))
24186
+ this.keptWelcomeDeferSince.delete(cid);
24187
+ }
24188
+ }
23843
24189
  for (const [cid, gid] of w2Acked) {
23844
24190
  if (w2Kept.has(cid))
23845
24191
  continue;
@@ -23880,6 +24226,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23880
24226
  if (supersededChannelIds.length) {
23881
24227
  Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
23882
24228
  }
24229
+ if (w2Kept.size) {
24230
+ Object.defineProperty(appEntries, "pendingWelcomeChannelIds", { value: [...w2Kept], enumerable: false });
24231
+ }
23883
24232
  return appEntries;
23884
24233
  }
23885
24234
  /**
@@ -24116,7 +24465,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
24116
24465
  voiceDefaultResponder: row.voice_default_responder ?? void 0,
24117
24466
  ...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
24118
24467
  payloadHex: row.payload,
24119
- openedAt
24468
+ openedAt,
24469
+ // #1246 C-1: the server row id, beside the sender-chosen envelope id.
24470
+ ...typeof row.message_id === "string" && row.message_id ? { serverMessageId: row.message_id } : {}
24120
24471
  };
24121
24472
  const custodiedAt = this.nowMs();
24122
24473
  this.custody.set(row.queue_id, entry);
@@ -25671,9 +26022,9 @@ var RestMlsBackend = class {
25671
26022
  current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
25672
26023
  };
25673
26024
  }
25674
- async initConnectChannelGroup(channelId, memberDeviceIds) {
26025
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
25675
26026
  const path = `/api/v1/mls/connect-channels/${channelId}/init`;
25676
- const body = { member_device_ids: memberDeviceIds };
26027
+ const body = opts?.creatorLeafSignatureKey !== void 0 ? { member_device_ids: memberDeviceIds, creator_leaf_signature_key: opts.creatorLeafSignatureKey } : { member_device_ids: memberDeviceIds };
25677
26028
  let j;
25678
26029
  for (let attempt = 0; ; attempt++) {
25679
26030
  try {
@@ -25915,6 +26266,22 @@ var RestMlsBackend = class {
25915
26266
  throw new Error(`unrequested-removals: reports is ${describeType(rows)}`);
25916
26267
  return rows;
25917
26268
  }
26269
+ async signalVoiceLeafUnusable(groupId, deviceId) {
26270
+ 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 }) });
26271
+ const text = await res.text().catch(() => "");
26272
+ if (res.ok)
26273
+ return { status: res.status };
26274
+ let parsed = null;
26275
+ if (text) {
26276
+ try {
26277
+ parsed = JSON.parse(text);
26278
+ } catch {
26279
+ parsed = null;
26280
+ }
26281
+ }
26282
+ const code = parseErrorCode(parsed);
26283
+ return code === void 0 ? { status: res.status } : { status: res.status, code };
26284
+ }
25918
26285
  async getCommitterWork(channelId) {
25919
26286
  const j = await this.get("mls_committer_work", `/api/v1/mls/connect-channels/${channelId}/committer-work`);
25920
26287
  const requireList = (field) => {
@@ -25924,7 +26291,29 @@ var RestMlsBackend = class {
25924
26291
  }
25925
26292
  return v.map(String);
25926
26293
  };
25927
- return { adds: requireList("adds"), removes: requireList("removes") };
26294
+ const work = {
26295
+ adds: requireList("adds"),
26296
+ removes: requireList("removes")
26297
+ };
26298
+ if (j?.remove_devices !== void 0) {
26299
+ work.removeDevices = requireList("remove_devices");
26300
+ }
26301
+ const rawLeaves = j?.remove_device_leaves;
26302
+ if (rawLeaves !== void 0) {
26303
+ if (!Array.isArray(rawLeaves))
26304
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves", describeType(rawLeaves));
26305
+ const seen = /* @__PURE__ */ new Set();
26306
+ work.removeDeviceLeaves = rawLeaves.map((e) => {
26307
+ const o = e !== null && typeof e === "object" && !Array.isArray(e) ? e : null;
26308
+ const key = typeof o?.leaf_signature_key === "string" ? o.leaf_signature_key.toLowerCase() : null;
26309
+ 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)) {
26310
+ throw new MalformedCommitterWorkError(channelId, "remove_device_leaves[]", describeType(e));
26311
+ }
26312
+ seen.add(o.device_id);
26313
+ return { deviceId: o.device_id, identityId: o.identity_id, leafSignatureKeyHex: key };
26314
+ });
26315
+ }
26316
+ return work;
25928
26317
  }
25929
26318
  async acquireCommitLease(channelId) {
25930
26319
  try {
@@ -25974,6 +26363,48 @@ var RestMlsBackend = class {
25974
26363
 
25975
26364
  // ../connect-sdk-ts/dist/mls/fake-backend.js
25976
26365
  var DELIVERY_PULL_LIMIT = 100;
26366
+ function keyPackageSignatureKeyHex(kpHex) {
26367
+ const b = hexToBytes22(kpHex);
26368
+ let at = 0;
26369
+ const u16 = () => {
26370
+ if (at + 2 > b.length)
26371
+ throw new Error("t");
26372
+ const v = b[at] << 8 | b[at + 1];
26373
+ at += 2;
26374
+ return v;
26375
+ };
26376
+ const opaque = () => {
26377
+ if (at >= b.length)
26378
+ throw new Error("t");
26379
+ const prefix = b[at] >> 6;
26380
+ if (prefix === 3)
26381
+ throw new Error("t");
26382
+ const size = 1 << prefix;
26383
+ if (at + size > b.length)
26384
+ throw new Error("t");
26385
+ let n = b[at] & 63;
26386
+ for (let i = 1; i < size; i++)
26387
+ n = n << 8 | b[at + i];
26388
+ at += size;
26389
+ if (at + n > b.length)
26390
+ throw new Error("t");
26391
+ const out = b.slice(at, at + n);
26392
+ at += n;
26393
+ return out;
26394
+ };
26395
+ try {
26396
+ if (u16() !== 1 || u16() !== 5)
26397
+ return null;
26398
+ u16();
26399
+ u16();
26400
+ opaque();
26401
+ opaque();
26402
+ const sig = opaque();
26403
+ return sig.length > 0 ? bytesToHex22(sig) : null;
26404
+ } catch {
26405
+ return null;
26406
+ }
26407
+ }
25977
26408
  var FakeConnectBackend = class _FakeConnectBackend {
25978
26409
  // Key-package store: deviceId → hex-encoded KeyPackage
25979
26410
  kps = /* @__PURE__ */ new Map();
@@ -26058,6 +26489,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
26058
26489
  * server's purge_device_queue. */
26059
26490
  linkIdentityDevice(identityId, deviceId) {
26060
26491
  this.deviceByIdentity.set(identityId, deviceId);
26492
+ this.identityByDevice.set(deviceId, identityId);
26061
26493
  }
26062
26494
  async publishKeyPackage(deviceId, keyPackageHex) {
26063
26495
  this.kps.set(deviceId, keyPackageHex);
@@ -26107,8 +26539,38 @@ var FakeConnectBackend = class _FakeConnectBackend {
26107
26539
  current_epoch: this.epochByChannel.get(channelId) ?? 0
26108
26540
  };
26109
26541
  }
26110
- async initConnectChannelGroup(channelId, memberDeviceIds) {
26542
+ /** ADR-0024 step 2a (P-7): channel ids the test registers as a hidden
26543
+ * voice channel V. Init on an EXISTING V returns it unchanged and merges
26544
+ * nothing, as the real backend does. */
26545
+ voiceChannelIds = /* @__PURE__ */ new Set();
26546
+ /** Task 24b (architect ruling "carry A", §3 A-C), modelled for a channel in
26547
+ * {@link voiceChannelIds} only, as the PR-E server does for V:
26548
+ * `channel -> device -> { key (hex), unusableSignaledAt }`, written ONLY
26549
+ * when a V leaf is born (the creator's init, an Add's Welcome) from the
26550
+ * verified claim, and deleted when a Remove of that device (or its
26551
+ * identity) lands. */
26552
+ voiceLeafBindings = /* @__PURE__ */ new Map();
26553
+ /** The device -> identity the server knows (the devices table). Written by
26554
+ * {@link linkIdentityDevice}; read for `remove_device_leaves`. */
26555
+ identityByDevice = /* @__PURE__ */ new Map();
26556
+ static voiceRefusal(path, code) {
26557
+ return new MlsBackendHttpError(path, 409, code);
26558
+ }
26559
+ /** The current vendable KeyPackage key of `deviceId`, or null. */
26560
+ currentLeafKey(deviceId) {
26561
+ const hex = this.kps.get(deviceId);
26562
+ return hex ? keyPackageSignatureKeyHex(hex) : null;
26563
+ }
26564
+ async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
26111
26565
  const alreadyExists = this.groupIdByChannel.has(channelId);
26566
+ if (!alreadyExists && this.voiceChannelIds.has(channelId)) {
26567
+ const creator = memberDeviceIds[0];
26568
+ const current = creator ? this.currentLeafKey(creator) : null;
26569
+ if (!opts?.creatorLeafSignatureKey || current === null || opts.creatorLeafSignatureKey.toLowerCase() !== current) {
26570
+ throw new Error(`/api/v1/mls/connect-channels/${channelId}/init failed: 409 {"detail":"voice_stale_key_package"}`);
26571
+ }
26572
+ this.voiceLeafBindings.set(channelId, /* @__PURE__ */ new Map([[creator, { key: current, unusableSignaledAt: null }]]));
26573
+ }
26112
26574
  let groupId = this.groupIdByChannel.get(channelId);
26113
26575
  if (!groupId) {
26114
26576
  groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
@@ -26116,7 +26578,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
26116
26578
  this.channelByGroup.set(groupId, channelId);
26117
26579
  this.epochByChannel.set(channelId, 0);
26118
26580
  }
26119
- const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
26581
+ const members = alreadyExists && this.voiceChannelIds.has(channelId) ? [...this.membersByChannel.get(channelId) ?? []] : [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
26120
26582
  this.membersByChannel.set(channelId, members);
26121
26583
  return {
26122
26584
  group_id: groupId,
@@ -26253,6 +26715,27 @@ var FakeConnectBackend = class _FakeConnectBackend {
26253
26715
  throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
26254
26716
  }
26255
26717
  }
26718
+ const vBindings = this.voiceChannelIds.has(channelId) ? this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map() : null;
26719
+ let bindNow = [];
26720
+ if (vBindings && req.commit && req.op !== "remove") {
26721
+ const targets = (req.welcomes ?? []).map((w) => w.target_device_id);
26722
+ const claims = req.added_leaf_signature_keys;
26723
+ const path = _FakeConnectBackend.distributePath(groupId);
26724
+ if (!claims)
26725
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
26726
+ const claimed = Object.keys(claims);
26727
+ if (claimed.length !== targets.length || targets.some((t) => !(t in claims))) {
26728
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
26729
+ }
26730
+ for (const t of targets) {
26731
+ const current = this.currentLeafKey(t);
26732
+ if (current === null || claims[t].toLowerCase() !== current)
26733
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
26734
+ if (vBindings.has(t))
26735
+ throw _FakeConnectBackend.voiceRefusal(path, "voice_leaf_already_bound");
26736
+ }
26737
+ bindNow = targets.map((t) => [t, claims[t].toLowerCase()]);
26738
+ }
26256
26739
  if (req.commit) {
26257
26740
  const log = this.commitLog.get(groupId) ?? [];
26258
26741
  log.push({
@@ -26275,6 +26758,12 @@ var FakeConnectBackend = class _FakeConnectBackend {
26275
26758
  this.membersByChannel.set(channelId, remaining);
26276
26759
  if (evictedDevice)
26277
26760
  this.queues.delete(evictedDevice);
26761
+ if (vBindings) {
26762
+ for (const d of [...vBindings.keys()]) {
26763
+ if (d === evictedDevice || !req.removed_device_id && this.identityByDevice.get(d) === req.removed_identity_id)
26764
+ vBindings.delete(d);
26765
+ }
26766
+ }
26278
26767
  } else {
26279
26768
  const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
26280
26769
  this.membersByChannel.set(channelId, members);
@@ -26298,6 +26787,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
26298
26787
  });
26299
26788
  }
26300
26789
  }
26790
+ if (vBindings && bindNow.length > 0) {
26791
+ for (const [d, k] of bindNow)
26792
+ vBindings.set(d, { key: k, unusableSignaledAt: null });
26793
+ this.voiceLeafBindings.set(channelId, vBindings);
26794
+ }
26301
26795
  for (const w of req.welcomes ?? []) {
26302
26796
  this.q(w.target_device_id).push({
26303
26797
  queue_id: "q-" + this._seq++,
@@ -26358,6 +26852,51 @@ var FakeConnectBackend = class _FakeConnectBackend {
26358
26852
  async getCommitterWork(_channelId) {
26359
26853
  return { adds: [], removes: [] };
26360
26854
  }
26855
+ /** Task 24b (ruling §3 C): the carry-A signal, every call recorded. Gates
26856
+ * modelled: the group is a V (else 404), a binding exists (else 409), the
26857
+ * device's current vendable key differs from the bound one (409
26858
+ * `voice_leaf_key_current` when equal, `voice_no_current_key_package`
26859
+ * when it has none). An already-signalled row is an idempotent 202. The
26860
+ * bind and roster gates are the server's (backend tests). */
26861
+ signalVoiceLeafUnusableCalls = [];
26862
+ async signalVoiceLeafUnusable(groupId, deviceId) {
26863
+ this.signalVoiceLeafUnusableCalls.push({ groupId, deviceId });
26864
+ const channelId = this.channelByGroup.get(groupId);
26865
+ if (!channelId || !this.voiceChannelIds.has(channelId))
26866
+ return { status: 404, code: "Not Found" };
26867
+ const row = this.voiceLeafBindings.get(channelId)?.get(deviceId);
26868
+ if (!row)
26869
+ return { status: 409, code: "voice_leaf_binding_missing" };
26870
+ const current = this.currentLeafKey(deviceId);
26871
+ if (current === null)
26872
+ return { status: 409, code: "voice_no_current_key_package" };
26873
+ if (current === row.key)
26874
+ return { status: 409, code: "voice_leaf_key_current" };
26875
+ if (row.unusableSignaledAt === null)
26876
+ row.unusableSignaledAt = this.now();
26877
+ return { status: 202 };
26878
+ }
26879
+ /** Task 24b test seam: V's committer work as the PR-E server derives it,
26880
+ * for a test's `roster` (the server's roster devices): `adds` = roster
26881
+ * devices not in the mirror; `remove_devices` / `remove_device_leaves` =
26882
+ * roster devices whose binding is signalled unusable (NOT in `adds`: they
26883
+ * are still in the mirror). The re-Add follows only from a later call,
26884
+ * after the Remove landed and the mirror dropped the device. */
26885
+ voiceCommitterWork(channelId, roster) {
26886
+ const mirror = this.membersByChannel.get(channelId) ?? [];
26887
+ const bindings = this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map();
26888
+ const signalled = roster.filter((d) => mirror.includes(d) && bindings.get(d)?.unusableSignaledAt != null);
26889
+ return {
26890
+ adds: roster.filter((d) => !mirror.includes(d)),
26891
+ removes: [],
26892
+ removeDevices: signalled,
26893
+ removeDeviceLeaves: signalled.map((d) => ({
26894
+ deviceId: d,
26895
+ identityId: this.identityByDevice.get(d) ?? "",
26896
+ leafSignatureKeyHex: bindings.get(d).key
26897
+ }))
26898
+ };
26899
+ }
26361
26900
  async acquireCommitLease(_channelId) {
26362
26901
  return "fake-lease-token";
26363
26902
  }
@@ -26450,6 +26989,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
26450
26989
  // Owner-signed tool grants (spec §7.0): like Plan 0's backend, each row carries
26451
26990
  // the envelope's `identity_proof.signature` as `identity_signature` beside the
26452
26991
  // unsigned canonical payload. `omitSignature` models a pre-Plan-0 row.
26992
+ /** `rowMessageId` (#1246 C-1): the server row id to deliver, when it is NOT
26993
+ * the envelope's id. The REST send path keys the row by the envelope id
26994
+ * (connect_messages.py, `MLSMessage(id=message_id)`); the WS `message_mls`
26995
+ * path stores through `store_mls_message`, whose row id is server-generated,
26996
+ * so there the two differ and the envelope id is the sender's free choice. */
26453
26997
  async ingestApplication(envelope, opts) {
26454
26998
  const channelId = envelope.channel_id;
26455
26999
  const canonical = await canonicalEnvelopeBytes(envelope);
@@ -26461,7 +27005,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
26461
27005
  continue;
26462
27006
  this.q(dev).push({
26463
27007
  queue_id: "q-" + this._seq++,
26464
- message_id: envelope.message_id,
27008
+ message_id: opts?.rowMessageId ?? envelope.message_id,
26465
27009
  group_id: groupId,
26466
27010
  message_type: "application",
26467
27011
  sender_device_id: senderDeviceId ?? "",
@@ -26845,6 +27389,7 @@ function openSchema(db) {
26845
27389
  ensureRuntimeInboxDeliveryAttempts(db);
26846
27390
  ensureRuntimeReplyIntent(db);
26847
27391
  ensureRuntimeReplyIntentEnvelopeGroup(db);
27392
+ ensureRuntimeReplyIntentCredit(db);
26848
27393
  ensureGroupLink(db);
26849
27394
  ensureMlsCustody(db);
26850
27395
  ensureToolGrantTables(db);
@@ -26925,6 +27470,19 @@ function ensureRuntimeReplyIntentEnvelopeGroup(db) {
26925
27470
  throw e;
26926
27471
  }
26927
27472
  }
27473
+ function ensureRuntimeReplyIntentCredit(db) {
27474
+ const hasColumn = () => db.prepare("PRAGMA table_info(runtime_reply_intent)").all().some((c) => c.name === "reply_credit");
27475
+ const cols = db.prepare("PRAGMA table_info(runtime_reply_intent)").all();
27476
+ if (cols.length === 0 || cols.some((c) => c.name === "reply_credit"))
27477
+ return;
27478
+ try {
27479
+ db.exec("ALTER TABLE runtime_reply_intent ADD COLUMN reply_credit TEXT");
27480
+ } catch (e) {
27481
+ if (hasColumn())
27482
+ return;
27483
+ throw e;
27484
+ }
27485
+ }
26928
27486
  var RECALL_STATE_COLUMNS = [
26929
27487
  ["recall_enabled", "INTEGER NOT NULL DEFAULT 0"],
26930
27488
  ["recall_mode_epoch", "INTEGER NOT NULL DEFAULT 0"],
@@ -27976,8 +28534,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27976
28534
  this.db.prepare("DELETE FROM runtime_reply_intent WHERE identity_id=? AND updated_at < ?").run(this.id, now - RUNTIME_REPLY_INTENT_RETENTION_MS);
27977
28535
  const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_reply_intent
27978
28536
  (identity_id, scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27979
- sealed_body, sealed_envelope, state, created_at, updated_at)
27980
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now);
28537
+ sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit)
28538
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now, input.replyCredit ?? null);
27981
28539
  const count = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_intent WHERE identity_id=?").get(this.id);
27982
28540
  if (count.n > RUNTIME_REPLY_INTENT_MAX) {
27983
28541
  this.db.prepare(`DELETE FROM runtime_reply_intent
@@ -27987,7 +28545,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27987
28545
  )`).run(this.id, this.id, RUNTIME_REPLY_INTENT_MAX);
27988
28546
  }
27989
28547
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27990
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
28548
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27991
28549
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, input.scope, input.part);
27992
28550
  if (!row)
27993
28551
  throw new Error("reply intent vanished after claim");
@@ -27996,7 +28554,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27996
28554
  /** SEC-06 #18: every intent answering `inReplyTo`, in claim order. */
27997
28555
  async listReplyIntents(inReplyTo) {
27998
28556
  const rows = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27999
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
28557
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
28000
28558
  FROM runtime_reply_intent WHERE identity_id=? AND in_reply_to=?
28001
28559
  ORDER BY created_at ASC, rowid ASC`).all(this.id, inReplyTo);
28002
28560
  const out = [];
@@ -28007,7 +28565,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28007
28565
  /** SEC-06 #18: re-read one intent by its key (null when absent). */
28008
28566
  async getReplyIntent(scope, part) {
28009
28567
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
28010
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
28568
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
28011
28569
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, scope, part);
28012
28570
  return row ? this.openReplyIntent(row) : null;
28013
28571
  }
@@ -28081,7 +28639,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28081
28639
  envelope,
28082
28640
  envelopeGroupId: envelopeGroupFor(envelope, await openOrNull(row.sealed_envelope_group)),
28083
28641
  state: row.state === "sent" ? "sent" : row.state === "skipped" ? "skipped" : "pending",
28084
- createdAt: Number(row.created_at)
28642
+ createdAt: Number(row.created_at),
28643
+ replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null
28085
28644
  };
28086
28645
  }
28087
28646
  async recordMemory(input) {
@@ -29851,8 +30410,8 @@ function decodeReplyBody(bytes) {
29851
30410
  if (b?.kind === "a2a" && b.a2a && typeof b.a2a === "object") return b;
29852
30411
  throw new Error("reply intent body is malformed");
29853
30412
  }
29854
- function replyBodyMac(key, replyId, channelId, body) {
29855
- return createHmac("sha256", key).update(JSON.stringify(["reply-body", replyId, channelId])).update("\0").update(body).digest("hex");
30413
+ function replyBodyMac(key, replyId, channelId, body, credit) {
30414
+ 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");
29856
30415
  }
29857
30416
  function partOrdinal(part) {
29858
30417
  const m = /^p:(\d+)$/.exec(part);
@@ -34693,6 +35252,8 @@ function checkConfiguredPaths(skillsJson, statFn = (p) => {
34693
35252
  }
34694
35253
  const search = skillsJson.search;
34695
35254
  if (search?.keyFile) paths.push(search.keyFile);
35255
+ const weather = skillsJson.weather;
35256
+ if (weather?.keyFile) paths.push(weather.keyFile);
34696
35257
  return paths.map((path) => {
34697
35258
  let st;
34698
35259
  try {
@@ -34718,7 +35279,7 @@ function parseSecretPathsReport(raw) {
34718
35279
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
34719
35280
  const r = raw;
34720
35281
  const f = r.outside_by_field;
34721
- 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;
35282
+ 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;
34722
35283
  return {
34723
35284
  mode: r.mode,
34724
35285
  inside: r.inside,
@@ -34728,7 +35289,8 @@ function parseSecretPathsReport(raw) {
34728
35289
  headers_file: f.headers_file,
34729
35290
  postgres_url_file: f.postgres_url_file,
34730
35291
  search_key_file: f.search_key_file,
34731
- ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
35292
+ ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {},
35293
+ ...f.weather_key_file !== void 0 ? { weather_key_file: f.weather_key_file } : {}
34732
35294
  }
34733
35295
  };
34734
35296
  }
@@ -34808,6 +35370,7 @@ function readLocalReport(home, identityId) {
34808
35370
  has_search_key: hasSearchKey,
34809
35371
  has_llm_key: hasLlmKey,
34810
35372
  has_extract_provider: doc.has_extract_provider === true,
35373
+ has_weather_key: doc.has_weather_key === true,
34811
35374
  ...ownerDmCap !== void 0 ? { owner_dm_search_cap_effective: ownerDmCap } : {},
34812
35375
  legacy_memory: localLegacySources(doc).map(({ path: _path, content: _content, ...source }) => source),
34813
35376
  secret_paths: parseSecretPathsReport(doc.secret_paths)
@@ -34909,6 +35472,19 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
34909
35472
  return removed;
34910
35473
  }
34911
35474
 
35475
+ // src/reply-credit.ts
35476
+ var REPLY_CREDIT_FEATURE = "reply_credit_v1";
35477
+ var WEATHER_CREDIT_LINE = "Weather data: WeatherAPI.com. For general information only.";
35478
+ var WEATHER_CREDIT_SUFFIX = `
35479
+
35480
+ ${WEATHER_CREDIT_LINE}`;
35481
+ function isReplyCredit(v) {
35482
+ return v === "weatherapi";
35483
+ }
35484
+ function stripCreditSuffix(text) {
35485
+ return text.endsWith(WEATHER_CREDIT_SUFFIX) ? text.slice(0, text.length - WEATHER_CREDIT_SUFFIX.length) : text;
35486
+ }
35487
+
34912
35488
  // src/grants/binding.ts
34913
35489
  import { sha256 as sha25611 } from "@noble/hashes/sha256";
34914
35490
 
@@ -34916,9 +35492,10 @@ import { sha256 as sha25611 } from "@noble/hashes/sha256";
34916
35492
  import { ed25519 as ed255195 } from "@noble/curves/ed25519";
34917
35493
  var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
34918
35494
  var MANIFEST_KNOWN_TOOLS = Object.freeze({
34919
- owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query"]),
35495
+ owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query", "weather_forecast", "currency_convert"]),
34920
35496
  // web_extract: owner DM only, never team_room (spec §5.4, D5)
34921
- team_room: Object.freeze(["web_search"])
35497
+ // Weather/currency spec §6.3 (U5): both utility tools in both audiences, each held behind utility_tools_v1 (TOOL_FEATURE).
35498
+ team_room: Object.freeze(["web_search", "weather_forecast", "currency_convert"])
34922
35499
  });
34923
35500
  function manifestSignedBytes(manifest) {
34924
35501
  const domain = new TextEncoder().encode(TOOL_GRANTS_DOMAIN);
@@ -35242,20 +35819,30 @@ function bindTargets(raw, signed, equals) {
35242
35819
  }
35243
35820
  return { written, mismatches };
35244
35821
  }
35245
- function signedManifestTools(manifest, audience, searchProvidersCapable) {
35246
- return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && (t !== "web_extract" || searchProvidersCapable));
35822
+ var NO_FEATURES = Object.freeze(/* @__PURE__ */ new Set());
35823
+ var TOOL_FEATURE = Object.freeze({
35824
+ web_extract: "search_providers_v2",
35825
+ weather_forecast: "utility_tools_v1",
35826
+ currency_convert: "utility_tools_v1"
35827
+ });
35828
+ function toolAllowedByFeatures(tool, features) {
35829
+ if (!Object.hasOwn(TOOL_FEATURE, tool)) return true;
35830
+ return features.has(TOOL_FEATURE[tool]);
35831
+ }
35832
+ function signedManifestTools(manifest, audience, features) {
35833
+ return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && toolAllowedByFeatures(t, features));
35247
35834
  }
35248
- function manifestPolicyFor(manifest, switches, searchProvidersCapable) {
35835
+ function manifestPolicyFor(manifest, switches, features) {
35249
35836
  return {
35250
35837
  v: 1,
35251
35838
  manifest_tools: [.../* @__PURE__ */ new Set([
35252
- ...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", searchProvidersCapable) : [],
35253
- ...switches.room ? signedManifestTools(manifest, "team_room", searchProvidersCapable) : []
35839
+ ...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", features) : [],
35840
+ ...switches.room ? signedManifestTools(manifest, "team_room", features) : []
35254
35841
  ])].sort(),
35255
35842
  secret_path_mode: "enforce"
35256
35843
  };
35257
35844
  }
35258
- function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, searchProvidersCapable = false) {
35845
+ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, features = NO_FEATURES) {
35259
35846
  const mismatches = /* @__PURE__ */ new Set();
35260
35847
  const cfg = manifest.config;
35261
35848
  const out = {};
@@ -35284,7 +35871,8 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35284
35871
  for (const name of Object.keys(files)) {
35285
35872
  if (name !== LLM_SOURCE && !PERSONA_FILES.includes(name)) mismatches.add("files");
35286
35873
  }
35287
- const ownerDmTools = signedManifestTools(manifest, "owner_dm", searchProvidersCapable);
35874
+ const ownerDmTools = signedManifestTools(manifest, "owner_dm", features);
35875
+ const ownerDmSearchTools = ownerDmTools.filter((t) => t === "web_search" || t === "web_extract");
35288
35876
  const sj = isObj2(payload.skills_json) ? payload.skills_json : void 0;
35289
35877
  const eps = bindTargets(sj?.endpoints, manifest.endpoints, endpointEquals);
35290
35878
  const dbs = bindTargets(sj?.databases, manifest.databases, databaseEquals);
@@ -35305,17 +35893,20 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35305
35893
  const usablePrior = cfg.search !== void 0 && prior.search?.provider === cfg.search.provider ? prior.search : void 0;
35306
35894
  searchBlock = cfg.search === void 0 ? void 0 : usablePrior ?? Object.freeze({ provider: cfg.search.provider });
35307
35895
  }
35896
+ const pw = isObj2(sj?.weather) ? sj.weather : void 0;
35897
+ const weatherBlock = typeof pw?.keyFile === "string" && pw.keyFile !== "" ? { keyFile: pw.keyFile } : void 0;
35308
35898
  const skillsDoc = {
35309
35899
  endpoints: eps.written,
35310
35900
  databases: dbs.written,
35311
35901
  flags: [...cfg.flags],
35312
35902
  ...deny !== void 0 || cfg.tools_allow !== void 0 ? { tools: { ...deny !== void 0 ? { deny } : {}, ...cfg.tools_allow !== void 0 ? { allow: [...cfg.tools_allow] } : {} } } : {},
35313
35903
  ...searchBlock ? { search: searchBlock } : {},
35904
+ ...weatherBlock ? { weather: weatherBlock } : {},
35314
35905
  // D31: the marker the runtime keys D24 (iii) on; written under every manifest, true when config.search is signed.
35315
35906
  search_signed: cfg.search !== void 0,
35316
35907
  // D36, D39: the signed owner-DM cap, only when the owner-DM switch is on and the manifest grants an owner-DM tool;
35317
35908
  // otherwise none, so the runtime clamps a signed raise to the default. Never read from the payload.
35318
- ...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
35909
+ ...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmSearchTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
35319
35910
  };
35320
35911
  out["skills.json"] = JSON.stringify(skillsDoc, null, 2) + "\n";
35321
35912
  const signedMemory = { mode: cfg.memory.mode, ...cfg.memory.recall_days !== void 0 ? { recall_days: cfg.memory.recall_days } : {} };
@@ -35346,7 +35937,7 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35346
35937
  mismatches.add("legacy_assign");
35347
35938
  legacyDecisions = decisions.filter((d) => isObj2(d) && d.action === "delete");
35348
35939
  }
35349
- const policy = manifestPolicyFor(manifest, switches, searchProvidersCapable);
35940
+ const policy = manifestPolicyFor(manifest, switches, features);
35350
35941
  return {
35351
35942
  mismatches: CONFIG_BINDING_FIELDS.filter((f) => mismatches.has(f)).sort(),
35352
35943
  targetMismatches: { endpoint: eps.mismatches, database: dbs.mismatches },
@@ -35489,7 +36080,11 @@ var TOOL_ROOM_CLASSES = Object.freeze({
35489
36080
  // D5: spends the owner's key and sends URLs out
35490
36081
  fetch_url: Object.freeze(["egress"]),
35491
36082
  sql_query: Object.freeze(["owner_credential"]),
35492
- http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
36083
+ http_request: Object.freeze(["owner_credential", "egress", "side_effect"]),
36084
+ // Weather/currency (plan audit r1; supersedes spec §8's values): both send room words to an outside host, and weather
36085
+ // spends the owner's key.
36086
+ weather_forecast: Object.freeze(["owner_credential", "egress"]),
36087
+ currency_convert: Object.freeze(["egress"])
35493
36088
  });
35494
36089
  var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
35495
36090
  function isLiveRow(r) {
@@ -37303,7 +37898,7 @@ var BridgeAgent = class _BridgeAgent {
37303
37898
  }
37304
37899
  let bound;
37305
37900
  try {
37306
- bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable());
37901
+ bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures());
37307
37902
  } catch (e) {
37308
37903
  bound = null;
37309
37904
  return this.bindFailed(manifest, payload.memory, e);
@@ -37324,7 +37919,7 @@ var BridgeAgent = class _BridgeAgent {
37324
37919
  try {
37325
37920
  this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, bound.files);
37326
37921
  if (manifest.config.llm === void 0) this.grantWriters.removeManagedLlmSource(this.home, this.cfg.identityId);
37327
- this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable()));
37922
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures()));
37328
37923
  } catch (e) {
37329
37924
  this.manifestBinding = null;
37330
37925
  throw e;
@@ -37378,6 +37973,7 @@ var BridgeAgent = class _BridgeAgent {
37378
37973
  async postRuntimeConfigReport(base, version, skillsJson) {
37379
37974
  const local = readLocalReport(this.home, this.cfg.identityId);
37380
37975
  const reportedSearchProviders = this.searchProvidersCapable();
37976
+ const reportedUtilityTools = this.utilityToolsCapable();
37381
37977
  const report = {
37382
37978
  applied_version: version,
37383
37979
  paths: checkConfiguredPaths(skillsJson),
@@ -37395,6 +37991,9 @@ var BridgeAgent = class _BridgeAgent {
37395
37991
  // Final review I-2 (controller ruling): AND the current search_providers_v2, so the web never shows extract while
37396
37992
  // the bridge withholds web_extract from the runtime.
37397
37993
  has_extract_provider: (local?.has_extract_provider ?? false) && reportedSearchProviders,
37994
+ // Weather/currency L-1, §8 item 5: AND the current utility_tools_v1, so the web never shows a usable weather key
37995
+ // while the bridge withholds weather_forecast from the runtime.
37996
+ has_weather_key: (local?.has_weather_key ?? false) && reportedUtilityTools,
37398
37997
  // D39: the owner-DM daily cap the runtime enforces, passed through when it reported a valid one; never the signed value.
37399
37998
  ...local?.owner_dm_search_cap_effective !== void 0 ? { owner_dm_search_cap_effective: local.owner_dm_search_cap_effective } : {},
37400
37999
  // Same idea, for the BYOK LLM key file: only the runtime can see
@@ -37442,7 +38041,9 @@ var BridgeAgent = class _BridgeAgent {
37442
38041
  // D28 (§11.1 step 3): a separate key, never a value in the capability enum. False with no runtime attached.
37443
38042
  search_providers_v2: reportedSearchProviders,
37444
38043
  // D19 item 4: content-free; the signed provider is one the attached runtime(s) cannot honour.
37445
- search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders)
38044
+ search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders),
38045
+ // Weather/currency §8 item 5: a separate key, false with no runtime attached.
38046
+ utility_tools_v1: reportedUtilityTools
37446
38047
  }
37447
38048
  };
37448
38049
  const reportedVerifyFailures = this.ownerVerifyFailures;
@@ -37464,6 +38065,7 @@ var BridgeAgent = class _BridgeAgent {
37464
38065
  }
37465
38066
  this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
37466
38067
  this.lastReportedSearchProviders = reportedSearchProviders;
38068
+ this.lastReportedUtilityTools = reportedUtilityTools;
37467
38069
  if (version) writeServiceLog("info", "runtime_config.applied", { version });
37468
38070
  else writeServiceLog("info", "runtime_config.legacy_memory_reported");
37469
38071
  }
@@ -37533,7 +38135,7 @@ var BridgeAgent = class _BridgeAgent {
37533
38135
  this.startedAt = Date.now();
37534
38136
  await this.restoreRuntimeInbox();
37535
38137
  await this.loadToolGrantState();
37536
- this.recomputeManifestPolicy(false);
38138
+ this.recomputeManifestPolicy(NO_FEATURES);
37537
38139
  try {
37538
38140
  this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
37539
38141
  } catch (e) {
@@ -38516,13 +39118,13 @@ var BridgeAgent = class _BridgeAgent {
38516
39118
  * absent, because the id is derived.
38517
39119
  */
38518
39120
  async send(channelId, text, messageId, opts = {}) {
38519
- const body = { kind: "text", text };
39121
+ const body = { kind: "text", text: opts.credit !== void 0 ? text + WEATHER_CREDIT_SUFFIX : text };
38520
39122
  if (this.isIdempotentSend(messageId, opts)) return this.trackReplyWork(this.sendIdempotent(channelId, body, opts));
38521
39123
  const id = resolveMessageId(messageId);
38522
39124
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
38523
39125
  const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
38524
39126
  await this.publishBody(channelId, body, id);
38525
- this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
39127
+ this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, text);
38526
39128
  return { message_id: id, status: "sent" };
38527
39129
  }
38528
39130
  /** PR #716 review MEDIUM-5: register reply work so close() can wait for it. */
@@ -38541,7 +39143,7 @@ var BridgeAgent = class _BridgeAgent {
38541
39143
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
38542
39144
  const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
38543
39145
  await this.publishBody(channelId, body, id);
38544
- this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
39146
+ this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, null);
38545
39147
  return { message_id: id, status: "sent" };
38546
39148
  }
38547
39149
  /**
@@ -38674,18 +39276,24 @@ var BridgeAgent = class _BridgeAgent {
38674
39276
  const meta = this.freshMeta(capChannel);
38675
39277
  if (meta && !meta.is_dm) this.enforceRateFloor("room", capChannel);
38676
39278
  }
38677
- /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
38678
- afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture) {
39279
+ /**
39280
+ * Bookkeeping after a CONFIRMED publish of `body` under `id`. `recordText` (weather/currency Task 17) is what a text
39281
+ * reply is RECORDED as: the published text minus any credit line. Every record site below reads `recorded`, never
39282
+ * `body.text`. Every production caller passes it (pinned by reply-credit.test.ts: text, or null for an a2a body);
39283
+ * undefined is left only for direct test calls of an uncredited body, where the published and record texts are equal.
39284
+ */
39285
+ afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture, recordText) {
39286
+ const recorded = recordText ?? (recordText === void 0 && body.kind === "text" ? body.text : "");
38679
39287
  if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
38680
39288
  const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
38681
- const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(body.text);
39289
+ const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(recorded);
38682
39290
  if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
38683
- if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(body.text) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
38684
- this.callBuffer.appendAgent(channelId, body.text, "reply");
39291
+ if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(recorded) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
39292
+ this.callBuffer.appendAgent(channelId, recorded, "reply");
38685
39293
  }
38686
39294
  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";
38687
- if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, body.text, id, modality, purgeEpoch, origin);
38688
- if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, body.text, id, replyCapture ?? null, origin);
39295
+ if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, recorded, id, modality, purgeEpoch, origin);
39296
+ if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, recorded, id, replyCapture ?? null, origin);
38689
39297
  }
38690
39298
  /**
38691
39299
  * Final review bridge I-1 (ledger T25): the agent reply's U11 capture, taken BEFORE the publish await. The clear generation
@@ -38763,14 +39371,14 @@ var BridgeAgent = class _BridgeAgent {
38763
39371
  const k = replyIntentKey({ inReplyTo: opts.inReplyTo, idempotencyKey: opts.idempotencyKey, ordinal: () => 0 });
38764
39372
  if (!k) throw new Error("idempotent send without a key");
38765
39373
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
38766
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
38767
- const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
39374
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit);
39375
+ const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null });
38768
39376
  if (intent.bodyMac !== mac2 || intent.channelId !== channelId || intent.state === "skipped") {
38769
39377
  throw new IdempotencyConflictError();
38770
39378
  }
38771
39379
  return k;
38772
39380
  }
38773
- return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true);
39381
+ return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null);
38774
39382
  });
38775
39383
  return this.publishIntent(key, opts);
38776
39384
  }
@@ -38779,22 +39387,23 @@ var BridgeAgent = class _BridgeAgent {
38779
39387
  * the reply to `inReplyTo`. Caller holds the scope lock. `matchRetry` false
38780
39388
  * after a slot collision: the body must go to a NEW part.
38781
39389
  */
38782
- async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry) {
39390
+ async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit) {
39391
+ const credit = replyCredit ?? void 0;
38783
39392
  const scope = `reply:${inReplyTo}`;
38784
39393
  let latest = null;
38785
39394
  for (const intent of await this.store.listReplyIntents(inReplyTo)) {
38786
39395
  const n = intent.scope === scope ? partOrdinal(intent.part) : null;
38787
39396
  if (n !== null && (latest === null || n > latest.ordinal)) latest = { ordinal: n, intent };
38788
39397
  }
38789
- if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes)) {
39398
+ if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes, credit)) {
38790
39399
  return { scope, part: latest.intent.part, inReplyTo };
38791
39400
  }
38792
39401
  let ordinal = latest ? latest.ordinal + 1 : 0;
38793
39402
  for (let i = 0; i < REPLY_MAX_PART_ADVANCES; i++, ordinal++) {
38794
39403
  const k = { scope, part: `p:${ordinal}`, inReplyTo };
38795
39404
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
38796
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
38797
- const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
39405
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit);
39406
+ const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit });
38798
39407
  if (created) return k;
38799
39408
  if (matchRetry && intent.state !== "skipped" && intent.bodyMac === mac2 && intent.channelId === channelId) return k;
38800
39409
  writeServiceLog("info", "reply.part_slot_taken", { attempt: i + 1 });
@@ -38839,6 +39448,12 @@ var BridgeAgent = class _BridgeAgent {
38839
39448
  }
38840
39449
  const bytes = intent.body;
38841
39450
  const body = decodeReplyBody(bytes);
39451
+ const recordText = this.recordTextOf(intent.replyCredit, body);
39452
+ if (recordText === void 0) {
39453
+ writeServiceLog("error", "reply.intent_integrity_refused");
39454
+ this.store.deletePendingReplyIntent(key.scope, key.part);
39455
+ throw new ReplyIntentIntegrityError();
39456
+ }
38842
39457
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(intent.channelId);
38843
39458
  const replyCapture = this.unifiedReplyCapture(intent.channelId, intent.inReplyTo ?? void 0);
38844
39459
  try {
@@ -38867,14 +39482,17 @@ var BridgeAgent = class _BridgeAgent {
38867
39482
  const inReplyTo = intent.inReplyTo;
38868
39483
  const next = await this.withReplyLock(
38869
39484
  JSON.stringify(["scope", key.scope]),
38870
- () => this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false)
39485
+ () => (
39486
+ // r2 BLOCKER 1: the colliding row's OWN credit (verified above), never `opts`.
39487
+ this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null)
39488
+ )
38871
39489
  );
38872
39490
  return this.publishIntent(next, opts, advances + 1);
38873
39491
  }
38874
39492
  throw e;
38875
39493
  }
38876
39494
  this.store.markReplyIntentSent(key.scope, key.part);
38877
- this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture);
39495
+ this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture, recordText);
38878
39496
  return { message_id: intent.replyMessageId, status: "sent" };
38879
39497
  });
38880
39498
  }
@@ -38884,9 +39502,21 @@ var BridgeAgent = class _BridgeAgent {
38884
39502
  */
38885
39503
  intentVerifies(intent) {
38886
39504
  if (!intent.body) return false;
39505
+ const credit = intent.replyCredit ?? null;
39506
+ if (credit !== null && !isReplyCredit(credit)) return false;
38887
39507
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, intent.channelId, intent);
38888
39508
  if (id !== intent.replyMessageId) return false;
38889
- return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body) === intent.bodyMac;
39509
+ return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0) === intent.bodyMac;
39510
+ }
39511
+ /**
39512
+ * Weather/currency Task 17: what a verified row's reply is RECORDED as. Null credit: the decoded text unchanged (null
39513
+ * for an a2a body). "weatherapi": the text minus the exact credit suffix. Undefined: the row is refused (an unknown
39514
+ * credit, a credited a2a body, or a credited text without the exact suffix).
39515
+ */
39516
+ recordTextOf(credit, body) {
39517
+ if (credit === null) return body.kind === "text" ? body.text : null;
39518
+ if (!isReplyCredit(credit) || body.kind !== "text" || !body.text.endsWith(WEATHER_CREDIT_SUFFIX)) return void 0;
39519
+ return body.text.slice(0, body.text.length - WEATHER_CREDIT_SUFFIX.length);
38890
39520
  }
38891
39521
  /**
38892
39522
  * SEC-06 #18 item 3: a rate-limited reply retries the PUBLISH in-process
@@ -40293,6 +40923,12 @@ var BridgeAgent = class _BridgeAgent {
40293
40923
  * frozen at receipt) AND a window-eligible turn (voiceWindowEligible), whatever the window size. Two call sites: the voice
40294
40924
  * 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;
40295
40925
  * otherwise nothing is written (H-4). Logs are content-free (U17). Never throws.
40926
+ *
40927
+ * Bridge 0.33.1 (architect Q2, C5 not accepted): a turn on a voice channel V carries `parent`, its P from the
40928
+ * per-frame verdict. The scope is ownerScopeForWrite(P) (P's pin and roster, as for a turn on P), the item keeps
40929
+ * channelId = V (so V's own capture still guards it in the store), and P's receipt capture is checked at commit,
40930
+ * inside the memory mutex: a purge or room clear of P since receipt writes nothing. No P capture at receipt, or no
40931
+ * persisted V -> P link (the purge of P reaches V's items only through it): nothing is written.
40296
40932
  */
40297
40933
  async tapUnifiedVoice(m, c) {
40298
40934
  const skip = (reason) => this.logUnifiedTapSkip(reason);
@@ -40301,9 +40937,22 @@ var BridgeAgent = class _BridgeAgent {
40301
40937
  if (m.authorization?.relay_speaker !== "owner" || m.speaker === void 0) return skip("audience");
40302
40938
  if (c.stamp === void 0) return skip("stamp_missing");
40303
40939
  if (c.capture.purgeEpoch < 0) return skip("purged");
40304
- const scopeId = await this.ownerScopeForWrite(m.channel_id);
40940
+ const parent3 = c.parent;
40941
+ const parentCapture = parent3?.capture;
40942
+ if (parent3 === void 0 && this.voiceChannels.isVoice(m.channel_id)) return skip("scope");
40943
+ if (parent3 !== void 0) {
40944
+ if (parentCapture === void 0 || parentCapture.purgeEpoch < 0) return skip("scope");
40945
+ if (this.store.voiceChannelParent(m.channel_id) !== parent3.channelId) return skip("scope");
40946
+ }
40947
+ const scopeId = await this.ownerScopeForWrite(parent3?.channelId ?? m.channel_id);
40305
40948
  if (scopeId === null) return skip("scope");
40306
40949
  if (parseOwnerDmScopeId(scopeId)?.ownerIdentityId !== m.speaker.identityId) return skip("scope");
40950
+ const precommit = parent3 === void 0 || parentCapture === void 0 ? void 0 : () => {
40951
+ const now = this.store.itemCapture(parent3.channelId);
40952
+ if (now.purgeEpoch !== parentCapture.purgeEpoch) return "purged";
40953
+ if (now.roomClearGeneration !== parentCapture.roomClearGeneration) return "room_cleared";
40954
+ return null;
40955
+ };
40307
40956
  const r = await this.items.commit({
40308
40957
  scopeKind: "owner_dm",
40309
40958
  scopeId,
@@ -40314,7 +40963,8 @@ var BridgeAgent = class _BridgeAgent {
40314
40963
  source: "owner_voice",
40315
40964
  trust: OWNER_VOICE_TRUST,
40316
40965
  text: typeof m.text === "string" ? m.text : "",
40317
- expect: { ...c.capture, clearGeneration: c.stamp }
40966
+ expect: { ...c.capture, clearGeneration: c.stamp },
40967
+ ...precommit ? { precommit } : {}
40318
40968
  });
40319
40969
  return r.ok ? { ok: true } : { ok: false, reason: r.reason };
40320
40970
  } catch {
@@ -42493,11 +43143,20 @@ var BridgeAgent = class _BridgeAgent {
42493
43143
  const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
42494
43144
  const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
42495
43145
  const recallModeEpochAtReceipt = this.store.recallState().modeEpoch;
43146
+ const unifiedVoiceParentAt = (p) => {
43147
+ const cap = itemCaptureAtReceipt.get(p);
43148
+ return { channelId: p, capture: cap === void 0 ? void 0 : { ...cap, purgeEpoch: purgeEpochAtReceipt.get(p) ?? -1 } };
43149
+ };
42496
43150
  for (const m of entries) {
42497
43151
  this.lastInboundAt.set(m.channelId, receivedAt);
42498
43152
  if (m.voiceMode === true) this.heartbeat.noteVoiceActivity();
42499
43153
  if (!purgeEpochAtReceipt.has(m.channelId)) purgeEpochAtReceipt.set(m.channelId, this.store.conversationPurgeEpoch(m.channelId));
42500
43154
  if (this.unifiedOn && !itemCaptureAtReceipt.has(m.channelId)) itemCaptureAtReceipt.set(m.channelId, this.store.itemCapture(m.channelId));
43155
+ const receiptParent = this.voiceChannels.parentOf(m.channelId);
43156
+ if (receiptParent !== void 0) {
43157
+ if (!purgeEpochAtReceipt.has(receiptParent)) purgeEpochAtReceipt.set(receiptParent, this.store.conversationPurgeEpoch(receiptParent));
43158
+ if (this.unifiedOn && !itemCaptureAtReceipt.has(receiptParent)) itemCaptureAtReceipt.set(receiptParent, this.store.itemCapture(receiptParent));
43159
+ }
42501
43160
  this.recallReceipt.set(m.messageId, { purgeEpoch: purgeEpochAtReceipt.get(m.channelId), modeEpoch: recallModeEpochAtReceipt });
42502
43161
  receiptIds.push(m.messageId);
42503
43162
  const stamp = voiceStamp.get(m.channelId);
@@ -42843,6 +43502,7 @@ var BridgeAgent = class _BridgeAgent {
42843
43502
  pending: Promise.resolve(),
42844
43503
  purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
42845
43504
  itemCapture: itemCaptureAtReceipt.get(m.channelId),
43505
+ ...voiceParent !== void 0 ? { voiceParent: unifiedVoiceParentAt(voiceParent) } : {},
42846
43506
  bufferGeneration
42847
43507
  };
42848
43508
  this.voiceCoalescing.set(windowKey, win);
@@ -42873,7 +43533,7 @@ var BridgeAgent = class _BridgeAgent {
42873
43533
  const merged = { ...held, text, memory: windowMemory };
42874
43534
  if (this.unifiedOn) {
42875
43535
  const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
42876
- const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
43536
+ const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 }, ...win.voiceParent ? { parent: win.voiceParent } : {} });
42877
43537
  this.unifiedWork.add(work);
42878
43538
  void work.finally(() => this.unifiedWork.delete(work));
42879
43539
  }
@@ -42963,7 +43623,11 @@ var BridgeAgent = class _BridgeAgent {
42963
43623
  );
42964
43624
  if (windowEligible && turnAuthorization.relay_speaker === "owner") {
42965
43625
  const snap = itemCaptureAtReceipt.get(m.channelId) ?? { ...this.store.itemCapture(m.channelId), purgeEpoch: -1 };
42966
- const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), { stamp: this.ingestGeneration.get(m.messageId), capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 } });
43626
+ const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
43627
+ stamp: this.ingestGeneration.get(m.messageId),
43628
+ capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 },
43629
+ ...voiceParent !== void 0 ? { parent: unifiedVoiceParentAt(voiceParent) } : {}
43630
+ });
42967
43631
  this.unifiedWork.add(work);
42968
43632
  void work.finally(() => this.unifiedWork.delete(work));
42969
43633
  }
@@ -42976,7 +43640,7 @@ var BridgeAgent = class _BridgeAgent {
42976
43640
  this.memory,
42977
43641
  m.channelId,
42978
43642
  m.envelope.sender_id,
42979
- parsed.text,
43643
+ m.envelope.sender_id === this.cfg.identityId ? stripCreditSuffix(parsed.text) : parsed.text,
42980
43644
  m.messageId,
42981
43645
  m.voiceMode ? "voice" : "text",
42982
43646
  purgeEpochAtTap
@@ -43774,21 +44438,49 @@ ${channelId}`;
43774
44438
  */
43775
44439
  searchProvidersSockets = /* @__PURE__ */ new Map();
43776
44440
  /**
43777
- * The D28 value the files were last recomputed for; null until the first socket observation, so that observation
43778
- * always recomputes (final review I-1), even when it is non-capable.
44441
+ * Weather/currency U6 (H-1): the same AND-over-sockets state for utility_tools_v1, kept separately per socket, so a
44442
+ * runtime that declares search_providers_v2 but not utility_tools_v1 (0.14.0, 0.14.1) keeps web_extract and never
44443
+ * sees a utility name.
44444
+ */
44445
+ utilityToolsSockets = /* @__PURE__ */ new Map();
44446
+ /**
44447
+ * The (search, utility) tuple the files were last recomputed for; null until the first socket observation, so that
44448
+ * observation always recomputes (final review I-1), even when it is non-capable. One trigger keyed on BOTH values.
43779
44449
  */
43780
- searchProvidersState = null;
44450
+ capabilityState = null;
43781
44451
  searchCapabilityRefetchTimer = null;
43782
44452
  /** When the current burst's first change happened (valid while the timer is set). */
43783
44453
  searchCapabilityBurstStart = 0;
43784
44454
  /** The D28 value in the last report posted (null until one is); a change needs a refetch to reach the web. */
43785
44455
  lastReportedSearchProviders = null;
44456
+ /** U6: the utility_tools_v1 value in the last report posted (null until one is). */
44457
+ lastReportedUtilityTools = null;
43786
44458
  noteSearchProvidersSocket(token, declared) {
43787
44459
  this.searchProvidersSockets.set(token, declared);
43788
- this.onSearchProvidersMaybeChanged();
44460
+ this.onCapabilityMaybeChanged();
43789
44461
  }
43790
44462
  clearSearchProvidersSocket(token) {
43791
- if (this.searchProvidersSockets.delete(token)) this.onSearchProvidersMaybeChanged();
44463
+ if (this.searchProvidersSockets.delete(token)) this.onCapabilityMaybeChanged();
44464
+ }
44465
+ noteUtilityToolsSocket(token, declared) {
44466
+ this.utilityToolsSockets.set(token, declared);
44467
+ this.onCapabilityMaybeChanged();
44468
+ }
44469
+ clearUtilityToolsSocket(token) {
44470
+ if (this.utilityToolsSockets.delete(token)) this.onCapabilityMaybeChanged();
44471
+ }
44472
+ /** U6: every attached runtime socket declared utility_tools_v1; no socket attached means absent (false). */
44473
+ utilityToolsCapable() {
44474
+ if (this.utilityToolsSockets.size === 0) return false;
44475
+ for (const v of this.utilityToolsSockets.values()) if (!v) return false;
44476
+ return true;
44477
+ }
44478
+ /** U6: the features every attached runtime socket declared, for the policy functions. */
44479
+ capabilityFeatures() {
44480
+ const out = /* @__PURE__ */ new Set();
44481
+ if (this.searchProvidersCapable()) out.add("search_providers_v2");
44482
+ if (this.utilityToolsCapable()) out.add("utility_tools_v1");
44483
+ return out;
43792
44484
  }
43793
44485
  /** D28: every attached runtime socket declared search_providers_v2; no socket attached means absent (false). */
43794
44486
  searchProvidersCapable() {
@@ -43815,14 +44507,14 @@ ${channelId}`;
43815
44507
  * D35 (P-4): on a capability change, rewrite manifest-policy.json at once from the stored manifest, the kill
43816
44508
  * switches and the new capability, then a debounced refetch (one per burst) only when needed.
43817
44509
  */
43818
- onSearchProvidersMaybeChanged() {
43819
- const capable = this.searchProvidersCapable();
43820
- const prev = this.searchProvidersState;
43821
- if (capable === prev) return;
43822
- this.searchProvidersState = capable;
43823
- this.recomputeManifestPolicy(capable);
44510
+ onCapabilityMaybeChanged() {
44511
+ const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable() };
44512
+ const prev = this.capabilityState;
44513
+ if (prev !== null && prev.search === next.search && prev.utility === next.utility) return;
44514
+ this.capabilityState = next;
44515
+ this.recomputeManifestPolicy(this.capabilityFeatures());
43824
44516
  if (this.closingDown) return;
43825
- if (prev === null && !capable) return;
44517
+ if (prev === null && !next.search && !next.utility) return;
43826
44518
  const now = Date.now();
43827
44519
  if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
43828
44520
  else this.searchCapabilityBurstStart = now;
@@ -43840,7 +44532,7 @@ ${channelId}`;
43840
44532
  searchCapabilityRefetchNeeded() {
43841
44533
  if (!this.bootConfigSettled || this.closingDown) return false;
43842
44534
  const inForce = (this.cfg.ownerGrantPin?.pin ?? null) !== null && this.toolGrantManifest !== null;
43843
- return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable();
44535
+ return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable();
43844
44536
  }
43845
44537
  /**
43846
44538
  * D35: the local recompute. Widening (web_extract back in) only while the binding for the in-force manifest stands,
@@ -43848,15 +44540,16 @@ ${channelId}`;
43848
44540
  * lists web_extract loses it at once, so an older runtime never reads it as unreadable (which drops every grant).
43849
44541
  * Content-free; a write failure is logged and the debounced refetch tries again.
43850
44542
  */
43851
- recomputeManifestPolicy(capable) {
44543
+ recomputeManifestPolicy(features) {
43852
44544
  const pin = this.cfg.ownerGrantPin?.pin ?? null;
43853
44545
  const manifest = pin !== null ? this.toolGrantManifest : null;
43854
44546
  try {
43855
44547
  if (manifest !== null && this.manifestBinding !== null && this.manifestBinding.counter === manifest.counter) {
43856
- this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, capable));
44548
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, features));
43857
44549
  return;
43858
44550
  }
43859
- if (capable) return;
44551
+ const withheld = new Set(Object.keys(TOOL_FEATURE).filter((t) => !toolAllowedByFeatures(t, features)));
44552
+ if (withheld.size === 0) return;
43860
44553
  const path = join19(runtimeConfigDir(this.home, this.cfg.identityId), MANIFEST_POLICY_FILE);
43861
44554
  if (!existsSync5(path)) return;
43862
44555
  let doc;
@@ -43866,10 +44559,10 @@ ${channelId}`;
43866
44559
  return;
43867
44560
  }
43868
44561
  const tools = doc?.manifest_tools;
43869
- if (!Array.isArray(tools) || !tools.includes("web_extract")) return;
44562
+ if (!Array.isArray(tools) || !tools.some((t) => typeof t === "string" && withheld.has(t))) return;
43870
44563
  this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, {
43871
44564
  v: 1,
43872
- manifest_tools: tools.filter((t) => typeof t === "string" && t !== "web_extract"),
44565
+ manifest_tools: tools.filter((t) => typeof t === "string" && !withheld.has(t)),
43873
44566
  secret_path_mode: "enforce"
43874
44567
  });
43875
44568
  } catch (e) {
@@ -44260,6 +44953,9 @@ var BRIDGE_ERRORS = {
44260
44953
  // C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
44261
44954
  // session (WS 4409), so this copy stopped. Every local route but /healthz.
44262
44955
  session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
44956
+ // Weather/currency Task 17 (OQ-P1): a send frame's credit is not the closed value, or rides an a2a frame. TERMINAL:
44957
+ // the inbound is retired, so the model (and the weather call) never runs again for it.
44958
+ reply_credit_unknown: { http: 400, message: "reply credit not recognised; not sent" },
44263
44959
  internal: { http: 500, message: "internal error" }
44264
44960
  };
44265
44961
  function errorBody(code, detail) {
@@ -44289,6 +44985,7 @@ function withConsumerGrants(m, perToolConsumer) {
44289
44985
  }
44290
44986
  var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
44291
44987
  var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
44988
+ var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
44292
44989
  var CONSTANT_TOOL_NAMES = Object.freeze({
44293
44990
  owner_dm: Object.freeze(["current_time", "web_search"]),
44294
44991
  team_room: Object.freeze(["current_time"])
@@ -44320,6 +45017,15 @@ function withSearchCapability(m, consumerHasFeature, signedProvider) {
44320
45017
  if (kept.length === a.tool_grants.length) return m;
44321
45018
  return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
44322
45019
  }
45020
+ var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
45021
+ function withUtilityCapability(m, consumerHasFeature) {
45022
+ if (consumerHasFeature) return m;
45023
+ const a = m.authorization;
45024
+ if (!a) return m;
45025
+ const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
45026
+ if (kept.length === a.tool_grants.length) return m;
45027
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
45028
+ }
44323
45029
 
44324
45030
  // src/http-server.ts
44325
45031
  function writeSse(res, msgs, onWriteError) {
@@ -44343,7 +45049,7 @@ data: ${JSON.stringify(msgs[i])}
44343
45049
  function deliverSseBatch(res, batch, agent, onWriteError) {
44344
45050
  const withheld = agent.harnessGrantsWithheld();
44345
45051
  const provider = agent.signedSearchProvider();
44346
- const left3 = writeSse(res, batch.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)), onWriteError);
45052
+ const left3 = writeSse(res, batch.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)), onWriteError);
44347
45053
  const leftIds = new Set(left3.map((message) => message.message_id));
44348
45054
  agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
44349
45055
  if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
@@ -44375,7 +45081,7 @@ var OTHER_CAP = 8 * 1024;
44375
45081
  var a2aSchema = z3.object({
44376
45082
  a2a_version: z3.string().min(1),
44377
45083
  kind: z3.enum(["message", "task", "artifact"]),
44378
- object: z3.record(z3.unknown())
45084
+ object: z3.record(z3.string(), z3.unknown())
44379
45085
  });
44380
45086
  var sendAttachmentSchema = z3.object({
44381
45087
  channel_id: z3.string().min(1),
@@ -44617,7 +45323,7 @@ function createHttpServer(agent, opts) {
44617
45323
  const drained = await agent.drain(channel, wait);
44618
45324
  const withheld = agent.harnessGrantsWithheld();
44619
45325
  const provider = agent.signedSearchProvider();
44620
- return json(res, 200, drained.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)));
45326
+ return json(res, 200, drained.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)));
44621
45327
  }
44622
45328
  if (method === "GET" && path === "/v1/events") {
44623
45329
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -44792,6 +45498,7 @@ function attachAgentWebSocket(server, agent, opts) {
44792
45498
  perToolGrants: false,
44793
45499
  manifestGrants: false,
44794
45500
  searchProviders: false,
45501
+ utilityTools: false,
44795
45502
  // helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
44796
45503
  deliveryOpen: helloWaitMs <= 0,
44797
45504
  deliveryToken: /* @__PURE__ */ new Map(),
@@ -44845,14 +45552,14 @@ function attachAgentWebSocket(server, agent, opts) {
44845
45552
  continue;
44846
45553
  }
44847
45554
  const out = withConsumerNotes(
44848
- withSearchCapability(
45555
+ withUtilityCapability(withSearchCapability(
44849
45556
  withManifestCapability(
44850
45557
  withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
44851
45558
  session.manifestGrants
44852
45559
  ),
44853
45560
  session.searchProviders,
44854
45561
  agent.signedSearchProvider()
44855
- ),
45562
+ ), session.utilityTools),
44856
45563
  batch[i],
44857
45564
  session,
44858
45565
  agent
@@ -44909,6 +45616,7 @@ function attachAgentWebSocket(server, agent, opts) {
44909
45616
  if (session.perToolGrants) agent.clearPerToolConsumer(session);
44910
45617
  if (session.manifestGrants) agent.clearManifestConsumer(session);
44911
45618
  agent.clearSearchProvidersSocket(session);
45619
+ agent.clearUtilityToolsSocket(session);
44912
45620
  };
44913
45621
  current = { sock, cleanup, sweepStale };
44914
45622
  sock.on("close", cleanup);
@@ -44949,7 +45657,10 @@ function sendJson(sock, body) {
44949
45657
  } catch {
44950
45658
  }
44951
45659
  }
45660
+ var ReplyCreditUnknownError = class extends Error {
45661
+ };
44952
45662
  function sendErrorCode(e) {
45663
+ if (e instanceof ReplyCreditUnknownError) return "reply_credit_unknown";
44953
45664
  if (e instanceof ChannelPendingError) return "channel_pending";
44954
45665
  if (e instanceof RateLimitedError) return "rate_limited";
44955
45666
  if (e instanceof AgentPausedSendError) return "agent_paused";
@@ -45010,6 +45721,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45010
45721
  agent.noteConsumerCapability(manifest ? "ok" : session.harnessClaimed ? "harness_consumer" : "missing");
45011
45722
  session.searchProviders = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(SEARCH_PROVIDERS_FEATURE);
45012
45723
  agent.noteSearchProvidersSocket(session, session.searchProviders);
45724
+ session.utilityTools = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(UTILITY_TOOLS_FEATURE);
45725
+ agent.noteUtilityToolsSocket(session, session.utilityTools);
45013
45726
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
45014
45727
  agent.noteNotesConsumer();
45015
45728
  }
@@ -45017,7 +45730,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45017
45730
  if (session.recallConsumer) agent.noteRecallConsumer();
45018
45731
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
45019
45732
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
45020
- sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE] });
45733
+ 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] });
45021
45734
  if (!session.deliveryOpen) {
45022
45735
  session.deliveryOpen = true;
45023
45736
  flush();
@@ -45046,7 +45759,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45046
45759
  }
45047
45760
  const replyFailed = (e, inReplyTo) => {
45048
45761
  const code = sendErrorCode(e);
45049
- if ((code === "agent_paused" || code === "send_forbidden") && typeof inReplyTo === "string") {
45762
+ const terminal = code === "agent_paused" || code === "send_forbidden" || code === "reply_credit_unknown";
45763
+ if (terminal && typeof inReplyTo === "string") {
45050
45764
  inflight.delete(inReplyTo);
45051
45765
  session.deliveryToken.delete(inReplyTo);
45052
45766
  session.shownRecall.delete(inReplyTo);
@@ -45054,7 +45768,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45054
45768
  agent.ackRuntimeMessages([inReplyTo]);
45055
45769
  }
45056
45770
  sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
45057
- if (code === "agent_paused" || code === "send_forbidden") flush();
45771
+ if (terminal) flush();
45058
45772
  };
45059
45773
  if (m.type === "send") {
45060
45774
  if (typeof m.channel_id !== "string" || !m.channel_id) {
@@ -45071,8 +45785,14 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45071
45785
  }
45072
45786
  const record = typeof m.in_reply_to === "string" ? inflight.get(m.in_reply_to) : void 0;
45073
45787
  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);
45788
+ if (m.credit !== void 0 && (m.a2a !== void 0 || !isReplyCredit(m.credit))) {
45789
+ writeServiceLog("error", "reply.credit_unknown");
45790
+ replyFailed(new ReplyCreditUnknownError(), m.in_reply_to);
45791
+ return;
45792
+ }
45074
45793
  const sendOpts = {
45075
45794
  agentTurn: isAgentTurn(agent, m),
45795
+ ...isReplyCredit(m.credit) ? { credit: m.credit } : {},
45076
45796
  ...typeof m.in_reply_to === "string" ? { inReplyTo: m.in_reply_to } : {},
45077
45797
  ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {},
45078
45798
  ...callBufferReply ? { callBufferReply: true } : {}