agent-embassy 2.0.1 → 3.0.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.
Files changed (144) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTRIBUTING.md +19 -34
  3. package/README.md +110 -228
  4. package/SECURITY.md +55 -89
  5. package/dist/src/errors.d.ts +10 -1
  6. package/dist/src/errors.js +3 -1
  7. package/dist/src/errors.js.map +1 -1
  8. package/dist/src/gateway/claude-helper-protocol.d.ts +8 -11
  9. package/dist/src/gateway/claude-helper-protocol.js +12 -11
  10. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  11. package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -5
  12. package/dist/src/gateway/claude-helper-supervisor.js +10 -9
  13. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  14. package/dist/src/gateway/claude-helper.js +6 -6
  15. package/dist/src/gateway/claude-helper.js.map +1 -1
  16. package/dist/src/gateway/claude-peer.d.ts +0 -3
  17. package/dist/src/gateway/claude-peer.js +6 -18
  18. package/dist/src/gateway/claude-peer.js.map +1 -1
  19. package/dist/src/gateway/cli.d.ts +17 -6
  20. package/dist/src/gateway/cli.js +912 -261
  21. package/dist/src/gateway/cli.js.map +1 -1
  22. package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
  23. package/dist/src/gateway/codex-socket-holder.js +76 -0
  24. package/dist/src/gateway/codex-socket-holder.js.map +1 -0
  25. package/dist/src/gateway/codex-stateless-transport.js +1 -1
  26. package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
  27. package/dist/src/gateway/config.d.ts +1 -10
  28. package/dist/src/gateway/config.js +4 -10
  29. package/dist/src/gateway/config.js.map +1 -1
  30. package/dist/src/gateway/control.d.ts +45 -77
  31. package/dist/src/gateway/control.js +56 -141
  32. package/dist/src/gateway/control.js.map +1 -1
  33. package/dist/src/gateway/federation-nodes.d.ts +29 -2
  34. package/dist/src/gateway/federation-nodes.js +177 -7
  35. package/dist/src/gateway/federation-nodes.js.map +1 -1
  36. package/dist/src/gateway/peer-client.d.ts +4 -3
  37. package/dist/src/gateway/peer-client.js +22 -13
  38. package/dist/src/gateway/peer-client.js.map +1 -1
  39. package/dist/src/gateway/peer-protocol.d.ts +8 -11
  40. package/dist/src/gateway/peer-protocol.js +5 -12
  41. package/dist/src/gateway/peer-protocol.js.map +1 -1
  42. package/dist/src/gateway/provenance-envelope.d.ts +0 -1
  43. package/dist/src/gateway/provenance-envelope.js +4 -19
  44. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  45. package/dist/src/gateway/providers.d.ts +7 -4
  46. package/dist/src/gateway/providers.js +16 -20
  47. package/dist/src/gateway/providers.js.map +1 -1
  48. package/dist/src/gateway/server.d.ts +3 -12
  49. package/dist/src/gateway/server.js +31 -50
  50. package/dist/src/gateway/server.js.map +1 -1
  51. package/dist/src/gateway/service-agent.d.ts +187 -0
  52. package/dist/src/gateway/service-agent.js +758 -0
  53. package/dist/src/gateway/service-agent.js.map +1 -0
  54. package/dist/src/gateway/service.d.ts +114 -31
  55. package/dist/src/gateway/service.js +461 -566
  56. package/dist/src/gateway/service.js.map +1 -1
  57. package/dist/src/gateway/status-view.d.ts +167 -0
  58. package/dist/src/gateway/status-view.js +488 -0
  59. package/dist/src/gateway/status-view.js.map +1 -0
  60. package/dist/src/gateway/store.d.ts +103 -21
  61. package/dist/src/gateway/store.js +454 -529
  62. package/dist/src/gateway/store.js.map +1 -1
  63. package/dist/src/gateway/types.d.ts +48 -99
  64. package/dist/src/gateway/types.js +15 -52
  65. package/dist/src/gateway/types.js.map +1 -1
  66. package/docs/CONFIGURATION.md +173 -44
  67. package/docs/DELIVERY.md +11 -11
  68. package/docs/GATEWAY-ARCHITECTURE.md +277 -375
  69. package/package.json +4 -12
  70. package/skills/embassy-peer/SKILL.md +65 -90
  71. package/skills/embassy-peer/agents/openai.yaml +1 -1
  72. package/README.zh-CN.md +0 -275
  73. package/assets/live-dashboard/app.css +0 -1619
  74. package/assets/vendor/react/LICENSE +0 -21
  75. package/assets/vendor/react/react-dom.production.min.js +0 -267
  76. package/assets/vendor/react/react.production.min.js +0 -31
  77. package/dist/src/gateway/acp-client.d.ts +0 -110
  78. package/dist/src/gateway/acp-client.js +0 -407
  79. package/dist/src/gateway/acp-client.js.map +0 -1
  80. package/dist/src/gateway/acp-provider.d.ts +0 -66
  81. package/dist/src/gateway/acp-provider.js +0 -275
  82. package/dist/src/gateway/acp-provider.js.map +0 -1
  83. package/dist/src/gateway/cli-copy.d.ts +0 -8
  84. package/dist/src/gateway/cli-copy.en.d.ts +0 -22
  85. package/dist/src/gateway/cli-copy.en.js +0 -62
  86. package/dist/src/gateway/cli-copy.en.js.map +0 -1
  87. package/dist/src/gateway/cli-copy.js +0 -27
  88. package/dist/src/gateway/cli-copy.js.map +0 -1
  89. package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
  90. package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
  91. package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
  92. package/dist/src/gateway/codex-doctor.d.ts +0 -36
  93. package/dist/src/gateway/codex-doctor.js +0 -127
  94. package/dist/src/gateway/codex-doctor.js.map +0 -1
  95. package/dist/src/gateway/dashboard-copy.d.ts +0 -7
  96. package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
  97. package/dist/src/gateway/dashboard-copy.en.js +0 -505
  98. package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
  99. package/dist/src/gateway/dashboard-copy.js +0 -514
  100. package/dist/src/gateway/dashboard-copy.js.map +0 -1
  101. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
  102. package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
  103. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
  104. package/dist/src/gateway/dashboard-model.d.ts +0 -343
  105. package/dist/src/gateway/dashboard-model.js +0 -1061
  106. package/dist/src/gateway/dashboard-model.js.map +0 -1
  107. package/dist/src/gateway/dashboard.d.ts +0 -20
  108. package/dist/src/gateway/dashboard.js +0 -874
  109. package/dist/src/gateway/dashboard.js.map +0 -1
  110. package/dist/src/gateway/deepseek-detect.d.ts +0 -14
  111. package/dist/src/gateway/deepseek-detect.js +0 -41
  112. package/dist/src/gateway/deepseek-detect.js.map +0 -1
  113. package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
  114. package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
  115. package/dist/src/gateway/live-dashboard-assets.js +0 -74
  116. package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
  117. package/dist/src/gateway/live-dashboard-command.d.ts +0 -60
  118. package/dist/src/gateway/live-dashboard-command.js +0 -334
  119. package/dist/src/gateway/live-dashboard-command.js.map +0 -1
  120. package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
  121. package/dist/src/gateway/live-dashboard-http.js +0 -383
  122. package/dist/src/gateway/live-dashboard-http.js.map +0 -1
  123. package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
  124. package/dist/src/gateway/live-dashboard-protocol.js +0 -114
  125. package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
  126. package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
  127. package/dist/src/gateway/live-dashboard-server.js +0 -144
  128. package/dist/src/gateway/live-dashboard-server.js.map +0 -1
  129. package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
  130. package/dist/src/gateway/live-dashboard-stream.js +0 -234
  131. package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
  132. package/dist/src/gateway/live-dashboard.d.ts +0 -28
  133. package/dist/src/gateway/live-dashboard.js +0 -154
  134. package/dist/src/gateway/live-dashboard.js.map +0 -1
  135. package/dist/src/gateway/locale.d.ts +0 -4
  136. package/dist/src/gateway/locale.js +0 -10
  137. package/dist/src/gateway/locale.js.map +0 -1
  138. package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
  139. package/dist/src/gateway/progress-watch-machine.js +0 -70
  140. package/dist/src/gateway/progress-watch-machine.js.map +0 -1
  141. package/docs/CONFIGURATION.zh-CN.md +0 -97
  142. package/docs/DASHBOARD.md +0 -98
  143. package/docs/DASHBOARD.zh-CN.md +0 -49
  144. package/docs/DELIVERY.zh-CN.md +0 -55
@@ -5,12 +5,38 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { BridgeError } from "../errors.js";
7
7
  import { KeyedMutex } from "../mutex.js";
8
- import { peerEdgeRef, peerRouteRef } from "./peer-protocol.js";
8
+ import { peerRouteRef } from "./peer-protocol.js";
9
9
  import { deliveryStates, directionId, gatewayActivityActions, gatewayActivityKinds, gatewayProviders, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, routeRegistrationModes } from "./types.js";
10
10
  const STATE_MARKER = ".agent-embassy-state";
11
11
  const STATE_MARKER_CONTENT = "agent-embassy-state-v1\n";
12
12
  const STATE_FILE = "gateway-state.json";
13
- const CONTROLLER_LOCK = ".gateway-controller.lock";
13
+ /** Static dashboard files a 2.x install may have left behind (emb-100 removed the feature; emb-106 sweeps the litter). */
14
+ const STALE_DASHBOARD_FILES = ["gateway-dashboard.html", "gateway-dashboard.zh-CN.html"];
15
+ /** 2.x wrote its dashboards via temp-file + rename; a crash mid-publish could leave one of these behind too. */
16
+ const STALE_DASHBOARD_TEMP_PATTERNS = [
17
+ /^\.gateway-dashboard\.html\.[^/]+\.tmp$/,
18
+ /^\.gateway-dashboard\.zh-CN\.html\.[^/]+\.tmp$/,
19
+ ];
20
+ function isStaleDashboardArtifact(name) {
21
+ return STALE_DASHBOARD_FILES.includes(name) ||
22
+ STALE_DASHBOARD_TEMP_PATTERNS.some((pattern) => pattern.test(name));
23
+ }
24
+ /**
25
+ * Locks this store moved aside as stale, kept a week so a crash stays
26
+ * diagnosable. The recovery time is in the name, not in the file's mtime:
27
+ * `rename` carries the crashed lock's own timestamps across, so a broker that
28
+ * ran for a month would have its lock swept by the very boot that recovered it.
29
+ */
30
+ const STALE_LOCK_PATTERN = /^\.gateway-controller\.lock\.stale-(\d{13,16})-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
31
+ const STALE_LOCK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
32
+ /** The recovery time encoded in a stale-lock name, or undefined if this is not one. */
33
+ function staleLockRecoveredAt(name) {
34
+ const found = STALE_LOCK_PATTERN.exec(name);
35
+ return found === null ? undefined : Number(found[1]);
36
+ }
37
+ /** The controller lock's file name; the CLI reads the same file for a pid. */
38
+ export const GATEWAY_CONTROLLER_LOCK_FILE = ".gateway-controller.lock";
39
+ const CONTROLLER_LOCK = GATEWAY_CONTROLLER_LOCK_FILE;
14
40
  const MAX_MARKER_FILE_BYTES = 128;
15
41
  const MAX_LOCK_FILE_BYTES = 4 * 1024;
16
42
  export const GATEWAY_MAX_STATE_FILE_BYTES = 8 * 1024 * 1024;
