@evomap/evolver 1.91.2 → 1.92.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +13 -7
  2. package/index.js +150 -71
  3. package/package.json +2 -1
  4. package/src/adapters/scripts/_runtimePaths.js +10 -1
  5. package/src/adapters/scripts/evolver-session-end.js +10 -1
  6. package/src/canonicalIdentityLock.js +455 -0
  7. package/src/evolve/guards.js +1 -1
  8. package/src/evolve/pipeline/collect.js +1 -1
  9. package/src/evolve/pipeline/dispatch.js +1 -1
  10. package/src/evolve/pipeline/enrich.js +1 -1
  11. package/src/evolve/pipeline/hub.js +1 -1
  12. package/src/evolve/pipeline/select.js +1 -1
  13. package/src/evolve/pipeline/signals.js +1 -1
  14. package/src/evolve/utils.js +1 -1
  15. package/src/evolve.js +1 -1
  16. package/src/gep/a2aProtocol.js +1 -5175
  17. package/src/gep/antiAbuseTelemetry.js +1 -233
  18. package/src/gep/assetStore.js +56 -12
  19. package/src/gep/autoDistillConv.js +1 -205
  20. package/src/gep/autoDistillLlm.js +1 -315
  21. package/src/gep/candidateEval.js +1 -92
  22. package/src/gep/candidates.js +1 -1
  23. package/src/gep/contentHash.js +1 -30
  24. package/src/gep/conversationDistiller.js +1 -270
  25. package/src/gep/conversationSniffer.js +1 -266
  26. package/src/gep/crypto.js +1 -93
  27. package/src/gep/curriculum.js +1 -1
  28. package/src/gep/deviceId.js +1 -218
  29. package/src/gep/envFingerprint.js +1 -118
  30. package/src/gep/epigenetics.js +1 -31
  31. package/src/gep/execBridge.js +1 -712
  32. package/src/gep/explore.js +1 -289
  33. package/src/gep/hash.js +1 -15
  34. package/src/gep/hubFetch.js +1 -672
  35. package/src/gep/hubReview.js +1 -212
  36. package/src/gep/hubSearch.js +1 -544
  37. package/src/gep/hubVerify.js +1 -306
  38. package/src/gep/learningSignals.js +1 -1
  39. package/src/gep/memoryGraph.js +1 -1449
  40. package/src/gep/memoryGraphAdapter.js +1 -203
  41. package/src/gep/mutation.js +1 -1
  42. package/src/gep/narrativeMemory.js +1 -1
  43. package/src/gep/openPRRegistry.js +1 -205
  44. package/src/gep/personality.js +1 -1
  45. package/src/gep/policyCheck.js +1 -599
  46. package/src/gep/prompt.js +1 -1
  47. package/src/gep/recallInject.js +1 -409
  48. package/src/gep/recallVerifier.js +1 -318
  49. package/src/gep/reflection.js +1 -1
  50. package/src/gep/savingsCore.js +1 -1
  51. package/src/gep/schemas/gene.js +2 -0
  52. package/src/gep/selector.js +1 -1
  53. package/src/gep/skillDistiller.js +1 -1294
  54. package/src/gep/solidify.js +1 -1
  55. package/src/gep/strategy.js +1 -136
  56. package/src/gep/syncAsset.js +1 -0
  57. package/src/gep/tokenSavings.js +1 -1
  58. package/src/gep/trajectoryExport.js +1 -3841
  59. package/src/gep/workspaceKeychain.js +1 -174
  60. package/src/proxy/extensions/traceControl.js +1 -123
  61. package/src/proxy/index.js +136 -8
  62. package/src/proxy/inject.js +1 -52
  63. package/src/proxy/lifecycle/manager.js +279 -18
  64. package/src/proxy/mailbox/state.js +60 -29
  65. package/src/proxy/trace/extractor.js +1 -2355
  66. package/src/proxy/trace/usage.js +1 -105
@@ -6,6 +6,7 @@ const { PROXY_PROTOCOL_VERSION } = require('../mailbox/store');
6
6
  const { readMailboxStateFile } = require('../mailbox/state');
7
7
  const { buildEnvelope } = require('../envelope');
8
8
  const crypto = require('crypto');
9
+ const { acquireCanonicalIdentityLock } = require('../../canonicalIdentityLock');
9
10
  const {
10
11
  hubFetch,
11
12
  hubUnreachableBackoffMs,
@@ -146,6 +147,175 @@ function _readLegacyNodeId() {
146
147
  return null;
147
148
  }
148
149
 
150
+ const CANONICAL_CREDENTIAL_SIBLINGS = Object.freeze([
151
+ 'node_secret',
152
+ 'node_secret_version',
153
+ 'node_secret_source',
154
+ 'node_secret_env_suppressed',
155
+ ]);
156
+
157
+ function _readValidNodeIdFile(file) {
158
+ try {
159
+ if (!fs.existsSync(file)) return null;
160
+ const value = fs.readFileSync(file, 'utf8').trim();
161
+ return NODE_ID_RE.test(value) ? value : null;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ function _snapshotCanonicalCredentials(nodeIdFile) {
168
+ const snapshot = new Map();
169
+ const dir = path.dirname(nodeIdFile);
170
+ for (const name of CANONICAL_CREDENTIAL_SIBLINGS) {
171
+ const file = path.join(dir, name);
172
+ try {
173
+ snapshot.set(file, fs.readFileSync(file));
174
+ } catch (e) {
175
+ if (e && e.code === 'ENOENT') {
176
+ snapshot.set(file, null);
177
+ continue;
178
+ }
179
+ return null;
180
+ }
181
+ }
182
+ return snapshot;
183
+ }
184
+
185
+ function _credentialSnapshotHasData(snapshot) {
186
+ if (!(snapshot instanceof Map)) return false;
187
+ for (const content of snapshot.values()) {
188
+ if (content !== null) return true;
189
+ }
190
+ return false;
191
+ }
192
+
193
+ function _clearCanonicalCredentials(nodeIdFile) {
194
+ const dir = path.dirname(nodeIdFile);
195
+ let cleared = true;
196
+ for (const name of CANONICAL_CREDENTIAL_SIBLINGS) {
197
+ const file = path.join(dir, name);
198
+ try {
199
+ if (fs.existsSync(file)) fs.unlinkSync(file);
200
+ } catch {
201
+ cleared = false;
202
+ }
203
+ try {
204
+ if (fs.existsSync(file)) cleared = false;
205
+ } catch {
206
+ cleared = false;
207
+ }
208
+ }
209
+ return cleared;
210
+ }
211
+
212
+ function _preparePrivateFile(file, content) {
213
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
214
+ let fd = null;
215
+ try {
216
+ fd = fs.openSync(tmp, 'wx', 0o600);
217
+ fs.writeFileSync(fd, content);
218
+ fs.fsyncSync(fd);
219
+ fs.closeSync(fd);
220
+ fd = null;
221
+ return tmp;
222
+ } catch (e) {
223
+ if (fd !== null) {
224
+ try { fs.closeSync(fd); } catch { /* preserve the original write error */ }
225
+ }
226
+ try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
227
+ throw e;
228
+ }
229
+ }
230
+
231
+ function _commitPreparedFile(tmp, file) {
232
+ if (process.platform === 'win32') {
233
+ try { fs.unlinkSync(file); } catch (e) {
234
+ if (!e || e.code !== 'ENOENT') throw e;
235
+ }
236
+ }
237
+ fs.renameSync(tmp, file);
238
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort on Windows */ }
239
+ }
240
+
241
+ function _restoreCanonicalCredentials(snapshot) {
242
+ if (!(snapshot instanceof Map)) return false;
243
+ for (const [file, content] of snapshot.entries()) {
244
+ try {
245
+ if (content === null) {
246
+ try { fs.unlinkSync(file); } catch (e) {
247
+ if (!e || e.code !== 'ENOENT') return false;
248
+ }
249
+ } else {
250
+ const tmp = _preparePrivateFile(file, content);
251
+ try {
252
+ _commitPreparedFile(tmp, file);
253
+ } finally {
254
+ try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
255
+ }
256
+ }
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+ for (const [file, content] of snapshot.entries()) {
262
+ try {
263
+ if (content === null) {
264
+ if (fs.existsSync(file)) return false;
265
+ } else if (!fs.readFileSync(file).equals(content)) {
266
+ return false;
267
+ }
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+ return true;
273
+ }
274
+
275
+ function _replaceNodeIdFile(file, id) {
276
+ const expected = Buffer.from(id, 'utf8');
277
+ const tmp = _preparePrivateFile(file, expected);
278
+ try {
279
+ _commitPreparedFile(tmp, file);
280
+ } finally {
281
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
282
+ }
283
+ if (!fs.readFileSync(file).equals(expected)) {
284
+ throw new Error('node_id replacement verification failed');
285
+ }
286
+ }
287
+
288
+ function _failClosedCanonicalTuple(nodeIdFile) {
289
+ const credentialsCleared = _clearCanonicalCredentials(nodeIdFile);
290
+ if (!credentialsCleared) {
291
+ // Keep an invalid owner marker in place when an orphan credential cannot
292
+ // be removed. A missing node_id would let a later exclusive claim turn
293
+ // the orphan into a readable credential for an unrelated node.
294
+ try { _replaceNodeIdFile(nodeIdFile, 'invalid'); } catch { /* verified below */ }
295
+ try {
296
+ return _readValidNodeIdFile(nodeIdFile) === null && fs.existsSync(nodeIdFile);
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+ try { fs.unlinkSync(nodeIdFile); } catch (e) {
302
+ if (!e || e.code !== 'ENOENT') {
303
+ try { _replaceNodeIdFile(nodeIdFile, 'invalid'); } catch { /* verified below */ }
304
+ }
305
+ }
306
+ return _readValidNodeIdFile(nodeIdFile) === null;
307
+ }
308
+
309
+ function _rollbackCanonicalNodeTransition(nodeIdFile, previousId, snapshot) {
310
+ try { _replaceNodeIdFile(nodeIdFile, previousId); } catch { /* fail closed below */ }
311
+ if (_readValidNodeIdFile(nodeIdFile) === previousId) {
312
+ if (_restoreCanonicalCredentials(snapshot)) return true;
313
+ }
314
+ // Without an exact A snapshot, neither A nor B may remain claimable.
315
+ _failClosedCanonicalTuple(nodeIdFile);
316
+ return false;
317
+ }
318
+
149
319
  // Mirror of src/gep/a2aProtocol.js `_persistNodeId`. Pure-proxy daemons
150
320
  // (EVOMAP_PROXY=1, no a2aProtocol heartbeat thread) mint their own
151
321
  // node_id and ONLY persist it to MailboxStore state.json. The legacy
@@ -177,25 +347,31 @@ function _readLegacyNodeId() {
177
347
  // MailboxStore state.json (`evolver reset-local-secret`).
178
348
  function _persistLegacyNodeId(id) {
179
349
  if (!id || !NODE_ID_RE.test(id)) return;
350
+ const canonicalTarget = getEvomapPath('node_id');
180
351
  const targets = [
181
- getEvomapPath('node_id'),
352
+ canonicalTarget,
182
353
  path.resolve(__dirname, '..', '..', '..', '.evomap_node_id'),
183
354
  ];
184
355
  // Try targets in order until one succeeds, matching the read order in
185
356
  // _readLegacyNodeId. We only need ONE persistent copy; once the home
186
357
  // path takes the write, the install-root path is unused.
187
358
  for (const file of targets) {
188
- try {
189
- // Skip if the file already matches — common steady-state path,
190
- // saves a syscall storm under heartbeat backoff doubling.
359
+ let prepared = null;
360
+ let releaseCanonicalLock = null;
361
+ if (file === canonicalTarget) {
191
362
  try {
192
- if (fs.existsSync(file)) {
193
- const existing = fs.readFileSync(file, 'utf8').trim();
194
- if (existing === id) return;
195
- }
363
+ releaseCanonicalLock = acquireCanonicalIdentityLock(file);
196
364
  } catch {
197
- // Unreadable -- treat as missing and try to write.
365
+ // Never fall through to the install-local identity while another
366
+ // process may be mutating the canonical tuple.
367
+ return;
198
368
  }
369
+ }
370
+ try {
371
+ // Skip if the file already matches — common steady-state path,
372
+ // saves a syscall storm under heartbeat backoff doubling.
373
+ const existing = _readValidNodeIdFile(file);
374
+ if (existing === id) return;
199
375
  const dir = path.dirname(file);
200
376
  try {
201
377
  if (!fs.existsSync(dir)) {
@@ -207,19 +383,78 @@ function _persistLegacyNodeId(id) {
207
383
  // may still work.
208
384
  continue;
209
385
  }
210
- // Atomic write: a sibling evolver process (mixed-mode upgrade, two
211
- // proxy daemons started by hand) could otherwise race on this
212
- // path and leave a half-written file. Matches the pattern in
213
- // a2aProtocol.js `_persistNodeSecret`.
214
- const tmp = file + '.' + process.pid + '.tmp';
215
- fs.writeFileSync(tmp, id, { encoding: 'utf8', mode: 0o600 });
216
- fs.renameSync(tmp, file);
386
+ const isCanonicalTransition = file === canonicalTarget
387
+ && existing !== id;
388
+ const credentialSnapshot = isCanonicalTransition
389
+ ? _snapshotCanonicalCredentials(file)
390
+ : null;
391
+ if (isCanonicalTransition && !credentialSnapshot) return;
392
+ const hasCanonicalCredentials = _credentialSnapshotHasData(credentialSnapshot);
393
+ const requiresCredentialInvalidation = isCanonicalTransition
394
+ && (Boolean(existing) || hasCanonicalCredentials);
395
+ const canRestorePreviousOwner = Boolean(existing);
396
+
397
+ // Prepare the exact bytes before invalidating A's credential tuple.
398
+ // The same file is committed after clear-before; it is not rewritten.
399
+ prepared = _preparePrivateFile(file, Buffer.from(id, 'utf8'));
400
+ if (requiresCredentialInvalidation && !_clearCanonicalCredentials(file)) {
401
+ try { fs.unlinkSync(prepared); } catch { /* best-effort cleanup */ }
402
+ prepared = null;
403
+ const recovered = canRestorePreviousOwner
404
+ ? _rollbackCanonicalNodeTransition(file, existing, credentialSnapshot)
405
+ : _failClosedCanonicalTuple(file);
406
+ if (!recovered) {
407
+ const err = new Error('failed to restore canonical identity after credential invalidation failure');
408
+ err.code = 'CANONICAL_IDENTITY_TRANSITION_UNSAFE';
409
+ throw err;
410
+ }
411
+ return;
412
+ }
413
+
414
+ try {
415
+ _commitPreparedFile(prepared, file);
416
+ prepared = null;
417
+ if (_readValidNodeIdFile(file) !== id) {
418
+ throw new Error('node_id replacement verification failed');
419
+ }
420
+ } catch (e) {
421
+ if (requiresCredentialInvalidation) {
422
+ const recovered = canRestorePreviousOwner
423
+ ? _rollbackCanonicalNodeTransition(file, existing, credentialSnapshot)
424
+ : _failClosedCanonicalTuple(file);
425
+ if (!recovered) {
426
+ const err = new Error('failed to restore canonical identity after node_id replacement failure');
427
+ err.code = 'CANONICAL_IDENTITY_TRANSITION_UNSAFE';
428
+ throw err;
429
+ }
430
+ return;
431
+ }
432
+ throw e;
433
+ }
434
+
435
+ if (requiresCredentialInvalidation && !_clearCanonicalCredentials(file)) {
436
+ const recovered = canRestorePreviousOwner
437
+ ? _rollbackCanonicalNodeTransition(file, existing, credentialSnapshot)
438
+ : _failClosedCanonicalTuple(file);
439
+ if (!recovered) {
440
+ const err = new Error('failed to invalidate canonical credentials after node_id transition');
441
+ err.code = 'CANONICAL_IDENTITY_TRANSITION_UNSAFE';
442
+ throw err;
443
+ }
444
+ return;
445
+ }
217
446
  return;
218
- } catch {
447
+ } catch (e) {
448
+ if (e && e.code === 'CANONICAL_IDENTITY_TRANSITION_UNSAFE') throw e;
219
449
  // Best-effort: continue to the next candidate. If both fail (no
220
450
  // home, no writable install root) we accept the legacy file is
221
451
  // unavailable — the proxy will still function, it just cannot
222
452
  // unify state-file suffixes with a co-resident a2aProtocol path.
453
+ } finally {
454
+ if (prepared) {
455
+ try { fs.unlinkSync(prepared); } catch { /* best-effort cleanup */ }
456
+ }
457
+ if (releaseCanonicalLock) releaseCanonicalLock();
223
458
  }
224
459
  }
225
460
  }
@@ -445,11 +680,12 @@ function normalizeNodeSecretEnvSuppression(value) {
445
680
  }
446
681
 
447
682
  class LifecycleManager {
448
- constructor({ hubUrl, store, logger, getTaskMeta } = {}) {
683
+ constructor({ hubUrl, store, logger, getTaskMeta, onWake } = {}) {
449
684
  this.hubUrl = (hubUrl || process.env.A2A_HUB_URL || '').replace(/\/+$/, '');
450
685
  this.store = store;
451
686
  this.logger = logger || console;
452
687
  this.getTaskMeta = getTaskMeta || null;
688
+ this.onWake = typeof onWake === 'function' ? onWake : null;
453
689
  this._heartbeatTimer = null;
454
690
  this._running = false;
455
691
  this._startedAt = null;
@@ -468,6 +704,7 @@ class LifecycleManager {
468
704
  : null);
469
705
  this._envSuppressionClearedForSecret = null;
470
706
  this._suppressEnvSecret = Boolean(this._envSecretSuppressionMarker && getEnvNodeSecret());
707
+ this._deliveryIdentityListeners = new Set();
471
708
 
472
709
  // H4 fix: persist the legacy node_id file as soon as the in-memory
473
710
  // node_id is known, NOT only after a successful hello(). The original
@@ -537,6 +774,20 @@ class LifecycleManager {
537
774
  return validStoreSecret ? storeVersion : null;
538
775
  }
539
776
 
777
+ onDeliveryIdentityChange(listener) {
778
+ if (typeof listener !== 'function') return () => {};
779
+ this._deliveryIdentityListeners.add(listener);
780
+ return () => this._deliveryIdentityListeners.delete(listener);
781
+ }
782
+
783
+ _notifyDeliveryIdentityChange() {
784
+ for (const listener of Array.from(this._deliveryIdentityListeners)) {
785
+ try { listener(); } catch {
786
+ this.logger.warn?.('[lifecycle] delivery identity listener failed');
787
+ }
788
+ }
789
+ }
790
+
540
791
  /**
541
792
  * Resolve the active node_secret with conflict reconciliation between the
542
793
  * persistent MailboxStore and `process.env.A2A_NODE_SECRET`.
@@ -881,6 +1132,7 @@ class LifecycleManager {
881
1132
  }
882
1133
 
883
1134
  this.store.setState('node_id', nodeId);
1135
+ this._notifyDeliveryIdentityChange();
884
1136
  // Unify proxy node_id with the legacy GEP file. Without this, the
885
1137
  // proxy-only fast path (EVOMAP_PROXY=1) never seeds
886
1138
  // ~/.evomap/node_id and `_shortNodeIdForStatePath` in a2aProtocol
@@ -1054,6 +1306,7 @@ class LifecycleManager {
1054
1306
  envSuppressed: this.store.getState('node_secret_env_suppressed') || '',
1055
1307
  });
1056
1308
  this.logger.warn('[lifecycle] local in-memory node_secret is stale; preserving newer disk hub-rotated secret');
1309
+ this._notifyDeliveryIdentityChange();
1057
1310
  return;
1058
1311
  } catch (err) {
1059
1312
  const reason = err && (err.code || err.name) ? (err.code || err.name) : 'error';
@@ -1074,6 +1327,7 @@ class LifecycleManager {
1074
1327
  // Suppress only the exact env secret that was just proven stale. If no
1075
1328
  // env secret is present, do not leave a marker that blocks a future reset.
1076
1329
  this._markCurrentEnvSecretSuppressed();
1330
+ this._notifyDeliveryIdentityChange();
1077
1331
  }
1078
1332
 
1079
1333
  _emitManualResetNeeded() {
@@ -1436,6 +1690,13 @@ class LifecycleManager {
1436
1690
  } catch (_) { /* logger broken; non-fatal */ }
1437
1691
  }
1438
1692
  this.pokeHeartbeatLoop();
1693
+ if (this.onWake) {
1694
+ try {
1695
+ this.onWake();
1696
+ } catch (err) {
1697
+ this.logger.warn?.(`[lifecycle] wake recovery callback failed: ${err && err.message || err}`);
1698
+ }
1699
+ }
1439
1700
  }
1440
1701
  } catch (err) {
1441
1702
  try { this.logger.error(`[lifecycle] drift detector threw: ${err && err.message}`); }
@@ -141,6 +141,39 @@ function replaceStateFile(stateFile, state) {
141
141
  bestEffortChmod(stateFile, PRIVATE_FILE_MODE);
142
142
  }
143
143
 
144
+ function mergeMailboxState(diskState, nextState, updatedKeys) {
145
+ const next = isPlainState(nextState) ? nextState : {};
146
+ const disk = isPlainState(diskState) ? diskState : null;
147
+ const hasDiskState = Boolean(disk);
148
+ const merged = hasDiskState ? { ...disk } : {};
149
+ const updatedSet = updatedKeys ? new Set(Array.from(updatedKeys)) : null;
150
+ const keys = updatedSet ? Array.from(updatedSet) : Object.keys(next);
151
+ const touchesNodeSecretTuple = keys.some((key) => MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key));
152
+ const preserveDiskNodeSecretTuple = touchesNodeSecretTuple
153
+ && hasDiskState
154
+ && isHubRotatedNodeSecretState(disk)
155
+ && !isFullNodeSecretTupleUpdate(updatedSet);
156
+
157
+ for (const key of keys) {
158
+ if (MAILBOX_NODE_SECRET_STATE_KEY_SET.has(key) && hasDiskState && (!updatedSet || !updatedSet.has(key))) {
159
+ continue;
160
+ }
161
+ if (
162
+ MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key) &&
163
+ preserveDiskNodeSecretTuple &&
164
+ !canApplyPartialNodeSecretTupleWrite(key, disk, next)
165
+ ) {
166
+ continue;
167
+ }
168
+ if (!Object.prototype.hasOwnProperty.call(next, key)) {
169
+ delete merged[key];
170
+ continue;
171
+ }
172
+ merged[key] = next[key];
173
+ }
174
+ return merged;
175
+ }
176
+
144
177
  /**
145
178
  * Merge a partial state write with the latest on-disk state.
146
179
  *
@@ -157,36 +190,8 @@ function replaceStateFile(stateFile, state) {
157
190
  function writeMergedMailboxStateFile(stateFile, nextState, updatedKeys) {
158
191
  const releaseLock = acquireStateFileLock(stateFile);
159
192
  try {
160
- const next = isPlainState(nextState) ? nextState : {};
161
193
  const disk = readMailboxStateFile(stateFile);
162
- const hasDiskState = isPlainState(disk);
163
- const merged = hasDiskState ? { ...disk } : {};
164
- const updatedSet = updatedKeys ? new Set(Array.from(updatedKeys)) : null;
165
- const keys = updatedSet ? Array.from(updatedSet) : Object.keys(next);
166
- const touchesNodeSecretTuple = keys.some((key) => MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key));
167
- const preserveDiskNodeSecretTuple = touchesNodeSecretTuple
168
- && hasDiskState
169
- && isHubRotatedNodeSecretState(disk)
170
- && !isFullNodeSecretTupleUpdate(updatedSet);
171
-
172
- for (const key of keys) {
173
- if (MAILBOX_NODE_SECRET_STATE_KEY_SET.has(key) && hasDiskState && (!updatedSet || !updatedSet.has(key))) {
174
- continue;
175
- }
176
- if (
177
- MAILBOX_NODE_SECRET_TUPLE_KEY_SET.has(key) &&
178
- preserveDiskNodeSecretTuple &&
179
- !canApplyPartialNodeSecretTupleWrite(key, disk, next)
180
- ) {
181
- continue;
182
- }
183
- if (!Object.prototype.hasOwnProperty.call(next, key)) {
184
- delete merged[key];
185
- continue;
186
- }
187
- merged[key] = next[key];
188
- }
189
-
194
+ const merged = mergeMailboxState(disk, nextState, updatedKeys);
190
195
  replaceStateFile(stateFile, merged);
191
196
  return merged;
192
197
  } finally {
@@ -194,6 +199,31 @@ function writeMergedMailboxStateFile(stateFile, nextState, updatedKeys) {
194
199
  }
195
200
  }
196
201
 
202
+ /**
203
+ * Conditionally update mailbox state while holding the state-file lock.
204
+ * The callback returns `{ nextState, updatedKeys }`, or null to fail closed
205
+ * without writing. This keeps owner checks and legacy node_id binding in the
206
+ * same critical section as the secret tuple mutation.
207
+ *
208
+ * @param {string} stateFile
209
+ * @param {(state: Record<string, unknown>|null) => {nextState: Record<string, unknown>, updatedKeys: Iterable<string>}|null} buildUpdate
210
+ * @returns {{updated: boolean, state: Record<string, unknown>|null}}
211
+ */
212
+ function updateMergedMailboxStateFile(stateFile, buildUpdate) {
213
+ if (typeof buildUpdate !== 'function') throw new TypeError('buildUpdate must be a function');
214
+ const releaseLock = acquireStateFileLock(stateFile);
215
+ try {
216
+ const disk = readMailboxStateFile(stateFile);
217
+ const update = buildUpdate(isPlainState(disk) ? { ...disk } : null);
218
+ if (!update) return { updated: false, state: disk };
219
+ const merged = mergeMailboxState(disk, update.nextState, update.updatedKeys);
220
+ replaceStateFile(stateFile, merged);
221
+ return { updated: true, state: merged };
222
+ } finally {
223
+ releaseLock();
224
+ }
225
+ }
226
+
197
227
  module.exports = {
198
228
  PRIVATE_DIR_MODE,
199
229
  PRIVATE_FILE_MODE,
@@ -204,4 +234,5 @@ module.exports = {
204
234
  readMailboxStateFile,
205
235
  isHubRotatedNodeSecretState,
206
236
  writeMergedMailboxStateFile,
237
+ updateMergedMailboxStateFile,
207
238
  };