@@ -55,6 +81,27 @@ function isNonNegativeInteger(value) {
55
81
  function isPositiveInteger(value) {
56
82
  return Number.isSafeInteger(value) && value > 0;
57
83
  }
84
+ const LOCK_HOSTNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
85
+ /**
86
+ * A machine name bounded before it is compared or handed to a client hint.
87
+ * Both sides of the comparison pass through here, so a local hostname the
88
+ * pattern cannot represent (a name with a space, say) is never mistaken for
89
+ * a different machine — it is simply unverifiable on both sides.
90
+ */
91
+ function boundedHostname(value) {
92
+ return typeof value === "string" && LOCK_HOSTNAME_PATTERN.test(value) ? value : undefined;
93
+ }
94
+ const lockUnverified = () => new BridgeError("GATEWAY_STATE_LOCK_UNVERIFIED", "The gateway state lock exists but cannot be read as a controller record.");
95
+ function defaultProcessLiveness(pid) {
96
+ try {
97
+ process.kill(pid, 0);
98
+ return true;
99
+ }
100
+ catch (error) {
101
+ // ESRCH is the only answer that means "gone"; EPERM means alive and not ours.
102
+ return !(error instanceof Error && "code" in error && error.code === "ESRCH");
103
+ }
104
+ }
58
105
  function isIsoTimestamp(value) {
59
106
  return (typeof value === "string" &&
60
107
  value.length >= 20 &&
@@ -113,79 +160,26 @@ function isRoute(value) {
113
160
  isIsoTimestamp(value.updatedAt) &&
114
161
  isCounters(value.counters));
115
162
  }
116
- function isConsentEndpoint(value) {
117
- return (isObject(value) &&
118
- hasOnlyKeys(value, ["alias", "provider", "registrationId"]) &&
119
- typeof value.alias === "string" &&
120
- ALIAS_PATTERN.test(value.alias) &&
121
- isProvider(value.provider) &&
122
- isPrivateToken(value.registrationId));
123
- }
124
- function compareConsentEndpoints(left, right) {
125
- const providerOrder = gatewayProviders.indexOf(left.provider) -
126
- gatewayProviders.indexOf(right.provider);
127
- return providerOrder !== 0 ? providerOrder : left.alias.localeCompare(right.alias);
128
- }
129
- function canonicalConsentEndpoints(left, right) {
130
- const endpoints = [
131
- {
132
- alias: left.alias, provider: left.binding.provider,
133
- registrationId: left.binding.registrationId,
134
- },
135
- {
136
- alias: right.alias, provider: right.binding.provider,
137
- registrationId: right.binding.registrationId,
138
- },
139
- ];
140
- endpoints.sort(compareConsentEndpoints);
141
- return endpoints;
142
- }
143
- function consentKey(endpoints) {
144
- return endpoints
145
- .map((endpoint) => [endpoint.provider, endpoint.alias, endpoint.registrationId].join("\0"))
146
- .join("\0");
147
- }
148
- function edgeOwnerHost(endpoints) {
149
- return endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0];
150
- }
151
163
  function peerMirrorRegistrationId(host, routeRef) {
152
164
  return `reg_peer_${createHash("sha256").update(`${host}\0${routeRef}`).digest("base64url").slice(0, 32)}`;
153
165
  }
154
- function sameConsent(left, right) {
155
- if (left === null || right === null)
156
- return left === right;
157
- return consentKey(left) === consentKey(right);
158
- }
159
166
  function routeModeMatchesHost(route, localHost) {
160
167
  if (route.registrationMode === "federated_peer") {
161
- return localHost !== undefined && route.binding.hostId !== localHost;
168
+ // Without a known local host there is nothing to prove the mirror foreign
169
+ // against, so the check abstains rather than refusing. The store always
170
+ // supplies its configured host, so the shipped path always checks.
171
+ return localHost === undefined || route.binding.hostId !== localHost;
162
172
  }
163
173
  return route.binding.hostId === localHost && (route.binding.provider === "claude"
164
174
  ? route.registrationMode === "selected_live_peer"
165
175
  : route.registrationMode === "explicit_opt_in");
166
176
  }
167
- function isConsentEdge(value) {
168
- if (!isObject(value) ||
169
- !hasOnlyKeys(value, ["endpoints", "createdAt", "updatedAt", "counters"]) ||
170
- !Array.isArray(value.endpoints) ||
171
- value.endpoints.length !== 2 ||
172
- !value.endpoints.every(isConsentEndpoint)) {
173
- return false;
174
- }
175
- const endpoints = value.endpoints;
176
- return (compareConsentEndpoints(endpoints[0], endpoints[1]) < 0 &&
177
- endpoints[0].alias !== endpoints[1].alias &&
178
- isIsoTimestamp(value.createdAt) &&
179
- isIsoTimestamp(value.updatedAt) &&
180
- isCounters(value.counters));
181
- }
182
177
  function isPrepared(value) {
183
178
  return (isObject(value) &&
184
179
  hasOnlyKeys(value, ["kind", "bodyBytes", "bodySha256", "frameBytes", "sha256"]) &&
185
180
  (value.kind === "claude_mailbox" ||
186
181
  value.kind === "codex_turn_start" ||
187
182
  value.kind === "codex_turn_steer" ||
188
- value.kind === "acp_prompt" ||
189
183
  value.kind === "peer_mailbox" ||
190
184
  value.kind === "peer_handoff") &&
191
185
  isPositiveInteger(value.bodyBytes) &&
@@ -202,12 +196,9 @@ function expectedPreparedKind(message, targetMode) {
202
196
  const target = parseDirection(message.direction).targetProvider;
203
197
  if (target === "claude")
204
198
  return "claude_mailbox";
205
- if (target === "codex") {
206
- return message.steer === true ? "codex_turn_steer" : "codex_turn_start";
207
- }
208
199
  if (target === "peer")
209
200
  return "peer_mailbox";
210
- return "acp_prompt";
201
+ return message.steer === true ? "codex_turn_steer" : "codex_turn_start";
211
202
  }
212
203
  function isAttemptAuthority(value) {
213
204
  return (typeof value.attemptId === "string" &&
@@ -215,13 +206,7 @@ function isAttemptAuthority(value) {
215
206
  isPositiveInteger(value.attemptCount) &&
216
207
  typeof value.targetRegistrationId === "string" &&
217
208
  isPrivateToken(value.targetRegistrationId) &&
218
- (value.sourceRegistrationId === null ||
219
- isPrivateToken(value.sourceRegistrationId)) &&
220
- (value.consentEdge === null ||
221
- (Array.isArray(value.consentEdge) &&
222
- value.consentEdge.length === 2 &&
223
- value.consentEdge.every(isConsentEndpoint) &&
224
- compareConsentEndpoints(value.consentEdge[0], value.consentEdge[1]) < 0)));
209
+ isPrivateToken(value.sourceRegistrationId));
225
210
  }
226
211
  function isMessageState(value) {
227
212
  if (!isObject(value) || typeof value.phase !== "string")
@@ -232,20 +217,20 @@ function isMessageState(value) {
232
217
  }
233
218
  if (value.phase === "reserved") {
234
219
  return (hasOnlyKeys(value, ["phase", "attemptId", "attemptCount", "targetRegistrationId",
235
- "sourceRegistrationId", "consentEdge", "reservedAt"]) &&
220
+ "sourceRegistrationId", "reservedAt"]) &&
236
221
  isAttemptAuthority(value) &&
237
222
  isIsoTimestamp(value.reservedAt));
238
223
  }
239
224
  if (value.phase === "armed") {
240
225
  return (hasOnlyKeys(value, ["phase", "attemptId", "attemptCount", "targetRegistrationId",
241
- "sourceRegistrationId", "consentEdge", "armedAt", "prepared"]) &&
226
+ "sourceRegistrationId", "armedAt", "prepared"]) &&
242
227
  isAttemptAuthority(value) &&
243
228
  isIsoTimestamp(value.armedAt) &&
244
229
  isPrepared(value.prepared));
245
230
  }
246
231
  if (value.phase === "accepted") {
247
232
  return (hasOnlyKeys(value, ["phase", "attemptId", "attemptCount", "targetRegistrationId",
248
- "sourceRegistrationId", "consentEdge", "acceptedAt", "prepared", "lossOutcome"]) &&
233
+ "sourceRegistrationId", "acceptedAt", "prepared", "lossOutcome"]) &&
249
234
  isAttemptAuthority(value) &&
250
235
  isIsoTimestamp(value.acceptedAt) &&
251
236
  isPrepared(value.prepared) &&
@@ -270,7 +255,7 @@ function isMessage(value) {
270
255
  if (!isObject(value) ||
271
256
  !hasOnlyKeys(value, ["sequence", "messageId", "messageIdSuffix", "direction", "sourceAlias",
272
257
  "targetAlias", "enqueuedAt", "deadlineAt", "bytes", "sourceRegistrationId",
273
- "targetRegistrationId", "consentEdge", "state"], ["conversationIdSuffix", "deliveryToken", "body", "pair", "transientTarget", "steer"])) {
258
+ "targetRegistrationId", "state"], ["conversationIdSuffix", "deliveryToken", "body", "steer"])) {
274
259
  return false;
275
260
  }
276
261
  return (isPositiveInteger(value.sequence) &&
@@ -299,18 +284,10 @@ function isMessage(value) {
299
284
  (value.deliveryToken === undefined ||
300
285
  (typeof value.deliveryToken === "string" &&
301
286
  DELIVERY_TOKEN_PATTERN.test(value.deliveryToken))) &&
302
- (value.pair === undefined || value.pair === true) &&
303
- (value.transientTarget === undefined || value.transientTarget === true) &&
304
287
  (value.steer === undefined || value.steer === true) &&
305
- (value.sourceRegistrationId === null ||
306
- isPrivateToken(value.sourceRegistrationId)) &&
288
+ isPrivateToken(value.sourceRegistrationId) &&
307
289
  (value.targetRegistrationId === null ||
308
290
  isPrivateToken(value.targetRegistrationId)) &&
309
- (value.consentEdge === null ||
310
- (Array.isArray(value.consentEdge) &&
311
- value.consentEdge.length === 2 &&
312
- value.consentEdge.every(isConsentEndpoint) &&
313
- compareConsentEndpoints(value.consentEdge[0], value.consentEdge[1]) < 0)) &&
314
291
  isMessageState(value.state) &&
315
292
  (value.state.phase === "terminal" ||
316
293
  value.state.attemptCount <=
@@ -324,7 +301,7 @@ function isMessage(value) {
324
301
  function isDedupe(value) {
325
302
  return (isObject(value) &&
326
303
  hasOnlyKeys(value, ["fingerprint", "messageIdSuffix", "sourceAlias", "targetAlias", "direction",
327
- "firstSeenAt", "expiresAt"], ["conversationIdSuffix", "pair"]) &&
304
+ "firstSeenAt", "expiresAt"], ["conversationIdSuffix"]) &&
328
305
  typeof value.fingerprint === "string" &&
329
306
  FINGERPRINT_PATTERN.test(value.fingerprint) &&
330
307
  typeof value.messageIdSuffix === "string" &&
@@ -338,8 +315,7 @@ function isDedupe(value) {
338
315
  isIsoTimestamp(value.expiresAt) &&
339
316
  (value.conversationIdSuffix === undefined ||
340
317
  (typeof value.conversationIdSuffix === "string" &&
341
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))) &&
342
- (value.pair === undefined || value.pair === true));
318
+ CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))));
343
319
  }
344
320
  function isAccounting(value) {
345
321
  return (isObject(value) &&
@@ -402,11 +378,20 @@ function isMessageActivity(value) {
402
378
  value.type === "message_activity" &&
403
379
  isNormalizedEvent(value.event));
404
380
  }
405
- export function isGatewayPersistedStateV4(value) {
381
+ /**
382
+ * `configuredHost` is the broker's own host identity, known at construction and
383
+ * always supplied on the shipped path. Deriving the local host from the routes
384
+ * alone is not sufficient: a federation node whose only routes are mirrors has
385
+ * no local route to derive from, and a state file it just wrote would fail its
386
+ * own validator on the next persist.
387
+ */
388
+ export function isGatewayPersistedStateV5(value, configuredHost) {
406
389
  if (!isObject(value) ||
390
+ // Exact keys: a file that still carries the retired `consentEdges` key is
391
+ // refused here as a corrupt document; the documented recovery is a reset.
407
392
  !hasOnlyKeys(value, ["schemaVersion", "commit", "createdAt", "updatedAt", "eventSequence",
408
- "routes", "consentEdges", "messages", "dedupe", "rateBuckets", "activity", "accounting"]) ||
409
- value.schemaVersion !== 4 ||
393
+ "routes", "messages", "dedupe", "rateBuckets", "activity", "accounting"]) ||
394
+ value.schemaVersion !== 5 ||
410
395
  !isObject(value.commit) ||
411
396
  !hasOnlyKeys(value.commit, ["sequence", "id"]) ||
412
397
  !isNonNegativeInteger(value.commit.sequence) ||
@@ -416,8 +401,6 @@ export function isGatewayPersistedStateV4(value) {
416
401
  !isNonNegativeInteger(value.eventSequence) ||
417
402
  !Array.isArray(value.routes) ||
418
403
  !value.routes.every(isRoute) ||
419
- !Array.isArray(value.consentEdges) ||
420
- !value.consentEdges.every(isConsentEdge) ||
421
404
  !Array.isArray(value.messages) ||
422
405
  !value.messages.every(isMessage) ||
423
406
  !Array.isArray(value.dedupe) ||
@@ -436,7 +419,6 @@ export function isGatewayPersistedStateV4(value) {
436
419
  const routesByAlias = new Map(state.routes.map((route) => [route.alias, route]));
437
420
  const registrationIds = state.routes.map((route) => route.binding.registrationId);
438
421
  const routeTargets = state.routes.map((route) => [route.binding.provider, route.binding.hostId, route.binding.routeHandle].join("\0"));
439
- const edgeKeys = state.consentEdges.map((edge) => consentKey(edge.endpoints));
440
422
  const messages = state.messages;
441
423
  const activitySequences = state.activity.map((entry) => entry.event.sequence);
442
424
  const active = messages.filter((message) => message.state.phase !== "terminal");
@@ -449,7 +431,6 @@ export function isGatewayPersistedStateV4(value) {
449
431
  if (routesByAlias.size !== state.routes.length ||
450
432
  new Set(registrationIds).size !== registrationIds.length ||
451
433
  new Set(routeTargets).size !== routeTargets.length ||
452
- new Set(edgeKeys).size !== edgeKeys.length ||
453
434
  new Set(messages.map((message) => message.messageId)).size !== messages.length ||
454
435
  new Set(tokens).size !== tokens.length ||
455
436
  new Set(attemptIds).size !== attempts.length ||
@@ -462,34 +443,15 @@ export function isGatewayPersistedStateV4(value) {
462
443
  new Set(state.rateBuckets.map((entry) => entry.sourceAlias)).size !== state.rateBuckets.length) {
463
444
  return false;
464
445
  }
465
- for (const message of messages) {
466
- const direction = parseDirection(message.direction);
467
- if ((message.pair === true) !== (message.consentEdge !== null) ||
468
- (message.sourceRegistrationId === null &&
469
- (direction.sourceProvider !== "claude" || message.pair === true)) ||
470
- (message.transientTarget === true &&
471
- (direction.targetProvider !== "claude" ||
472
- message.pair === true ||
473
- message.sourceRegistrationId === null))) {
474
- return false;
475
- }
476
- if (message.consentEdge !== null) {
477
- const source = message.consentEdge.find((endpoint) => endpoint.alias === message.sourceAlias);
478
- const target = message.consentEdge.find((endpoint) => endpoint.alias === message.targetAlias);
479
- if (source?.provider !== direction.sourceProvider ||
480
- target?.provider !== direction.targetProvider ||
481
- source.registrationId !== message.sourceRegistrationId ||
482
- target.registrationId !== message.targetRegistrationId) {
483
- return false;
484
- }
485
- }
486
- }
487
446
  const localHosts = new Set(state.routes
488
447
  .filter((route) => route.registrationMode !== "federated_peer")
489
448
  .map((route) => route.binding.hostId));
490
449
  if (localHosts.size > 1)
491
450
  return false;
492
- const localHost = [...localHosts][0];
451
+ // The configured host is authority; a local route's own hostId is checked
452
+ // against it in the loop below, so the derived host is only the fallback for
453
+ // a caller that has no configured identity to offer.
454
+ const localHost = configuredHost ?? [...localHosts][0];
493
455
  for (const route of state.routes) {
494
456
  if (!route.alias.endsWith(`@${route.binding.hostId}`) ||
495
457
  !routeModeMatchesHost(route, localHost)) {
@@ -499,70 +461,24 @@ export function isGatewayPersistedStateV4(value) {
499
461
  if (prefix !== undefined && !route.alias.startsWith(prefix))
500
462
  return false;
501
463
  }
502
- for (const edge of state.consentEdges) {
503
- const [leftEndpoint, rightEndpoint] = edge.endpoints;
504
- const left = routesByAlias.get(leftEndpoint.alias);
505
- const right = routesByAlias.get(rightEndpoint.alias);
506
- if (left?.binding.registrationId !== leftEndpoint.registrationId ||
507
- right?.binding.registrationId !== rightEndpoint.registrationId ||
508
- left.binding.provider !== leftEndpoint.provider ||
509
- right.binding.provider !== rightEndpoint.provider ||
510
- (left.binding.hostId === right.binding.hostId &&
511
- left.binding.provider === right.binding.provider)) {
512
- return false;
513
- }
514
- }
515
464
  for (const message of active) {
516
465
  const target = routesByAlias.get(message.targetAlias);
517
466
  const source = routesByAlias.get(message.sourceAlias);
518
467
  const direction = parseDirection(message.direction);
519
468
  if (message.targetRegistrationId === null ||
520
- (message.transientTarget !== true &&
521
- (target?.binding.registrationId !== message.targetRegistrationId ||
522
- target.binding.provider !== direction.targetProvider)) ||
523
- (message.transientTarget === true && direction.targetProvider !== "claude")) {
524
- return false;
525
- }
526
- if (message.sourceRegistrationId !== null) {
527
- if (source?.binding.registrationId !== message.sourceRegistrationId ||
528
- source.binding.provider !== direction.sourceProvider ||
529
- (target !== undefined && source.binding.hostId === target.binding.hostId &&
530
- source.binding.provider === target.binding.provider)) {
531
- return false;
532
- }
533
- }
534
- else if (direction.sourceProvider !== "claude" ||
535
- target === undefined ||
536
- aliasHost(message.sourceAlias) !== target.binding.hostId) {
537
- return false;
538
- }
539
- if (message.transientTarget === true) {
540
- if (message.pair === true ||
541
- message.consentEdge !== null ||
542
- message.sourceRegistrationId === null ||
543
- source === undefined ||
544
- aliasHost(message.targetAlias) !== source.binding.hostId) {
545
- return false;
546
- }
547
- }
548
- if (message.pair === true) {
549
- if (message.consentEdge === null ||
550
- source === undefined ||
551
- target === undefined ||
552
- !sameConsent(message.consentEdge, canonicalConsentEndpoints(source, target)) ||
553
- !state.consentEdges.some((edge) => sameConsent(edge.endpoints, message.consentEdge))) {
554
- return false;
555
- }
556
- }
557
- else if (message.consentEdge !== null) {
469
+ target?.binding.registrationId !== message.targetRegistrationId ||
470
+ target.binding.provider !== direction.targetProvider ||
471
+ source?.binding.registrationId !== message.sourceRegistrationId ||
472
+ source.binding.provider !== direction.sourceProvider ||
473
+ (source.binding.hostId === target.binding.hostId &&
474
+ source.binding.provider === target.binding.provider)) {
558
475
  return false;
559
476
  }
560
477
  if ((message.state.phase === "reserved" ||
561
478
  message.state.phase === "armed" ||
562
479
  message.state.phase === "accepted") &&
563
480
  (message.state.sourceRegistrationId !== message.sourceRegistrationId ||
564
- message.state.targetRegistrationId !== message.targetRegistrationId ||
565
- !sameConsent(message.state.consentEdge, message.consentEdge))) {
481
+ message.state.targetRegistrationId !== message.targetRegistrationId)) {
566
482
  return false;
567
483
  }
568
484
  if ((message.state.phase === "armed" ||
@@ -615,9 +531,6 @@ function sameBinding(left, right) {
615
531
  left.routeHandle === right.routeHandle &&
616
532
  left.registrationId === right.registrationId);
617
533
  }
618
- function aliasHost(alias) {
619
- return alias.slice(alias.lastIndexOf("@") + 1);
620
- }
621
534
  async function assertNoSymlinkComponents(candidate) {
622
535
  let cursor = path.resolve(candidate);
623
536
  while (true) {
@@ -668,7 +581,20 @@ export class GatewayStore {
668
581
  state;
669
582
  lockHandle;
670
583
  lockToken;
584
+ isProcessAlive;
585
+ hostname;
671
586
  persistenceDeferred = false;
587
+ /**
588
+ * Registration ids that must never reach the durable state document. They
589
+ * are ordinary rows in the in-memory state — admission, dispatch, queueing
590
+ * and settlement all see them — and are projected out at the one place that
591
+ * serializes, together with every row that names one. When such a
592
+ * registration is removed, the same rows leave the live state in the same
593
+ * mutate, before the id leaves this set; a rolled-back mutate restores the
594
+ * set with the state. Nothing here is written down, so a restart reclaims
595
+ * them by construction.
596
+ */
597
+ ephemeralRegistrations = new Set();
672
598
  constructor(config, dependencies = {}) {
673
599
  this.config = config;
674
600
  this.rootDir = path.resolve(config.stateDir);
@@ -676,6 +602,8 @@ export class GatewayStore {
676
602
  this.randomId = dependencies.randomId ?? randomUUID;
677
603
  this.renameStateFile = dependencies.renameStateFile ?? rename;
678
604
  this.afterStateFileRename = dependencies.afterStateFileRename;
605
+ this.isProcessAlive = dependencies.isProcessAlive ?? defaultProcessLiveness;
606
+ this.hostname = dependencies.hostname ?? (() => os.hostname());
679
607
  }
680
608
  get stateFilePath() {
681
609
  return path.join(this.rootDir, STATE_FILE);
@@ -690,15 +618,16 @@ export class GatewayStore {
690
618
  const now = this.now();
691
619
  const loaded = await this.loadStateFile();
692
620
  this.state = loaded ?? {
693
- schemaVersion: 4,
621
+ schemaVersion: 5,
694
622
  commit: { sequence: 0, id: this.randomId() },
695
623
  createdAt: now.toISOString(), updatedAt: now.toISOString(),
696
624
  eventSequence: 0, routes: [],
697
- consentEdges: [], messages: [],
625
+ messages: [],
698
626
  dedupe: [], rateBuckets: [],
699
627
  activity: [], accounting: emptyAccounting(),
700
628
  };
701
629
  this.assertConfiguredBounds(this.state);
630
+ await this.removeStateDirectoryLitter(this.rootDir);
702
631
  this.recoverAfterRestart(now);
703
632
  this.prune(this.state, now);
704
633
  if (loaded !== undefined) {
@@ -744,9 +673,6 @@ export class GatewayStore {
744
673
  async listLogicalRoutes() {
745
674
  return this.inspectPrivateRoutes();
746
675
  }
747
- async inspectPrivateConsentEdges() {
748
- return this.read((state) => structuredClone(state.consentEdges));
749
- }
750
676
  async inspectPrivateRoute(alias) {
751
677
  return this.read((state) => {
752
678
  const route = state.routes.find((candidate) => candidate.alias === alias);
@@ -762,8 +688,7 @@ export class GatewayStore {
762
688
  return this.mutate((state, now) => {
763
689
  const localHost = this.config.hostId;
764
690
  if (!this.config.allowedHosts.includes(peerHost) || peerHost === localHost ||
765
- !catalog.complete || catalog.truncated || catalog.routes.some((route) => route.host !== peerHost) ||
766
- catalog.consentEdges.some((edge) => edge.ownerHost !== peerHost)) {
691
+ !catalog.complete || catalog.truncated || catalog.routes.some((route) => route.host !== peerHost)) {
767
692
  throw new BridgeError("INVALID_PEER_CATALOG", "The peer catalog is not an exact complete projection for this configured host.");
768
693
  }
769
694
  const desired = new Map(catalog.routes.map((route) => [route.ref, route]));
@@ -799,31 +724,6 @@ export class GatewayStore {
799
724
  route.enabled = row.enabled;
800
725
  route.updatedAt = now.toISOString();
801
726
  }
802
- const peerOwned = (edge) => edgeOwnerHost(edge.endpoints) === peerHost &&
803
- edge.endpoints.some((endpoint) => endpoint.alias.endsWith(`@${peerHost}`));
804
- const desiredEdges = [];
805
- for (const edge of catalog.consentEdges) {
806
- const resolved = edge.endpoints.map((endpoint) => state.routes.find((route) => route.alias === endpoint.alias && route.binding.provider === endpoint.provider &&
807
- (endpoint.host === peerHost ? route.registrationMode === "federated_peer" && route.binding.hostId === peerHost &&
808
- route.binding.routeHandle === endpoint.routeRef : endpoint.host === localHost &&
809
- route.registrationMode !== "federated_peer" && route.binding.hostId === localHost &&
810
- peerRouteRef(localHost, route.binding.registrationId) === endpoint.routeRef)));
811
- if (resolved[0] === undefined || resolved[1] === undefined || edge.ref !== peerEdgeRef(edge.endpoints))
812
- throw new BridgeError("INVALID_PEER_CATALOG", "A peer consent edge does not match current endpoint authority.");
813
- desiredEdges.push(canonicalConsentEndpoints(resolved[0], resolved[1]));
814
- }
815
- const removedEdges = state.consentEdges.filter((edge) => peerOwned(edge) &&
816
- !desiredEdges.some((candidate) => sameConsent(edge.endpoints, candidate)));
817
- for (const edge of removedEdges)
818
- settlements.push(...this.terminalizeConsentEdge(state, edge, now));
819
- state.consentEdges = state.consentEdges.filter((edge) => !removedEdges.includes(edge));
820
- for (const endpoints of desiredEdges) {
821
- if (state.consentEdges.some((edge) => sameConsent(edge.endpoints, endpoints)))
822
- continue;
823
- if (state.consentEdges.length >= this.config.limits.maxConsentEdges)
824
- throw new BridgeError("CONSENT_EDGE_CAPACITY_REACHED", "The bounded consent-edge inventory is full.", true);
825
- state.consentEdges.push({ endpoints, createdAt: now.toISOString(), updatedAt: now.toISOString(), counters: emptyCounters() });
826
- }
827
727
  return { settlements, routes: state.routes.filter((route) => route.registrationMode === "federated_peer" &&
828
728
  route.binding.hostId === peerHost).map((route) => ({ alias: route.alias, binding: { ...route.binding },
829
729
  registrationMode: route.registrationMode, enabled: route.enabled })) };
@@ -832,9 +732,11 @@ export class GatewayStore {
832
732
  async enqueuePeerHandoff(peerHost, handoff) {
833
733
  return this.mutate((state, now) => {
834
734
  const localHost = this.config.hostId;
735
+ // A handoff is authorized by the sending host's nodes.json membership
736
+ // (checked by the service before this call) plus exact alias addressing
737
+ // to a mirrored source and a local, enabled target.
835
738
  if (!this.config.allowedHosts.includes(peerHost) || peerHost === localHost || handoff.source.host !== peerHost ||
836
- handoff.target.host !== localHost || handoff.edgeOwnerHost !== [peerHost, localHost].sort()[0] ||
837
- handoff.edgeRef !== peerEdgeRef([handoff.source, handoff.target]))
739
+ handoff.target.host !== localHost)
838
740
  throw new BridgeError("INVALID_PEER_HANDOFF", "The peer handoff does not match this configured direct link.");
839
741
  const source = state.routes.find((route) => route.registrationMode === "federated_peer" && route.binding.hostId === peerHost &&
840
742
  route.alias === handoff.source.alias && route.binding.provider === handoff.source.provider && route.binding.routeHandle === handoff.source.routeRef);
@@ -843,21 +745,11 @@ export class GatewayStore {
843
745
  peerRouteRef(localHost, route.binding.registrationId) === handoff.target.routeRef && route.enabled);
844
746
  if (source === undefined || target === undefined)
845
747
  throw new BridgeError("ROUTE_UNREGISTERED", "The peer handoff endpoint is no longer current.");
846
- const endpoints = canonicalConsentEndpoints(source, target);
847
- let edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, endpoints));
848
- if (edge === undefined && handoff.edgeOwnerHost === peerHost) {
849
- if (state.consentEdges.length >= this.config.limits.maxConsentEdges)
850
- throw new BridgeError("CONSENT_EDGE_CAPACITY_REACHED", "The bounded consent-edge inventory is full.", true);
851
- edge = { endpoints, createdAt: now.toISOString(), updatedAt: now.toISOString(), counters: emptyCounters() };
852
- state.consentEdges.push(edge);
853
- }
854
- if (edge === undefined)
855
- throw new BridgeError("SENDER_NOT_PAIRED", "The peer handoff lacks current exact consent.");
856
748
  return this.enqueueResolved(state, now, { body: handoff.body, dedupeKey: `${peerHost}:${handoff.originMessageId}`,
857
749
  deadlineAt: handoff.deadlineAt, ...(handoff.conversationCorrelation === undefined ? {} :
858
750
  { conversationIdSuffix: handoff.conversationCorrelation }), ...(handoff.steer === true ? { steer: true } : {}) }, { sourceAlias: source.alias, targetAlias: target.alias, direction: directionId(source.binding.provider, target.binding.provider),
859
751
  sourceRegistrationId: source.binding.registrationId, targetRegistrationId: target.binding.registrationId,
860
- consentEdge: edge.endpoints, pair: true, exposeDeliveryToken: false });
752
+ exposeDeliveryToken: false });
861
753
  });
862
754
  }
863
755
  async registerRoute(input) {
@@ -883,8 +775,59 @@ export class GatewayStore {
883
775
  throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded logical route inventory is full.", true);
884
776
  }
885
777
  state.routes.push(routeRecord(input, now));
778
+ if (input.ephemeral === true)
779
+ this.ephemeralRegistrations.add(input.binding.registrationId);
886
780
  });
887
781
  }
782
+ /** Whether this registration is memory-only; false for every durable route. */
783
+ isEphemeralRegistration(registrationId) {
784
+ return this.ephemeralRegistrations.has(registrationId);
785
+ }
786
+ /**
787
+ * Everything an ephemeral registration owns, taken out of `state`: the route
788
+ * itself, every message that names it as source or target, the dedupe
789
+ * fingerprints and rate bucket keyed by its alias, the journal rows —
790
+ * message activity and runtime activity — that name the alias, and the
791
+ * queued-bytes total those messages contributed. Pure: the caller decides
792
+ * whether this becomes the written projection or the live state.
793
+ */
794
+ ephemeralRowsRemoved(state, ids, aliases) {
795
+ const kept = (message) => !ids.has(message.sourceRegistrationId) &&
796
+ (message.targetRegistrationId === null || !ids.has(message.targetRegistrationId));
797
+ const names = (row) => aliases.has(row.sourceAlias) || aliases.has(row.targetAlias);
798
+ const droppedQueuedBytes = state.messages
799
+ .filter((message) => !kept(message) && message.state.phase === "queued")
800
+ .reduce((total, message) => total + message.bytes, 0);
801
+ return {
802
+ routes: state.routes.filter((route) => !ids.has(route.binding.registrationId)),
803
+ messages: state.messages.filter(kept),
804
+ dedupe: state.dedupe.filter((row) => !names(row)),
805
+ rateBuckets: state.rateBuckets.filter((row) => !aliases.has(row.sourceAlias)),
806
+ activity: state.activity.filter((entry) => entry.type === "message_activity"
807
+ ? !names(entry.event)
808
+ : !entry.event.aliases.some((alias) => aliases.has(alias))),
809
+ accounting: { ...state.accounting,
810
+ queuedBytes: state.accounting.queuedBytes - droppedQueuedBytes },
811
+ };
812
+ }
813
+ /**
814
+ * The document actually written. While an ephemeral registration lives, its
815
+ * rows are projected out here, so the written state stays internally
816
+ * consistent under the strict loader — which refuses a state whose active
817
+ * message points at a route it cannot find, or whose queued total disagrees
818
+ * with its queued rows. Both refusals are exactly right, and neither must
819
+ * ever be reachable by a document we wrote. When the registration is
820
+ * removed, `removeRegistrationMetadata` takes the same rows out of the live
821
+ * state in the same mutate, so nothing of it is written afterwards either.
822
+ */
823
+ persistedProjection(state) {
824
+ if (this.ephemeralRegistrations.size === 0)
825
+ return state;
826
+ const dropped = state.routes.filter((route) => this.ephemeralRegistrations.has(route.binding.registrationId));
827
+ if (dropped.length === 0)
828
+ return state;
829
+ return { ...state, ...this.ephemeralRowsRemoved(state, new Set(dropped.map((route) => route.binding.registrationId)), new Set(dropped.map((route) => route.alias))) };
830
+ }
888
831
  async removeOwnedRouteAtomic(input) {
889
832
  return this.mutate((state, now) => {
890
833
  if (!ALIAS_PATTERN.test(input.alias) || !isLogicalBinding(input.binding)) {
@@ -894,19 +837,17 @@ export class GatewayStore {
894
837
  if (route === undefined || !sameBinding(route.binding, input.binding)) {
895
838
  return { removed: false, settlements: [] };
896
839
  }
897
- if (input.activity !== undefined &&
898
- route.binding.provider !== "codex" &&
899
- route.binding.provider !== "claude") {
900
- throw new BridgeError("INVALID_ROUTE_BINDING", "Only exact Codex unregister or Claude unselection has public activity.");
840
+ if (route.registrationMode === "federated_peer") {
841
+ throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A federated route is owned by its peer node; only catalog reconciliation may retire it.");
842
+ }
843
+ if (input.activity !== undefined && route.binding.provider !== "codex") {
844
+ throw new BridgeError("INVALID_ROUTE_BINDING", "Only exact Codex unregister has public activity.");
901
845
  }
902
846
  const settlements = this.terminalizeRegistration(state, route.binding.registrationId, now);
903
847
  this.removeRegistrationMetadata(state, route);
904
848
  if (input.activity !== undefined) {
905
849
  this.appendRuntimeActivity(state, now, {
906
- kind: route.binding.provider === "claude" ? "selection" : "registration",
907
- action: route.binding.provider === "claude"
908
- ? "claude_unselected"
909
- : "codex_unregistered",
850
+ kind: "registration", action: "codex_unregistered",
910
851
  outcome: "accepted", aliases: [route.alias],
911
852
  operatorAction: input.activity.operatorAction,
912
853
  });
@@ -969,90 +910,129 @@ export class GatewayStore {
969
910
  return { replaced: true, idempotent: false, settlements };
970
911
  });
971
912
  }
972
- async replaceClaudeSelection(replacement) {
913
+ /**
914
+ * Installs the route of a discovered Claude session on its first send, or
915
+ * brings an existing route up to date with discovery. The (host, session
916
+ * UUID) pair is the identity: a session already bound keeps its
917
+ * registration (and its in-flight conversations) and is renamed if its
918
+ * current name changed; a route whose alias now names a different session
919
+ * is displaced, its work settled `cancelled/ENDPOINT_RETIRED`. Both
920
+ * outcomes are journaled so `embassy status` can show them. Installation
921
+ * never creates a permission: same-UID, same-host addressing already is
922
+ * the permission, and the service's alias-collision fence has already run.
923
+ */
924
+ async installClaudeRoute(replacement) {
973
925
  return this.mutate((state, now) => {
974
926
  this.validateRouteInput(replacement);
975
927
  if (replacement.binding.provider !== "claude") {
976
- throw new BridgeError("INVALID_ROUTE_BINDING", "Claude selection replacement requires a Claude logical route.");
928
+ throw new BridgeError("INVALID_ROUTE_BINDING", "Only a Claude logical route installs on first send.");
977
929
  }
978
930
  const byAlias = state.routes.find((route) => route.alias === replacement.alias);
979
931
  const byRegistration = state.routes.find((route) => route.binding.registrationId === replacement.binding.registrationId);
980
- const byTarget = state.routes.find((route) => route.binding.provider === "claude" &&
932
+ const byIdentity = state.routes.find((route) => route.binding.provider === "claude" &&
981
933
  route.binding.hostId === replacement.binding.hostId &&
982
934
  route.binding.routeHandle === replacement.binding.routeHandle);
983
- if ((byRegistration === undefined) !== (byTarget === undefined) ||
984
- (byRegistration !== undefined && byRegistration !== byTarget)) {
985
- throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The Claude registration ID and native target do not identify one route.");
986
- }
987
- const byIdentity = byRegistration;
988
- if (byAlias !== undefined &&
989
- byIdentity !== undefined &&
990
- byAlias !== byIdentity) {
991
- throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The Claude alias and logical identity belong to distinct selections.");
992
- }
993
- const current = byIdentity ?? byAlias;
994
- if (current === undefined) {
935
+ if (byRegistration !== undefined && byRegistration !== byIdentity) {
936
+ throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The Claude registration ID and native session do not identify one route.");
937
+ }
938
+ if (byIdentity !== undefined &&
939
+ byIdentity.binding.registrationId !== replacement.binding.registrationId) {
940
+ throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The native Claude session is already registered under another identity.");
941
+ }
942
+ const settlements = [];
943
+ if (byAlias !== undefined && byAlias !== byIdentity) {
944
+ if (byAlias.binding.provider !== "claude" || byAlias.registrationMode === "federated_peer") {
945
+ throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The alias belongs to a route that a Claude session cannot displace.");
946
+ }
947
+ settlements.push(...this.terminalizeRegistration(state, byAlias.binding.registrationId, now, "ENDPOINT_RETIRED"));
948
+ this.removeRegistrationMetadata(state, byAlias);
949
+ this.appendRuntimeActivity(state, now, {
950
+ kind: "registration", action: "claude_route_retired",
951
+ outcome: "accepted", aliases: [byAlias.alias], operatorAction: true,
952
+ });
953
+ }
954
+ if (byIdentity === undefined) {
995
955
  if (state.routes.length >= this.config.limits.maxRoutes) {
996
956
  throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded logical route inventory is full.", true);
997
957
  }
998
958
  state.routes.push(routeRecord(replacement, now));
999
- return { settlements: [] };
1000
- }
1001
- if (current.binding.routeHandle === replacement.binding.routeHandle &&
1002
- current.binding.registrationId === replacement.binding.registrationId) {
1003
- this.renameRegistrationCoordinates(state, current.alias, replacement.alias, current.binding.registrationId);
1004
- current.alias = replacement.alias;
1005
- current.enabled = true;
1006
- current.updatedAt = now.toISOString();
1007
- return { settlements: [] };
1008
- }
1009
- const settlements = this.terminalizeRegistration(state, current.binding.registrationId, now);
1010
- this.removeRegistrationMetadata(state, current);
1011
- state.routes.push(routeRecord(replacement, now));
1012
- return { settlements };
1013
- });
1014
- }
1015
- async hasConsentEdge(input) {
1016
- return this.read((state) => {
1017
- const pair = this.resolvePair(state, input.aliases, false, input.expectedRegistrationIds);
1018
- return (pair !== undefined &&
1019
- state.consentEdges.some((edge) => sameConsent(edge.endpoints, pair.endpoints)));
1020
- });
1021
- }
1022
- async addConsentEdge(input) {
1023
- await this.mutate((state, now) => {
1024
- const pair = this.resolvePair(state, input.aliases, true, input.expectedRegistrationIds);
1025
- if (state.consentEdges.some((edge) => sameConsent(edge.endpoints, pair.endpoints))) {
1026
- return;
959
+ this.appendRuntimeActivity(state, now, {
960
+ kind: "registration", action: "claude_route_installed",
961
+ outcome: "accepted", aliases: [replacement.alias], operatorAction: true,
962
+ });
963
+ return { installed: true, settlements };
1027
964
  }
1028
- if (state.consentEdges.length >= this.config.limits.maxConsentEdges) {
1029
- throw new BridgeError("CONSENT_EDGE_CAPACITY_REACHED", "The bounded consent-edge inventory is full.", true);
965
+ const priorAlias = byIdentity.alias;
966
+ if (priorAlias !== replacement.alias) {
967
+ this.renameRegistrationCoordinates(state, priorAlias, replacement.alias, byIdentity.binding.registrationId);
968
+ byIdentity.alias = replacement.alias;
969
+ this.appendRuntimeActivity(state, now, {
970
+ kind: "registration", action: "claude_route_installed",
971
+ outcome: "accepted", aliases: [priorAlias, replacement.alias], operatorAction: true,
972
+ });
1030
973
  }
1031
- state.consentEdges.push({
1032
- endpoints: pair.endpoints, createdAt: now.toISOString(),
1033
- updatedAt: now.toISOString(), counters: emptyCounters(),
1034
- });
974
+ byIdentity.enabled = true;
975
+ byIdentity.updatedAt = now.toISOString();
976
+ return { installed: priorAlias !== replacement.alias, settlements };
1035
977
  });
1036
978
  }
1037
- async removeConsentEdge(input) {
1038
- return this.mutate((state, now) => {
1039
- const pair = this.resolvePair(state, input.aliases, false, input.expectedRegistrationIds);
1040
- if (pair === undefined) {
1041
- return { settlements: [], unreferencedAliases: [] };
1042
- }
1043
- const index = state.consentEdges.findIndex((edge) => sameConsent(edge.endpoints, pair.endpoints));
1044
- if (index < 0)
1045
- return { settlements: [], unreferencedAliases: [] };
1046
- const edge = state.consentEdges[index];
1047
- const settlements = this.terminalizeConsentEdge(state, edge, now);
1048
- state.consentEdges.splice(index, 1);
1049
- return {
1050
- settlements,
1051
- unreferencedAliases: edge.endpoints
1052
- .map((endpoint) => endpoint.alias)
1053
- .filter((alias) => !state.consentEdges.some((candidate) => candidate.endpoints.some((endpoint) => endpoint.alias === alias))),
1054
- };
979
+ /**
980
+ * The refusal half of admission, without the write. A send to a Claude
981
+ * session that has no route yet would otherwise install one — displacing
982
+ * another session and settling its queued work — on its way to being
983
+ * refused for its own reasons: too large, past the deadline window, rate
984
+ * limited, or into a full queue. Callers that are about to materialize a
985
+ * route run this first and materialize only if it returns.
986
+ *
987
+ * The refusal is still journaled, because a rejection is a fact about a real
988
+ * attempt; nothing else is written, and the authoritative checks still run
989
+ * inside `enqueueMessage` itself. `dedupeKey` is optional: a first send's key
990
+ * is broker-generated and cannot collide, and omitting it keeps this from
991
+ * inventing one to test against.
992
+ */
993
+ async assertEnqueueAdmissible(input) {
994
+ const bytes = Buffer.byteLength(typeof input.body === "string" ? input.body : "", "utf8");
995
+ const refusal = await this.read((state) => {
996
+ const now = this.now();
997
+ if (bytes > this.config.limits.maxMessageBytes)
998
+ return "MESSAGE_TOO_LARGE";
999
+ const deadlineAt = input.deadlineAt ??
1000
+ new Date(now.getTime() + this.config.limits.messageDeadlineMs).toISOString();
1001
+ if (!isIsoTimestamp(deadlineAt) || Date.parse(deadlineAt) <= now.getTime() ||
1002
+ Date.parse(deadlineAt) > now.getTime() + this.config.limits.messageDeadlineMs) {
1003
+ return "INVALID_DEADLINE";
1004
+ }
1005
+ // Read-only rate probe: the real enqueue consumes the token, this only
1006
+ // asks whether one is available.
1007
+ const bucket = state.rateBuckets.find((row) => row.sourceAlias === input.sourceAlias);
1008
+ const windowOpen = bucket === undefined ||
1009
+ now.getTime() - Date.parse(bucket.windowStartedAt) >= this.config.limits.rateWindowMs;
1010
+ if (windowOpen
1011
+ ? state.rateBuckets.filter((row) => row.sourceAlias !== input.sourceAlias).length >=
1012
+ this.config.limits.maxRoutes
1013
+ : bucket.count >= this.config.limits.rateLimitPerRoute) {
1014
+ return "GATEWAY_RATE_LIMITED";
1015
+ }
1016
+ const active = state.messages.filter((message) => message.state.phase !== "terminal");
1017
+ if (active.length >= this.config.limits.maxQueueMessages ||
1018
+ active.filter((message) => message.targetAlias === input.targetAlias).length >=
1019
+ this.config.limits.maxQueueMessagesPerRoute ||
1020
+ state.accounting.queuedBytes + bytes > this.config.limits.maxQueueBytes) {
1021
+ return "GATEWAY_QUEUE_FULL";
1022
+ }
1023
+ return undefined;
1055
1024
  });
1025
+ if (refusal === undefined)
1026
+ return;
1027
+ await this.mutate((state, now) => {
1028
+ this.recordRejection(state, { sourceAlias: input.sourceAlias, targetAlias: input.targetAlias,
1029
+ direction: input.direction, sourceRegistrationId: input.sourceRegistrationId,
1030
+ targetRegistrationId: input.sourceRegistrationId }, bytes, now, refusal, input);
1031
+ });
1032
+ throw new BridgeError(refusal, refusal === "MESSAGE_TOO_LARGE" ? "The message exceeds the configured byte bound."
1033
+ : refusal === "INVALID_DEADLINE" ? "The message deadline must fall inside the configured delivery window."
1034
+ : refusal === "GATEWAY_RATE_LIMITED" ? "The source exceeded the bounded gateway rate window."
1035
+ : "The bounded gateway queue is full.", refusal === "GATEWAY_RATE_LIMITED" || refusal === "GATEWAY_QUEUE_FULL");
1056
1036
  }
1057
1037
  async enqueueMessage(input) {
1058
1038
  return this.mutate((state, now) => {
@@ -1071,12 +1051,14 @@ export class GatewayStore {
1071
1051
  target.binding.registrationId !== input.expectedTargetRegistrationId)) {
1072
1052
  throw new BridgeError("ROUTE_UNREGISTERED", "The correlated source or target registration is no longer installed.");
1073
1053
  }
1074
- const edge = this.requireConsent(state, source, target);
1054
+ if (source.binding.provider === target.binding.provider &&
1055
+ source.binding.hostId === target.binding.hostId) {
1056
+ throw new BridgeError("ROUTE_DIRECTION_MISMATCH", "Messages route only between different providers or between configured hosts.");
1057
+ }
1075
1058
  return this.enqueueResolved(state, now, input, {
1076
1059
  sourceAlias: source.alias, targetAlias: target.alias,
1077
1060
  direction: directionId(source.binding.provider, target.binding.provider),
1078
1061
  sourceRegistrationId: source.binding.registrationId, targetRegistrationId: target.binding.registrationId,
1079
- consentEdge: edge.endpoints, pair: true,
1080
1062
  exposeDeliveryToken: true,
1081
1063
  });
1082
1064
  });
@@ -1099,68 +1081,26 @@ export class GatewayStore {
1099
1081
  input.source.alias.endsWith(`@${nativeSourceHost}`) === false) {
1100
1082
  throw new BridgeError("INVALID_NATIVE_PEER", "The native Claude sender does not match the target host.");
1101
1083
  }
1102
- const selected = state.routes.find((route) => route.alias === input.source.alias &&
1084
+ // The sender's own route was installed by the service on this send;
1085
+ // the exact binding fences a same-alias replacement that raced it.
1086
+ const source = state.routes.find((route) => route.alias === input.source.alias &&
1087
+ route.enabled &&
1103
1088
  route.binding.provider === "claude" &&
1104
1089
  route.binding.routeHandle === input.source.binding.routeHandle &&
1105
1090
  route.binding.registrationId === input.source.binding.registrationId);
1106
- const edge = selected === undefined
1107
- ? undefined
1108
- : state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, canonicalConsentEndpoints(selected, target)));
1109
- if ((this.config.inboundMode === "paired" || target.registrationMode === "federated_peer") && edge === undefined) {
1110
- this.recordRejection(state, {
1111
- sourceAlias: input.source.alias, targetAlias: target.alias,
1112
- direction: directionId("claude", target.binding.provider),
1113
- sourceRegistrationId: selected?.binding.registrationId ?? null, targetRegistrationId: target.binding.registrationId,
1114
- consentEdge: null,
1115
- }, Buffer.byteLength(input.body, "utf8"), now, "SENDER_NOT_PAIRED", input);
1116
- throw new CommitAndThrow(new BridgeError("SENDER_NOT_PAIRED", "The native Claude sender lacks exact durable consent."));
1091
+ if (source === undefined) {
1092
+ throw new BridgeError("ROUTE_UNREGISTERED", "The native Claude sender's route is no longer current.");
1117
1093
  }
1118
- return this.enqueueResolved(state, now, input, {
1119
- sourceAlias: input.source.alias, targetAlias: target.alias,
1120
- direction: directionId("claude", target.binding.provider),
1121
- sourceRegistrationId: selected?.binding.registrationId ?? null,
1122
- targetRegistrationId: target.binding.registrationId, consentEdge: edge?.endpoints ?? null,
1123
- ...(edge === undefined ? {} : { pair: true }),
1124
- exposeDeliveryToken: false,
1125
- });
1126
- });
1127
- }
1128
- async enqueueNativeReply(input) {
1129
- return this.mutate((state, now) => {
1130
- if (!ALIAS_PATTERN.test(input.sourceAlias) ||
1131
- !isPrivateToken(input.expectedSourceRegistrationId)) {
1132
- throw new BridgeError("INVALID_ROUTE_BINDING", "The native reply source authority is malformed.");
1133
- }
1134
- const source = state.routes.find((route) => route.alias === input.sourceAlias && route.enabled);
1135
- if (source === undefined ||
1136
- source.binding.registrationId !== input.expectedSourceRegistrationId) {
1137
- throw new BridgeError("ROUTE_UNREGISTERED", "The native reply source registration is no longer current.");
1138
- }
1139
- if (!isLogicalBinding(input.target.binding) ||
1140
- input.target.binding.provider !== "claude" ||
1141
- input.target.binding.hostId !== source.binding.hostId ||
1142
- !input.target.alias.endsWith(`@${source.binding.hostId}`)) {
1143
- throw new BridgeError("INVALID_NATIVE_PEER", "The native Claude reply target does not match the source host.");
1144
- }
1145
- const selectedTarget = state.routes.find((route) => route.alias === input.target.alias &&
1146
- route.binding.provider === "claude" &&
1147
- route.binding.routeHandle === input.target.binding.routeHandle &&
1148
- route.binding.registrationId === input.target.binding.registrationId);
1149
- let edge;
1150
- if (input.pair === true) {
1151
- if (selectedTarget === undefined) {
1152
- throw new BridgeError("SENDER_NOT_PAIRED", "The reply no longer matches an exact selected Claude route.");
1153
- }
1154
- edge = this.requireConsent(state, source, selectedTarget);
1094
+ if (source.binding.provider === target.binding.provider &&
1095
+ source.binding.hostId === target.binding.hostId) {
1096
+ throw new BridgeError("ROUTE_DIRECTION_MISMATCH", "Messages route only between different providers or between configured hosts.");
1155
1097
  }
1156
1098
  return this.enqueueResolved(state, now, input, {
1157
- sourceAlias: source.alias, targetAlias: input.target.alias,
1158
- direction: directionId(source.binding.provider, "claude"),
1099
+ sourceAlias: source.alias, targetAlias: target.alias,
1100
+ direction: directionId("claude", target.binding.provider),
1159
1101
  sourceRegistrationId: source.binding.registrationId,
1160
- targetRegistrationId: selectedTarget?.binding.registrationId ?? input.target.binding.registrationId,
1161
- consentEdge: edge?.endpoints ?? null,
1162
- ...(edge === undefined ? { transientTarget: true } : { pair: true }),
1163
- exposeDeliveryToken: input.exposeDeliveryToken === true,
1102
+ targetRegistrationId: target.binding.registrationId,
1103
+ exposeDeliveryToken: false,
1164
1104
  });
1165
1105
  });
1166
1106
  }
@@ -1219,7 +1159,7 @@ export class GatewayStore {
1219
1159
  attemptId,
1220
1160
  attemptCount,
1221
1161
  reservedAt: now.toISOString(), sourceRegistrationId: message.sourceRegistrationId,
1222
- targetRegistrationId: message.targetRegistrationId, consentEdge: message.consentEdge,
1162
+ targetRegistrationId: message.targetRegistrationId,
1223
1163
  };
1224
1164
  state.accounting.queuedBytes -= message.bytes;
1225
1165
  this.touchMessage(state, message);
@@ -1236,7 +1176,7 @@ export class GatewayStore {
1236
1176
  ? {}
1237
1177
  : { conversationIdSuffix: message.conversationIdSuffix }),
1238
1178
  sourceRegistrationId: message.sourceRegistrationId, targetRegistrationId: message.targetRegistrationId,
1239
- consentEdge: message.consentEdge, bytes: message.bytes,
1179
+ bytes: message.bytes,
1240
1180
  ...(message.steer === true ? { steer: true } : {}),
1241
1181
  },
1242
1182
  };
@@ -1269,26 +1209,20 @@ export class GatewayStore {
1269
1209
  }
1270
1210
  if (input.sourceRegistrationId !== message.sourceRegistrationId ||
1271
1211
  input.targetRegistrationId !== message.targetRegistrationId ||
1272
- (message.transientTarget !== true && target === undefined)) {
1212
+ target === undefined) {
1273
1213
  return { status: "stale", reason: "registration_changed" };
1274
1214
  }
1275
- if (message.sourceRegistrationId !== null) {
1276
- const source = state.routes.find((route) => route.alias === message.sourceAlias &&
1277
- route.binding.registrationId === message.sourceRegistrationId &&
1278
- route.enabled);
1279
- if (source === undefined) {
1280
- return { status: "stale", reason: "registration_changed" };
1281
- }
1282
- }
1283
- if (message.consentEdge !== null &&
1284
- !state.consentEdges.some((edge) => sameConsent(edge.endpoints, message.consentEdge))) {
1285
- return { status: "stale", reason: "consent_removed" };
1215
+ const source = state.routes.find((route) => route.alias === message.sourceAlias &&
1216
+ route.binding.registrationId === message.sourceRegistrationId &&
1217
+ route.enabled);
1218
+ if (source === undefined) {
1219
+ return { status: "stale", reason: "registration_changed" };
1286
1220
  }
1287
1221
  const authority = message.state;
1288
1222
  message.state = {
1289
1223
  phase: "armed", attemptId: authority.attemptId,
1290
1224
  attemptCount: authority.attemptCount, targetRegistrationId: authority.targetRegistrationId,
1291
- sourceRegistrationId: authority.sourceRegistrationId, consentEdge: authority.consentEdge,
1225
+ sourceRegistrationId: authority.sourceRegistrationId,
1292
1226
  armedAt: now.toISOString(), prepared: { ...input.prepared },
1293
1227
  };
1294
1228
  this.touchMessage(state, message);
@@ -1306,7 +1240,7 @@ export class GatewayStore {
1306
1240
  message.state = {
1307
1241
  phase: "accepted", attemptId: authority.attemptId,
1308
1242
  attemptCount: authority.attemptCount, targetRegistrationId: authority.targetRegistrationId,
1309
- sourceRegistrationId: authority.sourceRegistrationId, consentEdge: authority.consentEdge,
1243
+ sourceRegistrationId: authority.sourceRegistrationId,
1310
1244
  acceptedAt: now.toISOString(), prepared: authority.prepared,
1311
1245
  lossOutcome: input.lossOutcome,
1312
1246
  };
@@ -1349,8 +1283,7 @@ export class GatewayStore {
1349
1283
  if ((message.state.phase === "armed" &&
1350
1284
  (input.state === "expired" ||
1351
1285
  (input.state === "unconfirmed" &&
1352
- !((input.safeErrorCode === "ACP_OUTCOME_COARSE" && message.state.prepared.kind === "acp_prompt") ||
1353
- (input.safeErrorCode === "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" && message.state.prepared.kind === "peer_handoff"))))) ||
1286
+ !(input.safeErrorCode === "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" && message.state.prepared.kind === "peer_handoff")))) ||
1354
1287
  (message.state.phase === "accepted" &&
1355
1288
  (input.state === "expired" ||
1356
1289
  (input.state === "unconfirmed" &&
@@ -1438,18 +1371,6 @@ export class GatewayStore {
1438
1371
  mutable: route.registrationMode !== "federated_peer",
1439
1372
  };
1440
1373
  });
1441
- const consentEdges = state.consentEdges.map((edge) => ({
1442
- endpoints: [
1443
- {
1444
- alias: edge.endpoints[0].alias, provider: edge.endpoints[0].provider,
1445
- },
1446
- {
1447
- alias: edge.endpoints[1].alias, provider: edge.endpoints[1].provider,
1448
- },
1449
- ],
1450
- host: edgeOwnerHost(edge.endpoints), counters: { ...edge.counters },
1451
- mutable: edgeOwnerHost(edge.endpoints) === this.config.hostId,
1452
- }));
1453
1374
  const currentEvents = state.messages.map((message) => this.projectMessageEvent(message));
1454
1375
  const messageEvents = state.activity
1455
1376
  .filter((entry) => entry.type === "message_activity")
@@ -1479,10 +1400,9 @@ export class GatewayStore {
1479
1400
  }
1480
1401
  return projectGatewayPublicSnapshot({
1481
1402
  schemaVersion: 2, generatedAt: now.toISOString(),
1482
- inboundMode: this.config.inboundMode, health: "offline",
1403
+ health: "offline",
1483
1404
  connectors: [], availablePeers: [],
1484
1405
  routes,
1485
- consentEdges,
1486
1406
  activityEvents,
1487
1407
  deadlinePressure: {
1488
1408
  configuredDeadlineMs: this.config.limits.messageDeadlineMs,
@@ -1497,7 +1417,7 @@ export class GatewayStore {
1497
1417
  accounting: { ...state.accounting }, alerts: [],
1498
1418
  truncation: {
1499
1419
  connectors: 0, availablePeers: 0,
1500
- routes: 0, consentEdges: 0,
1420
+ routes: 0,
1501
1421
  activityEvents: 0,
1502
1422
  messages: Math.max(0, messageEvents.length + currentEvents.length - messages.length),
1503
1423
  alerts: 0,
@@ -1570,51 +1490,6 @@ export class GatewayStore {
1570
1490
  }
1571
1491
  return route;
1572
1492
  }
1573
- resolvePair(state, aliases, required, expectedRegistrationIds) {
1574
- if (aliases.length !== 2 ||
1575
- aliases[0] === aliases[1] ||
1576
- !aliases.every((alias) => ALIAS_PATTERN.test(alias))) {
1577
- if (required) {
1578
- throw new BridgeError("INVALID_CONSENT_EDGE", "Consent requires two distinct exact route aliases.");
1579
- }
1580
- return undefined;
1581
- }
1582
- const left = state.routes.find((route) => route.alias === aliases[0]);
1583
- const right = state.routes.find((route) => route.alias === aliases[1]);
1584
- if (expectedRegistrationIds === undefined ||
1585
- !expectedRegistrationIds.every(isPrivateToken)) {
1586
- if (required) {
1587
- throw new BridgeError("INVALID_CONSENT_EDGE", "Consent requires both exact registration identities.");
1588
- }
1589
- return undefined;
1590
- }
1591
- if (left?.binding.registrationId !== expectedRegistrationIds[0] ||
1592
- right?.binding.registrationId !== expectedRegistrationIds[1]) {
1593
- throw new BridgeError("ROUTE_UNREGISTERED", "A consent endpoint registration is no longer current.");
1594
- }
1595
- if (left === undefined ||
1596
- right === undefined ||
1597
- (left.binding.provider === right.binding.provider &&
1598
- left.binding.hostId === right.binding.hostId)) {
1599
- if (required) {
1600
- throw new BridgeError("INVALID_CONSENT_EDGE", "Consent requires distinct providers or distinct configured hosts.");
1601
- }
1602
- return undefined;
1603
- }
1604
- return {
1605
- left,
1606
- right,
1607
- endpoints: canonicalConsentEndpoints(left, right),
1608
- };
1609
- }
1610
- requireConsent(state, source, target) {
1611
- const endpoints = canonicalConsentEndpoints(source, target);
1612
- const edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, endpoints));
1613
- if (edge === undefined) {
1614
- throw new BridgeError("SENDER_NOT_PAIRED", "The exact logical routes do not share consent.");
1615
- }
1616
- return edge;
1617
- }
1618
1493
  enqueueResolved(state, now, input, authority) {
1619
1494
  if (typeof input.body !== "string" ||
1620
1495
  input.body.length === 0 ||
@@ -1708,13 +1583,8 @@ export class GatewayStore {
1708
1583
  deadlineAt,
1709
1584
  bytes,
1710
1585
  body: input.body,
1711
- ...(authority.pair === true ? { pair: true } : {}),
1712
- ...(authority.transientTarget === true
1713
- ? { transientTarget: true }
1714
- : {}),
1715
1586
  ...(input.steer === true ? { steer: true } : {}),
1716
1587
  sourceRegistrationId: authority.sourceRegistrationId, targetRegistrationId: authority.targetRegistrationId,
1717
- consentEdge: authority.consentEdge,
1718
1588
  state: { phase: "queued", attemptCount: 0 },
1719
1589
  };
1720
1590
  state.messages.push(message);
@@ -1726,12 +1596,6 @@ export class GatewayStore {
1726
1596
  source.counters.accepted += 1;
1727
1597
  source.counters.bytesAccepted += bytes;
1728
1598
  }
1729
- const edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, authority.consentEdge));
1730
- if (edge !== undefined) {
1731
- edge.counters.accepted += 1;
1732
- edge.counters.bytesAccepted += bytes;
1733
- edge.updatedAt = now.toISOString();
1734
- }
1735
1599
  state.dedupe.push({
1736
1600
  fingerprint,
1737
1601
  messageIdSuffix,
@@ -1740,7 +1604,6 @@ export class GatewayStore {
1740
1604
  : { conversationIdSuffix: input.conversationIdSuffix }),
1741
1605
  sourceAlias: authority.sourceAlias, targetAlias: authority.targetAlias,
1742
1606
  direction: authority.direction,
1743
- ...(authority.pair === true ? { pair: true } : {}),
1744
1607
  firstSeenAt: now.toISOString(),
1745
1608
  expiresAt: new Date(now.getTime() + this.config.limits.dedupeTtlMs).toISOString(),
1746
1609
  });
@@ -1752,22 +1615,11 @@ export class GatewayStore {
1752
1615
  messageId,
1753
1616
  messageIdSuffix,
1754
1617
  ...(deliveryToken === undefined ? {} : { deliveryToken }),
1755
- ...(authority.pair === true ? { pair: true } : {}),
1756
1618
  ...(supersededSettlement === undefined
1757
1619
  ? {}
1758
1620
  : { supersededSettlement }),
1759
1621
  };
1760
1622
  }
1761
- terminalizeConsentEdge(state, edge, now) {
1762
- return state.messages.filter((message) => message.state.phase !== "terminal" &&
1763
- sameConsent(message.consentEdge, edge.endpoints)).map((message) => {
1764
- const outcome = message.state.phase === "armed" ? "ambiguous" :
1765
- message.state.phase === "accepted" ? message.state.lossOutcome : "cancelled";
1766
- return this.finishMessage(state, message, outcome, now, outcome === "ambiguous"
1767
- ? "DISPATCH_OUTCOME_AMBIGUOUS" : outcome === "unconfirmed"
1768
- ? "DELIVERY_UNCONFIRMED" : "SENDER_NOT_PAIRED");
1769
- });
1770
- }
1771
1623
  allocateDeliveryToken(state) {
1772
1624
  for (let attempt = 0; attempt < 8; attempt += 1) {
1773
1625
  const token = `dlv_${createHash("sha256")
@@ -1807,11 +1659,6 @@ export class GatewayStore {
1807
1659
  const source = state.routes.find((route) => route.binding.registrationId === authority.sourceRegistrationId);
1808
1660
  if (source !== undefined)
1809
1661
  source.counters.rejected += 1;
1810
- const edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, authority.consentEdge));
1811
- if (edge !== undefined) {
1812
- edge.counters.rejected += 1;
1813
- edge.updatedAt = now.toISOString();
1814
- }
1815
1662
  const suffix = this.randomId().replaceAll("-", "").slice(-8).toLowerCase();
1816
1663
  if (!MESSAGE_SUFFIX_PATTERN.test(suffix))
1817
1664
  return;
@@ -1858,11 +1705,6 @@ export class GatewayStore {
1858
1705
  const target = state.routes.find((route) => route.binding.registrationId === message.targetRegistrationId);
1859
1706
  if (target !== undefined)
1860
1707
  target.counters[outcome] += 1;
1861
- const edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, message.consentEdge));
1862
- if (edge !== undefined) {
1863
- edge.counters[outcome] += 1;
1864
- edge.updatedAt = now.toISOString();
1865
- }
1866
1708
  return {
1867
1709
  messageId: message.messageId, state: outcome,
1868
1710
  ...(safeErrorCode === undefined ? {} : { safeErrorCode }),
@@ -1887,7 +1729,7 @@ export class GatewayStore {
1887
1729
  }
1888
1730
  return structuredClone(recorded);
1889
1731
  }
1890
- terminalizeRegistration(state, registrationId, now) {
1732
+ terminalizeRegistration(state, registrationId, now, cancelledCode = "ROUTE_UNREGISTERED") {
1891
1733
  const settlements = [];
1892
1734
  for (const message of state.messages) {
1893
1735
  if (message.state.phase === "terminal" ||
@@ -1901,7 +1743,7 @@ export class GatewayStore {
1901
1743
  ? message.state.lossOutcome
1902
1744
  : "cancelled";
1903
1745
  settlements.push(this.finishMessage(state, message, outcome, now, outcome === "cancelled"
1904
- ? "ROUTE_UNREGISTERED"
1746
+ ? cancelledCode
1905
1747
  : outcome === "ambiguous"
1906
1748
  ? "DISPATCH_OUTCOME_AMBIGUOUS"
1907
1749
  : "DELIVERY_UNCONFIRMED"));
@@ -1909,8 +1751,16 @@ export class GatewayStore {
1909
1751
  return settlements;
1910
1752
  }
1911
1753
  removeRegistrationMetadata(state, route) {
1754
+ if (this.ephemeralRegistrations.has(route.binding.registrationId)) {
1755
+ // An ephemeral registration takes every row it owns with it — in this
1756
+ // same mutate, and before its id leaves the set — so the closing
1757
+ // persist finds nothing of it to write: not the message it sent, not
1758
+ // the reply it received, not a journal row that names it.
1759
+ Object.assign(state, this.ephemeralRowsRemoved(state, new Set([route.binding.registrationId]), new Set([route.alias])));
1760
+ this.ephemeralRegistrations.delete(route.binding.registrationId);
1761
+ return;
1762
+ }
1912
1763
  state.routes = state.routes.filter((candidate) => candidate !== route);
1913
- state.consentEdges = state.consentEdges.filter((edge) => !edge.endpoints.some((endpoint) => endpoint.registrationId === route.binding.registrationId));
1914
1764
  state.dedupe = state.dedupe.filter((entry) => entry.sourceAlias !== route.alias && entry.targetAlias !== route.alias);
1915
1765
  state.rateBuckets = state.rateBuckets.filter((entry) => entry.sourceAlias !== route.alias);
1916
1766
  }
@@ -1921,13 +1771,6 @@ export class GatewayStore {
1921
1771
  state.routes.some((route) => route.alias === newAlias)) {
1922
1772
  throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The replacement alias is invalid or already registered.");
1923
1773
  }
1924
- for (const edge of state.consentEdges) {
1925
- const updated = edge.endpoints.map((endpoint) => endpoint.registrationId === registrationId
1926
- ? { ...endpoint, alias: newAlias }
1927
- : endpoint);
1928
- updated.sort(compareConsentEndpoints);
1929
- edge.endpoints = updated;
1930
- }
1931
1774
  for (const message of state.messages) {
1932
1775
  if (message.sourceRegistrationId === registrationId) {
1933
1776
  message.sourceAlias = newAlias;
@@ -1935,24 +1778,6 @@ export class GatewayStore {
1935
1778
  if (message.targetRegistrationId === registrationId) {
1936
1779
  message.targetAlias = newAlias;
1937
1780
  }
1938
- if (message.consentEdge !== null) {
1939
- const updated = message.consentEdge.map((endpoint) => endpoint.registrationId === registrationId
1940
- ? { ...endpoint, alias: newAlias }
1941
- : endpoint);
1942
- updated.sort(compareConsentEndpoints);
1943
- message.consentEdge = updated;
1944
- }
1945
- if (message.state.phase === "reserved" ||
1946
- message.state.phase === "armed" ||
1947
- message.state.phase === "accepted") {
1948
- if (message.state.consentEdge !== null) {
1949
- const updated = message.state.consentEdge.map((endpoint) => endpoint.registrationId === registrationId
1950
- ? { ...endpoint, alias: newAlias }
1951
- : endpoint);
1952
- updated.sort(compareConsentEndpoints);
1953
- message.state.consentEdge = updated;
1954
- }
1955
- }
1956
1781
  }
1957
1782
  for (const entry of state.dedupe) {
1958
1783
  if (entry.sourceAlias === oldAlias)
@@ -1996,10 +1821,6 @@ export class GatewayStore {
1996
1821
  : "CONTROLLER_RESTARTED");
1997
1822
  continue;
1998
1823
  }
1999
- if (message.transientTarget === true) {
2000
- this.finishMessage(state, message, "abandoned", now, "CONTROLLER_RESTARTED");
2001
- continue;
2002
- }
2003
1824
  if (message.state.phase === "reserved") {
2004
1825
  const attemptCount = message.state.attemptCount;
2005
1826
  message.state = { phase: "queued", attemptCount };
@@ -2054,7 +1875,6 @@ export class GatewayStore {
2054
1875
  if (state.routes.some((route) => !this.config.allowedHosts.includes(route.binding.hostId) ||
2055
1876
  !routeModeMatchesHost(route, this.config.hostId)) ||
2056
1877
  state.routes.length > this.config.limits.maxRoutes ||
2057
- state.consentEdges.length > this.config.limits.maxConsentEdges ||
2058
1878
  active.length > this.config.limits.maxQueueMessages ||
2059
1879
  active.filter((message) => message.state.phase === "reserved" ||
2060
1880
  message.state.phase === "armed" ||
@@ -2075,6 +1895,16 @@ export class GatewayStore {
2075
1895
  return this.mutex.run("gateway", async () => {
2076
1896
  const state = this.requireState();
2077
1897
  const before = structuredClone(state);
1898
+ // The ephemeral set is state too: a removal drops an id from it inside
1899
+ // the operation, and a rollback that restored the rows but not the id
1900
+ // would turn a memory-only registration into a durable one.
1901
+ const ephemeralBefore = new Set(this.ephemeralRegistrations);
1902
+ const rollback = () => {
1903
+ this.state = before;
1904
+ this.ephemeralRegistrations.clear();
1905
+ for (const id of ephemeralBefore)
1906
+ this.ephemeralRegistrations.add(id);
1907
+ };
2078
1908
  const now = this.now();
2079
1909
  this.prune(state, now);
2080
1910
  let value;
@@ -2091,14 +1921,13 @@ export class GatewayStore {
2091
1921
  await this.persist();
2092
1922
  }
2093
1923
  catch (persistError) {
2094
- if (!(persistError instanceof PostRenamePersistenceError)) {
2095
- this.state = before;
2096
- }
1924
+ if (!(persistError instanceof PostRenamePersistenceError))
1925
+ rollback();
2097
1926
  throw persistError;
2098
1927
  }
2099
1928
  throw error.error;
2100
1929
  }
2101
- this.state = before;
1930
+ rollback();
2102
1931
  throw error;
2103
1932
  }
2104
1933
  state.updatedAt = now.toISOString();
@@ -2110,7 +1939,7 @@ export class GatewayStore {
2110
1939
  }
2111
1940
  catch (error) {
2112
1941
  if (!(error instanceof PostRenamePersistenceError))
2113
- this.state = before;
1942
+ rollback();
2114
1943
  throw error;
2115
1944
  }
2116
1945
  return value;
@@ -2189,6 +2018,58 @@ export class GatewayStore {
2189
2018
  }
2190
2019
  return root;
2191
2020
  }
2021
+ /**
2022
+ * Boot-time litter sweep (emb-106): the static `gateway-dashboard*.html`
2023
+ * files 2.x published, any `.tmp` file its temp-file+rename publish could
2024
+ * have left behind mid-crash, and controller locks this store recovered as
2025
+ * stale more than seven days ago, are unlinked best-effort. Only a
2026
+ * regular file owned by this uid is removed (lstat, no symlink following);
2027
+ * nothing else in the state directory is touched, and a removal failure
2028
+ * never blocks startup. Runs only after the controller lock is held and
2029
+ * the state this boot will run on has passed both its schema check and
2030
+ * assertConfiguredBounds, so a refused boot leaves the directory exactly
2031
+ * as found.
2032
+ */
2033
+ async removeStateDirectoryLitter(root) {
2034
+ let entries;
2035
+ try {
2036
+ entries = await readdir(root);
2037
+ }
2038
+ catch {
2039
+ return;
2040
+ }
2041
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
2042
+ const now = this.now().getTime();
2043
+ for (const name of entries) {
2044
+ const dashboard = isStaleDashboardArtifact(name);
2045
+ const recoveredAt = dashboard ? undefined : staleLockRecoveredAt(name);
2046
+ if (!dashboard && recoveredAt === undefined)
2047
+ continue;
2048
+ const filePath = path.join(root, name);
2049
+ let info;
2050
+ try {
2051
+ info = await lstat(filePath);
2052
+ }
2053
+ catch {
2054
+ continue;
2055
+ }
2056
+ if (info.isSymbolicLink() || !info.isFile() || (uid !== undefined && info.uid !== uid))
2057
+ continue;
2058
+ // A recovered lock is evidence: keep it a week from the recovery that
2059
+ // created it, which is what its name records.
2060
+ if (recoveredAt !== undefined && now - recoveredAt <= STALE_LOCK_RETENTION_MS)
2061
+ continue;
2062
+ try {
2063
+ await unlink(filePath);
2064
+ process.stderr.write(dashboard
2065
+ ? `[embassy] removed stale ${name} left by an earlier Embassy release from the gateway state directory\n`
2066
+ : `[embassy] removed ${name}, a recovered gateway lock older than seven days\n`);
2067
+ }
2068
+ catch {
2069
+ // Best-effort cleanup; leave the file in place if it cannot be removed.
2070
+ }
2071
+ }
2072
+ }
2192
2073
  assertOwnedPrivate(uid, mode, kind) {
2193
2074
  if (typeof process.getuid === "function" && uid !== process.getuid()) {
2194
2075
  throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", `The gateway ${kind} is not owned by the current process user.`);
@@ -2238,6 +2119,23 @@ export class GatewayStore {
2238
2119
  await handle.close();
2239
2120
  }
2240
2121
  }
2122
+ /**
2123
+ * Move a lock nobody owns aside instead of deleting it: a crash stays
2124
+ * diagnosable for a week (the boot sweep ages these out), and the caller
2125
+ * is told, because a silent recovery of another process's lock would be
2126
+ * the one event an operator most needs to see.
2127
+ */
2128
+ async recoverStaleLock(lockPath, description) {
2129
+ const name = `${CONTROLLER_LOCK}.stale-${String(this.now().getTime())}-${randomUUID()}`;
2130
+ const moved = await rename(lockPath, path.join(this.rootDir, name)).then(() => true, (error) => {
2131
+ // Another start recovered it first; retry the create either way.
2132
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
2133
+ return false;
2134
+ throw error;
2135
+ });
2136
+ if (moved)
2137
+ process.stderr.write(`[embassy] recovered a stale gateway lock (${description}) → ${name}\n`);
2138
+ }
2241
2139
  async acquireLock() {
2242
2140
  const lockPath = path.join(this.rootDir, CONTROLLER_LOCK);
2243
2141
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -2250,7 +2148,7 @@ export class GatewayStore {
2250
2148
  try {
2251
2149
  await handle.writeFile(`${JSON.stringify({
2252
2150
  schemaVersion: 1, pid: process.pid,
2253
- hostname: os.hostname(),
2151
+ hostname: this.hostname(),
2254
2152
  token,
2255
2153
  })}\n`, "utf8");
2256
2154
  await handle.sync();
@@ -2271,35 +2169,54 @@ export class GatewayStore {
2271
2169
  throw error;
2272
2170
  }
2273
2171
  }
2274
- let owner;
2172
+ let body;
2275
2173
  try {
2276
- owner = JSON.parse(await this.readPrivateFile(lockPath, MAX_LOCK_FILE_BYTES));
2174
+ body = await this.readPrivateFile(lockPath, MAX_LOCK_FILE_BYTES);
2277
2175
  }
2278
2176
  catch {
2279
- throw new BridgeError("GATEWAY_STATE_LOCK_UNVERIFIED", "The gateway state lock exists but cannot be safely verified.");
2177
+ throw lockUnverified();
2280
2178
  }
2281
- if (owner.hostname !== os.hostname() || !isPositiveInteger(owner.pid)) {
2282
- throw new BridgeError("GATEWAY_STATE_IN_USE", "The gateway state directory is locked by another host or unverifiable process.", true);
2179
+ // A crash between the O_EXCL create and the write leaves a zero-byte
2180
+ // lock. It records no owner at all, so it can only be the corpse of a
2181
+ // start that never began: recover it rather than wedging on it.
2182
+ if (body.trim().length === 0) {
2183
+ await this.recoverStaleLock(lockPath, "empty, left by an interrupted start");
2184
+ continue;
2283
2185
  }
2284
- let alive = true;
2186
+ let owner;
2285
2187
  try {
2286
- process.kill(owner.pid, 0);
2188
+ owner = JSON.parse(body);
2287
2189
  }
2288
- catch (error) {
2289
- if (error instanceof Error && "code" in error && error.code === "ESRCH") {
2290
- alive = false;
2291
- }
2292
- }
2293
- if (alive) {
2294
- throw new BridgeError("GATEWAY_STATE_IN_USE", "Another live gateway controller owns this state directory.", true);
2295
- }
2296
- await rename(lockPath, path.join(this.rootDir, `.gateway-controller.lock.stale-${randomUUID()}`)).catch((error) => {
2297
- if (!(error instanceof Error) ||
2298
- !("code" in error) ||
2299
- error.code !== "ENOENT") {
2300
- throw error;
2301
- }
2302
- });
2190
+ catch {
2191
+ throw lockUnverified();
2192
+ }
2193
+ // Parsed, but naming no process: there is nothing to probe and nothing
2194
+ // to claim about it, so this is unverified like any other unreadable
2195
+ // record — never "another broker may own this".
2196
+ if (!isPositiveInteger(owner.pid))
2197
+ throw lockUnverified();
2198
+ // Recovery is decided by process liveness alone; the recorded hostname
2199
+ // is informational. A machine that renames itself — a network-triggered
2200
+ // rename, a restore onto new hardware — must not wedge its own state
2201
+ // directory forever, so a dead pid is stale whatever name wrote it.
2202
+ const recorded = boundedHostname(owner.hostname);
2203
+ const pid = String(owner.pid);
2204
+ // A name this cannot represent is not reported as a name: the client
2205
+ // hint drops the whole clause rather than printing a placeholder.
2206
+ const detail = recorded === undefined ? { pid } : { host: recorded, pid };
2207
+ if (this.isProcessAlive(owner.pid)) {
2208
+ // The pid is alive, but nothing here can tell whether it is a broker:
2209
+ // pid numbers are reused, and a renamed machine records its old name.
2210
+ // Refuse, and hand the client the bounded facts its hint needs.
2211
+ const mine = boundedHostname(this.hostname());
2212
+ const message = recorded !== undefined && mine !== undefined && recorded === mine
2213
+ ? `A live process (pid ${pid}) recorded on this machine owns this gateway state directory.`
2214
+ : recorded === undefined
2215
+ ? `The gateway state lock records pid ${pid} under a machine name that cannot be verified, and a process with that pid is alive here.`
2216
+ : `The gateway state lock records host ${recorded} and pid ${pid}, and a process with that pid is alive here.`;
2217
+ throw new BridgeError("GATEWAY_STATE_IN_USE", message, true, detail);
2218
+ }
2219
+ await this.recoverStaleLock(lockPath, recorded === undefined ? `pid ${pid}, machine name unverifiable` : `recorded host ${recorded}, pid ${pid}`);
2303
2220
  }
2304
2221
  throw new BridgeError("GATEWAY_STATE_IN_USE", "Could not acquire exclusive gateway controller ownership.", true);
2305
2222
  }
@@ -2339,11 +2256,16 @@ export class GatewayStore {
2339
2256
  catch {
2340
2257
  throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state is not valid JSON.");
2341
2258
  }
2342
- if (isObject(parsed) && Object.hasOwn(parsed, "schemaVersion") && parsed.schemaVersion !== 4) {
2343
- throw new BridgeError("GATEWAY_STATE_SCHEMA_UNSUPPORTED", "The gateway state schema is unsupported. Stop Embassy, move gateway-state.json aside, keep nodes.json, then restart and re-register, select, and pair routes.");
2259
+ if (isObject(parsed) && Object.hasOwn(parsed, "schemaVersion") && parsed.schemaVersion !== 5) {
2260
+ throw new BridgeError("GATEWAY_STATE_SCHEMA_UNSUPPORTED", "The gateway state schema is unsupported. Stop Embassy, move gateway-state.json aside nodes.json, if you use federation, is untouched — then restart and re-register Codex tasks.");
2344
2261
  }
2345
- if (!isGatewayPersistedStateV4(parsed)) {
2346
- throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict v4 schema validation.");
2262
+ if (!isGatewayPersistedStateV5(parsed, this.config.hostId)) {
2263
+ // A document that is a valid v5 state on its own terms but not under this
2264
+ // broker's host identity is not corrupt: it belongs to another host, and
2265
+ // the operator deserves that sentence rather than "failed validation".
2266
+ throw isGatewayPersistedStateV5(parsed)
2267
+ ? new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway state exceeds its configured bounds or host allowlist.")
2268
+ : new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict v5 schema validation.");
2347
2269
  }
2348
2270
  this.assertConfiguredBounds(parsed);
2349
2271
  return parsed;
@@ -2351,7 +2273,7 @@ export class GatewayStore {
2351
2273
  async persist(force = false) {
2352
2274
  if (this.persistenceDeferred && !force)
2353
2275
  return;
2354
- const state = this.requireState();
2276
+ const state = this.persistedProjection(this.requireState());
2355
2277
  const temporary = path.join(this.rootDir, `.gateway-state-${randomUUID()}.tmp`);
2356
2278
  const body = `${JSON.stringify(state, null, 2)}\n`;
2357
2279
  if (Buffer.byteLength(body, "utf8") > GATEWAY_MAX_STATE_FILE_BYTES) {
@@ -2410,10 +2332,13 @@ export class GatewayStore {
2410
2332
  }
2411
2333
  const installedCurrent = installed?.commit.id === state.commit.id &&
2412
2334
  installed.commit.sequence === state.commit.sequence;
2413
- if (installedCurrent && installed !== undefined) {
2414
- this.state = installed;
2335
+ // The rename crossed the commit point and the file holds this very
2336
+ // commit: the write succeeded. The live state stays as it is — the
2337
+ // file is its projection, minus the ephemeral rows that must never be
2338
+ // read back in, so installing the readback here would silently turn
2339
+ // every live ephemeral registration into nothing.
2340
+ if (installedCurrent)
2415
2341
  return;
2416
- }
2417
2342
  const installedPrior = !readbackFailed &&
2418
2343
  (prior === undefined
2419
2344
  ? installed === undefined