agent-embassy 2.0.1 → 3.1.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 (147) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +23 -36
  3. package/README.md +123 -222
  4. package/SECURITY.md +72 -93
  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 +45 -17
  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 +7 -19
  18. package/dist/src/gateway/claude-peer.js.map +1 -1
  19. package/dist/src/gateway/cli.d.ts +17 -7
  20. package/dist/src/gateway/cli.js +945 -265
  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 +66 -80
  31. package/dist/src/gateway/control.js +92 -147
  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 +13 -11
  40. package/dist/src/gateway/peer-protocol.js +14 -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 +11 -7
  46. package/dist/src/gateway/providers.js +49 -50
  47. package/dist/src/gateway/providers.js.map +1 -1
  48. package/dist/src/gateway/server.d.ts +4 -14
  49. package/dist/src/gateway/server.js +32 -54
  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 +119 -38
  55. package/dist/src/gateway/service.js +754 -798
  56. package/dist/src/gateway/service.js.map +1 -1
  57. package/dist/src/gateway/status-view.d.ts +119 -0
  58. package/dist/src/gateway/status-view.js +484 -0
  59. package/dist/src/gateway/status-view.js.map +1 -0
  60. package/dist/src/gateway/store.d.ts +106 -21
  61. package/dist/src/gateway/store.js +463 -537
  62. package/dist/src/gateway/store.js.map +1 -1
  63. package/dist/src/gateway/types.d.ts +64 -102
  64. package/dist/src/gateway/types.js +40 -86
  65. package/dist/src/gateway/types.js.map +1 -1
  66. package/docs/CONFIGURATION.md +174 -45
  67. package/docs/DELIVERY.md +11 -11
  68. package/docs/GATEWAY-ARCHITECTURE.md +292 -384
  69. package/package.json +4 -12
  70. package/skills/embassy-peer/SKILL.md +69 -92
  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/claude-helper-client.d.ts +0 -2
  84. package/dist/src/gateway/claude-helper-client.js +0 -2
  85. package/dist/src/gateway/claude-helper-client.js.map +0 -1
  86. package/dist/src/gateway/cli-copy.d.ts +0 -8
  87. package/dist/src/gateway/cli-copy.en.d.ts +0 -22
  88. package/dist/src/gateway/cli-copy.en.js +0 -62
  89. package/dist/src/gateway/cli-copy.en.js.map +0 -1
  90. package/dist/src/gateway/cli-copy.js +0 -27
  91. package/dist/src/gateway/cli-copy.js.map +0 -1
  92. package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
  93. package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
  94. package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
  95. package/dist/src/gateway/codex-doctor.d.ts +0 -36
  96. package/dist/src/gateway/codex-doctor.js +0 -127
  97. package/dist/src/gateway/codex-doctor.js.map +0 -1
  98. package/dist/src/gateway/dashboard-copy.d.ts +0 -7
  99. package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
  100. package/dist/src/gateway/dashboard-copy.en.js +0 -505
  101. package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
  102. package/dist/src/gateway/dashboard-copy.js +0 -514
  103. package/dist/src/gateway/dashboard-copy.js.map +0 -1
  104. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
  105. package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
  106. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
  107. package/dist/src/gateway/dashboard-model.d.ts +0 -343
  108. package/dist/src/gateway/dashboard-model.js +0 -1061
  109. package/dist/src/gateway/dashboard-model.js.map +0 -1
  110. package/dist/src/gateway/dashboard.d.ts +0 -20
  111. package/dist/src/gateway/dashboard.js +0 -874
  112. package/dist/src/gateway/dashboard.js.map +0 -1
  113. package/dist/src/gateway/deepseek-detect.d.ts +0 -14
  114. package/dist/src/gateway/deepseek-detect.js +0 -41
  115. package/dist/src/gateway/deepseek-detect.js.map +0 -1
  116. package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
  117. package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
  118. package/dist/src/gateway/live-dashboard-assets.js +0 -74
  119. package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
  120. package/dist/src/gateway/live-dashboard-command.d.ts +0 -60
  121. package/dist/src/gateway/live-dashboard-command.js +0 -334
  122. package/dist/src/gateway/live-dashboard-command.js.map +0 -1
  123. package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
  124. package/dist/src/gateway/live-dashboard-http.js +0 -383
  125. package/dist/src/gateway/live-dashboard-http.js.map +0 -1
  126. package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
  127. package/dist/src/gateway/live-dashboard-protocol.js +0 -114
  128. package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
  129. package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
  130. package/dist/src/gateway/live-dashboard-server.js +0 -144
  131. package/dist/src/gateway/live-dashboard-server.js.map +0 -1
  132. package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
  133. package/dist/src/gateway/live-dashboard-stream.js +0 -234
  134. package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
  135. package/dist/src/gateway/live-dashboard.d.ts +0 -28
  136. package/dist/src/gateway/live-dashboard.js +0 -154
  137. package/dist/src/gateway/live-dashboard.js.map +0 -1
  138. package/dist/src/gateway/locale.d.ts +0 -4
  139. package/dist/src/gateway/locale.js +0 -10
  140. package/dist/src/gateway/locale.js.map +0 -1
  141. package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
  142. package/dist/src/gateway/progress-watch-machine.js +0 -70
  143. package/dist/src/gateway/progress-watch-machine.js.map +0 -1
  144. package/docs/CONFIGURATION.zh-CN.md +0 -97
  145. package/docs/DASHBOARD.md +0 -98
  146. package/docs/DASHBOARD.zh-CN.md +0 -49
  147. 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,60 @@ 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. Attributable rows and bodies are removed;
822
+ * aggregate counters may remain advanced.
823
+ */
824
+ persistedProjection(state) {
825
+ if (this.ephemeralRegistrations.size === 0)
826
+ return state;
827
+ const dropped = state.routes.filter((route) => this.ephemeralRegistrations.has(route.binding.registrationId));
828
+ if (dropped.length === 0)
829
+ return state;
830
+ return { ...state, ...this.ephemeralRowsRemoved(state, new Set(dropped.map((route) => route.binding.registrationId)), new Set(dropped.map((route) => route.alias))) };
831
+ }
888
832
  async removeOwnedRouteAtomic(input) {
889
833
  return this.mutate((state, now) => {
890
834
  if (!ALIAS_PATTERN.test(input.alias) || !isLogicalBinding(input.binding)) {
@@ -894,19 +838,17 @@ export class GatewayStore {
894
838
  if (route === undefined || !sameBinding(route.binding, input.binding)) {
895
839
  return { removed: false, settlements: [] };
896
840
  }
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.");
841
+ if (route.registrationMode === "federated_peer") {
842
+ throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A federated route is owned by its peer node; only catalog reconciliation may retire it.");
843
+ }
844
+ if (input.activity !== undefined && input.activity.action !== "route_retired" && route.binding.provider !== "codex") {
845
+ throw new BridgeError("INVALID_ROUTE_BINDING", "Only exact Codex unregister has public activity.");
901
846
  }
902
847
  const settlements = this.terminalizeRegistration(state, route.binding.registrationId, now);
903
848
  this.removeRegistrationMetadata(state, route);
904
849
  if (input.activity !== undefined) {
905
850
  this.appendRuntimeActivity(state, now, {
906
- kind: route.binding.provider === "claude" ? "selection" : "registration",
907
- action: route.binding.provider === "claude"
908
- ? "claude_unselected"
909
- : "codex_unregistered",
851
+ kind: "registration", action: input.activity.action ?? "codex_unregistered",
910
852
  outcome: "accepted", aliases: [route.alias],
911
853
  operatorAction: input.activity.operatorAction,
912
854
  });
@@ -969,90 +911,129 @@ export class GatewayStore {
969
911
  return { replaced: true, idempotent: false, settlements };
970
912
  });
971
913
  }
972
- async replaceClaudeSelection(replacement) {
914
+ /**
915
+ * Installs the route of a discovered Claude session on its first send, or
916
+ * brings an existing route up to date with discovery. The (host, session
917
+ * UUID) pair is the identity: a session already bound keeps its
918
+ * registration (and its in-flight conversations) and is renamed if its
919
+ * current name changed; a route whose alias now names a different session
920
+ * is displaced, its work settled `cancelled/ENDPOINT_RETIRED`. Both
921
+ * outcomes are journaled so `embassy status` can show them. Installation
922
+ * never creates a permission: same-UID, same-host addressing already is
923
+ * the permission, and the service's alias-collision fence has already run.
924
+ */
925
+ async installClaudeRoute(replacement) {
973
926
  return this.mutate((state, now) => {
974
927
  this.validateRouteInput(replacement);
975
928
  if (replacement.binding.provider !== "claude") {
976
- throw new BridgeError("INVALID_ROUTE_BINDING", "Claude selection replacement requires a Claude logical route.");
929
+ throw new BridgeError("INVALID_ROUTE_BINDING", "Only a Claude logical route installs on first send.");
977
930
  }
978
931
  const byAlias = state.routes.find((route) => route.alias === replacement.alias);
979
932
  const byRegistration = state.routes.find((route) => route.binding.registrationId === replacement.binding.registrationId);
980
- const byTarget = state.routes.find((route) => route.binding.provider === "claude" &&
933
+ const byIdentity = state.routes.find((route) => route.binding.provider === "claude" &&
981
934
  route.binding.hostId === replacement.binding.hostId &&
982
935
  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) {
936
+ if (byRegistration !== undefined && byRegistration !== byIdentity) {
937
+ throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The Claude registration ID and native session do not identify one route.");
938
+ }
939
+ if (byIdentity !== undefined &&
940
+ byIdentity.binding.registrationId !== replacement.binding.registrationId) {
941
+ throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The native Claude session is already registered under another identity.");
942
+ }
943
+ const settlements = [];
944
+ if (byAlias !== undefined && byAlias !== byIdentity) {
945
+ if (byAlias.binding.provider !== "claude" || byAlias.registrationMode === "federated_peer") {
946
+ throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The alias belongs to a route that a Claude session cannot displace.");
947
+ }
948
+ settlements.push(...this.terminalizeRegistration(state, byAlias.binding.registrationId, now, "ENDPOINT_RETIRED"));
949
+ this.removeRegistrationMetadata(state, byAlias);
950
+ this.appendRuntimeActivity(state, now, {
951
+ kind: "registration", action: "claude_route_retired",
952
+ outcome: "accepted", aliases: [byAlias.alias], operatorAction: true,
953
+ });
954
+ }
955
+ if (byIdentity === undefined) {
995
956
  if (state.routes.length >= this.config.limits.maxRoutes) {
996
957
  throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded logical route inventory is full.", true);
997
958
  }
998
959
  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;
960
+ this.appendRuntimeActivity(state, now, {
961
+ kind: "registration", action: "claude_route_installed",
962
+ outcome: "accepted", aliases: [replacement.alias], operatorAction: true,
963
+ });
964
+ return { installed: true, settlements };
1027
965
  }
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);
966
+ const priorAlias = byIdentity.alias;
967
+ if (priorAlias !== replacement.alias) {
968
+ this.renameRegistrationCoordinates(state, priorAlias, replacement.alias, byIdentity.binding.registrationId);
969
+ byIdentity.alias = replacement.alias;
970
+ this.appendRuntimeActivity(state, now, {
971
+ kind: "registration", action: "claude_route_installed",
972
+ outcome: "accepted", aliases: [priorAlias, replacement.alias], operatorAction: true,
973
+ });
1030
974
  }
1031
- state.consentEdges.push({
1032
- endpoints: pair.endpoints, createdAt: now.toISOString(),
1033
- updatedAt: now.toISOString(), counters: emptyCounters(),
1034
- });
975
+ byIdentity.enabled = true;
976
+ byIdentity.updatedAt = now.toISOString();
977
+ return { installed: priorAlias !== replacement.alias, settlements };
1035
978
  });
1036
979
  }
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
- };
980
+ /**
981
+ * The refusal half of admission, without the write. A send to a Claude
982
+ * session that has no route yet would otherwise install one — displacing
983
+ * another session and settling its queued work — on its way to being
984
+ * refused for its own reasons: too large, past the deadline window, rate
985
+ * limited, or into a full queue. Callers that are about to materialize a
986
+ * route run this first and materialize only if it returns.
987
+ *
988
+ * The refusal is still journaled, because a rejection is a fact about a real
989
+ * attempt; nothing else is written, and the authoritative checks still run
990
+ * inside `enqueueMessage` itself. `dedupeKey` is optional: a first send's key
991
+ * is broker-generated and cannot collide, and omitting it keeps this from
992
+ * inventing one to test against.
993
+ */
994
+ async assertEnqueueAdmissible(input) {
995
+ const bytes = Buffer.byteLength(typeof input.body === "string" ? input.body : "", "utf8");
996
+ const refusal = await this.read((state) => {
997
+ const now = this.now();
998
+ if (bytes > this.config.limits.maxMessageBytes)
999
+ return "MESSAGE_TOO_LARGE";
1000
+ const deadlineAt = input.deadlineAt ??
1001
+ new Date(now.getTime() + this.config.limits.messageDeadlineMs).toISOString();
1002
+ if (!isIsoTimestamp(deadlineAt) || Date.parse(deadlineAt) <= now.getTime() ||
1003
+ Date.parse(deadlineAt) > now.getTime() + this.config.limits.messageDeadlineMs) {
1004
+ return "INVALID_DEADLINE";
1005
+ }
1006
+ // Read-only rate probe: the real enqueue consumes the token, this only
1007
+ // asks whether one is available.
1008
+ const bucket = state.rateBuckets.find((row) => row.sourceAlias === input.sourceAlias);
1009
+ const windowOpen = bucket === undefined ||
1010
+ now.getTime() - Date.parse(bucket.windowStartedAt) >= this.config.limits.rateWindowMs;
1011
+ if (windowOpen
1012
+ ? state.rateBuckets.filter((row) => row.sourceAlias !== input.sourceAlias).length >=
1013
+ this.config.limits.maxRoutes
1014
+ : bucket.count >= this.config.limits.rateLimitPerRoute) {
1015
+ return "GATEWAY_RATE_LIMITED";
1016
+ }
1017
+ const active = state.messages.filter((message) => message.state.phase !== "terminal");
1018
+ if (active.length >= this.config.limits.maxQueueMessages ||
1019
+ active.filter((message) => message.targetAlias === input.targetAlias).length >=
1020
+ this.config.limits.maxQueueMessagesPerRoute ||
1021
+ state.accounting.queuedBytes + bytes > this.config.limits.maxQueueBytes) {
1022
+ return "GATEWAY_QUEUE_FULL";
1023
+ }
1024
+ return undefined;
1055
1025
  });
1026
+ if (refusal === undefined)
1027
+ return;
1028
+ await this.mutate((state, now) => {
1029
+ this.recordRejection(state, { sourceAlias: input.sourceAlias, targetAlias: input.targetAlias,
1030
+ direction: input.direction, sourceRegistrationId: input.sourceRegistrationId,
1031
+ targetRegistrationId: input.sourceRegistrationId }, bytes, now, refusal, input);
1032
+ });
1033
+ throw new BridgeError(refusal, refusal === "MESSAGE_TOO_LARGE" ? "The message exceeds the configured byte bound."
1034
+ : refusal === "INVALID_DEADLINE" ? "The message deadline must fall inside the configured delivery window."
1035
+ : refusal === "GATEWAY_RATE_LIMITED" ? "The source exceeded the bounded gateway rate window."
1036
+ : "The bounded gateway queue is full.", refusal === "GATEWAY_RATE_LIMITED" || refusal === "GATEWAY_QUEUE_FULL");
1056
1037
  }
1057
1038
  async enqueueMessage(input) {
1058
1039
  return this.mutate((state, now) => {
@@ -1071,12 +1052,14 @@ export class GatewayStore {
1071
1052
  target.binding.registrationId !== input.expectedTargetRegistrationId)) {
1072
1053
  throw new BridgeError("ROUTE_UNREGISTERED", "The correlated source or target registration is no longer installed.");
1073
1054
  }
1074
- const edge = this.requireConsent(state, source, target);
1055
+ if (source.binding.provider === target.binding.provider &&
1056
+ source.binding.hostId === target.binding.hostId) {
1057
+ throw new BridgeError("ROUTE_DIRECTION_MISMATCH", "Messages route only between different providers or between configured hosts.");
1058
+ }
1075
1059
  return this.enqueueResolved(state, now, input, {
1076
1060
  sourceAlias: source.alias, targetAlias: target.alias,
1077
1061
  direction: directionId(source.binding.provider, target.binding.provider),
1078
1062
  sourceRegistrationId: source.binding.registrationId, targetRegistrationId: target.binding.registrationId,
1079
- consentEdge: edge.endpoints, pair: true,
1080
1063
  exposeDeliveryToken: true,
1081
1064
  });
1082
1065
  });
@@ -1099,68 +1082,26 @@ export class GatewayStore {
1099
1082
  input.source.alias.endsWith(`@${nativeSourceHost}`) === false) {
1100
1083
  throw new BridgeError("INVALID_NATIVE_PEER", "The native Claude sender does not match the target host.");
1101
1084
  }
1102
- const selected = state.routes.find((route) => route.alias === input.source.alias &&
1085
+ // The sender's own route was installed by the service on this send;
1086
+ // the exact binding fences a same-alias replacement that raced it.
1087
+ const source = state.routes.find((route) => route.alias === input.source.alias &&
1088
+ route.enabled &&
1103
1089
  route.binding.provider === "claude" &&
1104
1090
  route.binding.routeHandle === input.source.binding.routeHandle &&
1105
1091
  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."));
1092
+ if (source === undefined) {
1093
+ throw new BridgeError("ROUTE_UNREGISTERED", "The native Claude sender's route is no longer current.");
1117
1094
  }
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);
1095
+ if (source.binding.provider === target.binding.provider &&
1096
+ source.binding.hostId === target.binding.hostId) {
1097
+ throw new BridgeError("ROUTE_DIRECTION_MISMATCH", "Messages route only between different providers or between configured hosts.");
1155
1098
  }
1156
1099
  return this.enqueueResolved(state, now, input, {
1157
- sourceAlias: source.alias, targetAlias: input.target.alias,
1158
- direction: directionId(source.binding.provider, "claude"),
1100
+ sourceAlias: source.alias, targetAlias: target.alias,
1101
+ direction: directionId("claude", target.binding.provider),
1159
1102
  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,
1103
+ targetRegistrationId: target.binding.registrationId,
1104
+ exposeDeliveryToken: false,
1164
1105
  });
1165
1106
  });
1166
1107
  }
@@ -1219,7 +1160,7 @@ export class GatewayStore {
1219
1160
  attemptId,
1220
1161
  attemptCount,
1221
1162
  reservedAt: now.toISOString(), sourceRegistrationId: message.sourceRegistrationId,
1222
- targetRegistrationId: message.targetRegistrationId, consentEdge: message.consentEdge,
1163
+ targetRegistrationId: message.targetRegistrationId,
1223
1164
  };
1224
1165
  state.accounting.queuedBytes -= message.bytes;
1225
1166
  this.touchMessage(state, message);
@@ -1236,7 +1177,7 @@ export class GatewayStore {
1236
1177
  ? {}
1237
1178
  : { conversationIdSuffix: message.conversationIdSuffix }),
1238
1179
  sourceRegistrationId: message.sourceRegistrationId, targetRegistrationId: message.targetRegistrationId,
1239
- consentEdge: message.consentEdge, bytes: message.bytes,
1180
+ bytes: message.bytes,
1240
1181
  ...(message.steer === true ? { steer: true } : {}),
1241
1182
  },
1242
1183
  };
@@ -1269,26 +1210,20 @@ export class GatewayStore {
1269
1210
  }
1270
1211
  if (input.sourceRegistrationId !== message.sourceRegistrationId ||
1271
1212
  input.targetRegistrationId !== message.targetRegistrationId ||
1272
- (message.transientTarget !== true && target === undefined)) {
1213
+ target === undefined) {
1273
1214
  return { status: "stale", reason: "registration_changed" };
1274
1215
  }
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" };
1216
+ const source = state.routes.find((route) => route.alias === message.sourceAlias &&
1217
+ route.binding.registrationId === message.sourceRegistrationId &&
1218
+ route.enabled);
1219
+ if (source === undefined) {
1220
+ return { status: "stale", reason: "registration_changed" };
1286
1221
  }
1287
1222
  const authority = message.state;
1288
1223
  message.state = {
1289
1224
  phase: "armed", attemptId: authority.attemptId,
1290
1225
  attemptCount: authority.attemptCount, targetRegistrationId: authority.targetRegistrationId,
1291
- sourceRegistrationId: authority.sourceRegistrationId, consentEdge: authority.consentEdge,
1226
+ sourceRegistrationId: authority.sourceRegistrationId,
1292
1227
  armedAt: now.toISOString(), prepared: { ...input.prepared },
1293
1228
  };
1294
1229
  this.touchMessage(state, message);
@@ -1306,7 +1241,7 @@ export class GatewayStore {
1306
1241
  message.state = {
1307
1242
  phase: "accepted", attemptId: authority.attemptId,
1308
1243
  attemptCount: authority.attemptCount, targetRegistrationId: authority.targetRegistrationId,
1309
- sourceRegistrationId: authority.sourceRegistrationId, consentEdge: authority.consentEdge,
1244
+ sourceRegistrationId: authority.sourceRegistrationId,
1310
1245
  acceptedAt: now.toISOString(), prepared: authority.prepared,
1311
1246
  lossOutcome: input.lossOutcome,
1312
1247
  };
@@ -1349,8 +1284,7 @@ export class GatewayStore {
1349
1284
  if ((message.state.phase === "armed" &&
1350
1285
  (input.state === "expired" ||
1351
1286
  (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"))))) ||
1287
+ !(input.safeErrorCode === "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" && message.state.prepared.kind === "peer_handoff")))) ||
1354
1288
  (message.state.phase === "accepted" &&
1355
1289
  (input.state === "expired" ||
1356
1290
  (input.state === "unconfirmed" &&
@@ -1438,18 +1372,6 @@ export class GatewayStore {
1438
1372
  mutable: route.registrationMode !== "federated_peer",
1439
1373
  };
1440
1374
  });
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
1375
  const currentEvents = state.messages.map((message) => this.projectMessageEvent(message));
1454
1376
  const messageEvents = state.activity
1455
1377
  .filter((entry) => entry.type === "message_activity")
@@ -1479,10 +1401,9 @@ export class GatewayStore {
1479
1401
  }
1480
1402
  return projectGatewayPublicSnapshot({
1481
1403
  schemaVersion: 2, generatedAt: now.toISOString(),
1482
- inboundMode: this.config.inboundMode, health: "offline",
1404
+ health: "offline",
1483
1405
  connectors: [], availablePeers: [],
1484
1406
  routes,
1485
- consentEdges,
1486
1407
  activityEvents,
1487
1408
  deadlinePressure: {
1488
1409
  configuredDeadlineMs: this.config.limits.messageDeadlineMs,
@@ -1497,7 +1418,7 @@ export class GatewayStore {
1497
1418
  accounting: { ...state.accounting }, alerts: [],
1498
1419
  truncation: {
1499
1420
  connectors: 0, availablePeers: 0,
1500
- routes: 0, consentEdges: 0,
1421
+ routes: 0,
1501
1422
  activityEvents: 0,
1502
1423
  messages: Math.max(0, messageEvents.length + currentEvents.length - messages.length),
1503
1424
  alerts: 0,
@@ -1570,51 +1491,6 @@ export class GatewayStore {
1570
1491
  }
1571
1492
  return route;
1572
1493
  }
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
1494
  enqueueResolved(state, now, input, authority) {
1619
1495
  if (typeof input.body !== "string" ||
1620
1496
  input.body.length === 0 ||
@@ -1627,8 +1503,7 @@ export class GatewayStore {
1627
1503
  }
1628
1504
  const bytes = Buffer.byteLength(input.body, "utf8");
1629
1505
  if (bytes > this.config.limits.maxMessageBytes) {
1630
- this.recordRejection(state, authority, bytes, now, "MESSAGE_TOO_LARGE", input);
1631
- throw new CommitAndThrow(new BridgeError("MESSAGE_TOO_LARGE", "The message exceeds the configured byte bound."));
1506
+ this.rejectEnqueue(state, authority, bytes, now, input, "MESSAGE_TOO_LARGE", "The message exceeds the configured byte bound.");
1632
1507
  }
1633
1508
  if (Buffer.byteLength(input.dedupeKey, "utf8") > 512) {
1634
1509
  throw new BridgeError("INVALID_DEDUPE_KEY", "A bounded, non-empty deduplication key is required.");
@@ -1638,8 +1513,7 @@ export class GatewayStore {
1638
1513
  if (!isIsoTimestamp(deadlineAt) ||
1639
1514
  Date.parse(deadlineAt) <= now.getTime() ||
1640
1515
  Date.parse(deadlineAt) > now.getTime() + this.config.limits.messageDeadlineMs) {
1641
- this.recordRejection(state, authority, bytes, now, "INVALID_DEADLINE", input);
1642
- throw new CommitAndThrow(new BridgeError("INVALID_DEADLINE", "The message deadline must fall inside the configured delivery window."));
1516
+ this.rejectEnqueue(state, authority, bytes, now, input, "INVALID_DEADLINE", "The message deadline must fall inside the configured delivery window.");
1643
1517
  }
1644
1518
  const fingerprint = createHash("sha256")
1645
1519
  .update([
@@ -1659,8 +1533,7 @@ export class GatewayStore {
1659
1533
  };
1660
1534
  }
1661
1535
  if (!this.consumeRate(state, authority.sourceAlias, now)) {
1662
- this.recordRejection(state, authority, bytes, now, "GATEWAY_RATE_LIMITED", input);
1663
- throw new CommitAndThrow(new BridgeError("GATEWAY_RATE_LIMITED", "The source exceeded the bounded gateway rate window.", true));
1536
+ this.rejectEnqueue(state, authority, bytes, now, input, "GATEWAY_RATE_LIMITED", "The source exceeded the bounded gateway rate window.", true);
1664
1537
  }
1665
1538
  const active = state.messages.filter((message) => message.state.phase !== "terminal");
1666
1539
  const queuedSteers = input.steer === true
@@ -1680,8 +1553,7 @@ export class GatewayStore {
1680
1553
  this.config.limits.maxQueueMessagesPerRoute ||
1681
1554
  state.accounting.queuedBytes - (superseded?.bytes ?? 0) + bytes >
1682
1555
  this.config.limits.maxQueueBytes) {
1683
- this.recordRejection(state, authority, bytes, now, "GATEWAY_QUEUE_FULL", input);
1684
- throw new CommitAndThrow(new BridgeError("GATEWAY_QUEUE_FULL", "The bounded gateway queue is full.", true));
1556
+ this.rejectEnqueue(state, authority, bytes, now, input, "GATEWAY_QUEUE_FULL", "The bounded gateway queue is full.", true);
1685
1557
  }
1686
1558
  const messageId = `msg_${this.randomId()}`;
1687
1559
  if (!MESSAGE_ID_PATTERN.test(messageId)) {
@@ -1708,13 +1580,8 @@ export class GatewayStore {
1708
1580
  deadlineAt,
1709
1581
  bytes,
1710
1582
  body: input.body,
1711
- ...(authority.pair === true ? { pair: true } : {}),
1712
- ...(authority.transientTarget === true
1713
- ? { transientTarget: true }
1714
- : {}),
1715
1583
  ...(input.steer === true ? { steer: true } : {}),
1716
1584
  sourceRegistrationId: authority.sourceRegistrationId, targetRegistrationId: authority.targetRegistrationId,
1717
- consentEdge: authority.consentEdge,
1718
1585
  state: { phase: "queued", attemptCount: 0 },
1719
1586
  };
1720
1587
  state.messages.push(message);
@@ -1726,12 +1593,6 @@ export class GatewayStore {
1726
1593
  source.counters.accepted += 1;
1727
1594
  source.counters.bytesAccepted += bytes;
1728
1595
  }
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
1596
  state.dedupe.push({
1736
1597
  fingerprint,
1737
1598
  messageIdSuffix,
@@ -1740,7 +1601,6 @@ export class GatewayStore {
1740
1601
  : { conversationIdSuffix: input.conversationIdSuffix }),
1741
1602
  sourceAlias: authority.sourceAlias, targetAlias: authority.targetAlias,
1742
1603
  direction: authority.direction,
1743
- ...(authority.pair === true ? { pair: true } : {}),
1744
1604
  firstSeenAt: now.toISOString(),
1745
1605
  expiresAt: new Date(now.getTime() + this.config.limits.dedupeTtlMs).toISOString(),
1746
1606
  });
@@ -1752,22 +1612,11 @@ export class GatewayStore {
1752
1612
  messageId,
1753
1613
  messageIdSuffix,
1754
1614
  ...(deliveryToken === undefined ? {} : { deliveryToken }),
1755
- ...(authority.pair === true ? { pair: true } : {}),
1756
1615
  ...(supersededSettlement === undefined
1757
1616
  ? {}
1758
1617
  : { supersededSettlement }),
1759
1618
  };
1760
1619
  }
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
1620
  allocateDeliveryToken(state) {
1772
1621
  for (let attempt = 0; attempt < 8; attempt += 1) {
1773
1622
  const token = `dlv_${createHash("sha256")
@@ -1802,16 +1651,15 @@ export class GatewayStore {
1802
1651
  bucket.count += 1;
1803
1652
  return true;
1804
1653
  }
1654
+ rejectEnqueue(state, authority, bytes, now, input, code, message, recoverable = false) {
1655
+ this.recordRejection(state, authority, bytes, now, code, input);
1656
+ throw new CommitAndThrow(new BridgeError(code, message, recoverable));
1657
+ }
1805
1658
  recordRejection(state, authority, bytes, now, safeErrorCode, input) {
1806
1659
  state.accounting.rejected += 1;
1807
1660
  const source = state.routes.find((route) => route.binding.registrationId === authority.sourceRegistrationId);
1808
1661
  if (source !== undefined)
1809
1662
  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
1663
  const suffix = this.randomId().replaceAll("-", "").slice(-8).toLowerCase();
1816
1664
  if (!MESSAGE_SUFFIX_PATTERN.test(suffix))
1817
1665
  return;
@@ -1858,11 +1706,6 @@ export class GatewayStore {
1858
1706
  const target = state.routes.find((route) => route.binding.registrationId === message.targetRegistrationId);
1859
1707
  if (target !== undefined)
1860
1708
  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
1709
  return {
1867
1710
  messageId: message.messageId, state: outcome,
1868
1711
  ...(safeErrorCode === undefined ? {} : { safeErrorCode }),
@@ -1887,7 +1730,7 @@ export class GatewayStore {
1887
1730
  }
1888
1731
  return structuredClone(recorded);
1889
1732
  }
1890
- terminalizeRegistration(state, registrationId, now) {
1733
+ terminalizeRegistration(state, registrationId, now, cancelledCode = "ROUTE_UNREGISTERED") {
1891
1734
  const settlements = [];
1892
1735
  for (const message of state.messages) {
1893
1736
  if (message.state.phase === "terminal" ||
@@ -1901,7 +1744,7 @@ export class GatewayStore {
1901
1744
  ? message.state.lossOutcome
1902
1745
  : "cancelled";
1903
1746
  settlements.push(this.finishMessage(state, message, outcome, now, outcome === "cancelled"
1904
- ? "ROUTE_UNREGISTERED"
1747
+ ? cancelledCode
1905
1748
  : outcome === "ambiguous"
1906
1749
  ? "DISPATCH_OUTCOME_AMBIGUOUS"
1907
1750
  : "DELIVERY_UNCONFIRMED"));
@@ -1909,8 +1752,16 @@ export class GatewayStore {
1909
1752
  return settlements;
1910
1753
  }
1911
1754
  removeRegistrationMetadata(state, route) {
1755
+ if (this.ephemeralRegistrations.has(route.binding.registrationId)) {
1756
+ // An ephemeral registration takes every row it owns with it — in this
1757
+ // same mutate, and before its id leaves the set — so the closing
1758
+ // persist finds nothing of it to write: not the message it sent, not
1759
+ // the reply it received, not a journal row that names it.
1760
+ Object.assign(state, this.ephemeralRowsRemoved(state, new Set([route.binding.registrationId]), new Set([route.alias])));
1761
+ this.ephemeralRegistrations.delete(route.binding.registrationId);
1762
+ return;
1763
+ }
1912
1764
  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
1765
  state.dedupe = state.dedupe.filter((entry) => entry.sourceAlias !== route.alias && entry.targetAlias !== route.alias);
1915
1766
  state.rateBuckets = state.rateBuckets.filter((entry) => entry.sourceAlias !== route.alias);
1916
1767
  }
@@ -1921,13 +1772,6 @@ export class GatewayStore {
1921
1772
  state.routes.some((route) => route.alias === newAlias)) {
1922
1773
  throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The replacement alias is invalid or already registered.");
1923
1774
  }
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
1775
  for (const message of state.messages) {
1932
1776
  if (message.sourceRegistrationId === registrationId) {
1933
1777
  message.sourceAlias = newAlias;
@@ -1935,24 +1779,6 @@ export class GatewayStore {
1935
1779
  if (message.targetRegistrationId === registrationId) {
1936
1780
  message.targetAlias = newAlias;
1937
1781
  }
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
1782
  }
1957
1783
  for (const entry of state.dedupe) {
1958
1784
  if (entry.sourceAlias === oldAlias)
@@ -1996,10 +1822,6 @@ export class GatewayStore {
1996
1822
  : "CONTROLLER_RESTARTED");
1997
1823
  continue;
1998
1824
  }
1999
- if (message.transientTarget === true) {
2000
- this.finishMessage(state, message, "abandoned", now, "CONTROLLER_RESTARTED");
2001
- continue;
2002
- }
2003
1825
  if (message.state.phase === "reserved") {
2004
1826
  const attemptCount = message.state.attemptCount;
2005
1827
  message.state = { phase: "queued", attemptCount };
@@ -2054,7 +1876,6 @@ export class GatewayStore {
2054
1876
  if (state.routes.some((route) => !this.config.allowedHosts.includes(route.binding.hostId) ||
2055
1877
  !routeModeMatchesHost(route, this.config.hostId)) ||
2056
1878
  state.routes.length > this.config.limits.maxRoutes ||
2057
- state.consentEdges.length > this.config.limits.maxConsentEdges ||
2058
1879
  active.length > this.config.limits.maxQueueMessages ||
2059
1880
  active.filter((message) => message.state.phase === "reserved" ||
2060
1881
  message.state.phase === "armed" ||
@@ -2075,6 +1896,16 @@ export class GatewayStore {
2075
1896
  return this.mutex.run("gateway", async () => {
2076
1897
  const state = this.requireState();
2077
1898
  const before = structuredClone(state);
1899
+ // The ephemeral set is state too: a removal drops an id from it inside
1900
+ // the operation, and a rollback that restored the rows but not the id
1901
+ // would turn a memory-only registration into a durable one.
1902
+ const ephemeralBefore = new Set(this.ephemeralRegistrations);
1903
+ const rollback = () => {
1904
+ this.state = before;
1905
+ this.ephemeralRegistrations.clear();
1906
+ for (const id of ephemeralBefore)
1907
+ this.ephemeralRegistrations.add(id);
1908
+ };
2078
1909
  const now = this.now();
2079
1910
  this.prune(state, now);
2080
1911
  let value;
@@ -2091,14 +1922,13 @@ export class GatewayStore {
2091
1922
  await this.persist();
2092
1923
  }
2093
1924
  catch (persistError) {
2094
- if (!(persistError instanceof PostRenamePersistenceError)) {
2095
- this.state = before;
2096
- }
1925
+ if (!(persistError instanceof PostRenamePersistenceError))
1926
+ rollback();
2097
1927
  throw persistError;
2098
1928
  }
2099
1929
  throw error.error;
2100
1930
  }
2101
- this.state = before;
1931
+ rollback();
2102
1932
  throw error;
2103
1933
  }
2104
1934
  state.updatedAt = now.toISOString();
@@ -2110,7 +1940,7 @@ export class GatewayStore {
2110
1940
  }
2111
1941
  catch (error) {
2112
1942
  if (!(error instanceof PostRenamePersistenceError))
2113
- this.state = before;
1943
+ rollback();
2114
1944
  throw error;
2115
1945
  }
2116
1946
  return value;
@@ -2189,6 +2019,58 @@ export class GatewayStore {
2189
2019
  }
2190
2020
  return root;
2191
2021
  }
2022
+ /**
2023
+ * Boot-time litter sweep (emb-106): the static `gateway-dashboard*.html`
2024
+ * files 2.x published, any `.tmp` file its temp-file+rename publish could
2025
+ * have left behind mid-crash, and controller locks this store recovered as
2026
+ * stale more than seven days ago, are unlinked best-effort. Only a
2027
+ * regular file owned by this uid is removed (lstat, no symlink following);
2028
+ * nothing else in the state directory is touched, and a removal failure
2029
+ * never blocks startup. Runs only after the controller lock is held and
2030
+ * the state this boot will run on has passed both its schema check and
2031
+ * assertConfiguredBounds, so a refused boot leaves the directory exactly
2032
+ * as found.
2033
+ */
2034
+ async removeStateDirectoryLitter(root) {
2035
+ let entries;
2036
+ try {
2037
+ entries = await readdir(root);
2038
+ }
2039
+ catch {
2040
+ return;
2041
+ }
2042
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
2043
+ const now = this.now().getTime();
2044
+ for (const name of entries) {
2045
+ const dashboard = isStaleDashboardArtifact(name);
2046
+ const recoveredAt = dashboard ? undefined : staleLockRecoveredAt(name);
2047
+ if (!dashboard && recoveredAt === undefined)
2048
+ continue;
2049
+ const filePath = path.join(root, name);
2050
+ let info;
2051
+ try {
2052
+ info = await lstat(filePath);
2053
+ }
2054
+ catch {
2055
+ continue;
2056
+ }
2057
+ if (info.isSymbolicLink() || !info.isFile() || (uid !== undefined && info.uid !== uid))
2058
+ continue;
2059
+ // A recovered lock is evidence: keep it a week from the recovery that
2060
+ // created it, which is what its name records.
2061
+ if (recoveredAt !== undefined && now - recoveredAt <= STALE_LOCK_RETENTION_MS)
2062
+ continue;
2063
+ try {
2064
+ await unlink(filePath);
2065
+ process.stderr.write(dashboard
2066
+ ? `[embassy] removed stale ${name} left by an earlier Embassy release from the gateway state directory\n`
2067
+ : `[embassy] removed ${name}, a recovered gateway lock older than seven days\n`);
2068
+ }
2069
+ catch {
2070
+ // Best-effort cleanup; leave the file in place if it cannot be removed.
2071
+ }
2072
+ }
2073
+ }
2192
2074
  assertOwnedPrivate(uid, mode, kind) {
2193
2075
  if (typeof process.getuid === "function" && uid !== process.getuid()) {
2194
2076
  throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", `The gateway ${kind} is not owned by the current process user.`);
@@ -2238,6 +2120,23 @@ export class GatewayStore {
2238
2120
  await handle.close();
2239
2121
  }
2240
2122
  }
2123
+ /**
2124
+ * Move a lock nobody owns aside instead of deleting it: a crash stays
2125
+ * diagnosable for a week (the boot sweep ages these out), and the caller
2126
+ * is told, because a silent recovery of another process's lock would be
2127
+ * the one event an operator most needs to see.
2128
+ */
2129
+ async recoverStaleLock(lockPath, description) {
2130
+ const name = `${CONTROLLER_LOCK}.stale-${String(this.now().getTime())}-${randomUUID()}`;
2131
+ const moved = await rename(lockPath, path.join(this.rootDir, name)).then(() => true, (error) => {
2132
+ // Another start recovered it first; retry the create either way.
2133
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
2134
+ return false;
2135
+ throw error;
2136
+ });
2137
+ if (moved)
2138
+ process.stderr.write(`[embassy] recovered a stale gateway lock (${description}) → ${name}\n`);
2139
+ }
2241
2140
  async acquireLock() {
2242
2141
  const lockPath = path.join(this.rootDir, CONTROLLER_LOCK);
2243
2142
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -2250,7 +2149,7 @@ export class GatewayStore {
2250
2149
  try {
2251
2150
  await handle.writeFile(`${JSON.stringify({
2252
2151
  schemaVersion: 1, pid: process.pid,
2253
- hostname: os.hostname(),
2152
+ hostname: this.hostname(),
2254
2153
  token,
2255
2154
  })}\n`, "utf8");
2256
2155
  await handle.sync();
@@ -2271,35 +2170,54 @@ export class GatewayStore {
2271
2170
  throw error;
2272
2171
  }
2273
2172
  }
2274
- let owner;
2173
+ let body;
2275
2174
  try {
2276
- owner = JSON.parse(await this.readPrivateFile(lockPath, MAX_LOCK_FILE_BYTES));
2175
+ body = await this.readPrivateFile(lockPath, MAX_LOCK_FILE_BYTES);
2277
2176
  }
2278
2177
  catch {
2279
- throw new BridgeError("GATEWAY_STATE_LOCK_UNVERIFIED", "The gateway state lock exists but cannot be safely verified.");
2178
+ throw lockUnverified();
2280
2179
  }
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);
2180
+ // A crash between the O_EXCL create and the write leaves a zero-byte
2181
+ // lock. It records no owner at all, so it can only be the corpse of a
2182
+ // start that never began: recover it rather than wedging on it.
2183
+ if (body.trim().length === 0) {
2184
+ await this.recoverStaleLock(lockPath, "empty, left by an interrupted start");
2185
+ continue;
2283
2186
  }
2284
- let alive = true;
2187
+ let owner;
2285
2188
  try {
2286
- process.kill(owner.pid, 0);
2287
- }
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);
2189
+ owner = JSON.parse(body);
2295
2190
  }
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
- });
2191
+ catch {
2192
+ throw lockUnverified();
2193
+ }
2194
+ // Parsed, but naming no process: there is nothing to probe and nothing
2195
+ // to claim about it, so this is unverified like any other unreadable
2196
+ // record — never "another broker may own this".
2197
+ if (!isPositiveInteger(owner.pid))
2198
+ throw lockUnverified();
2199
+ // Recovery is decided by process liveness alone; the recorded hostname
2200
+ // is informational. A machine that renames itself — a network-triggered
2201
+ // rename, a restore onto new hardware — must not wedge its own state
2202
+ // directory forever, so a dead pid is stale whatever name wrote it.
2203
+ const recorded = boundedHostname(owner.hostname);
2204
+ const pid = String(owner.pid);
2205
+ // A name this cannot represent is not reported as a name: the client
2206
+ // hint drops the whole clause rather than printing a placeholder.
2207
+ const detail = recorded === undefined ? { pid } : { host: recorded, pid };
2208
+ if (this.isProcessAlive(owner.pid)) {
2209
+ // The pid is alive, but nothing here can tell whether it is a broker:
2210
+ // pid numbers are reused, and a renamed machine records its old name.
2211
+ // Refuse, and hand the client the bounded facts its hint needs.
2212
+ const mine = boundedHostname(this.hostname());
2213
+ const message = recorded !== undefined && mine !== undefined && recorded === mine
2214
+ ? `A live process (pid ${pid}) recorded on this machine owns this gateway state directory.`
2215
+ : recorded === undefined
2216
+ ? `The gateway state lock records pid ${pid} under a machine name that cannot be verified, and a process with that pid is alive here.`
2217
+ : `The gateway state lock records host ${recorded} and pid ${pid}, and a process with that pid is alive here.`;
2218
+ throw new BridgeError("GATEWAY_STATE_IN_USE", message, true, detail);
2219
+ }
2220
+ await this.recoverStaleLock(lockPath, recorded === undefined ? `pid ${pid}, machine name unverifiable` : `recorded host ${recorded}, pid ${pid}`);
2303
2221
  }
2304
2222
  throw new BridgeError("GATEWAY_STATE_IN_USE", "Could not acquire exclusive gateway controller ownership.", true);
2305
2223
  }
@@ -2339,11 +2257,16 @@ export class GatewayStore {
2339
2257
  catch {
2340
2258
  throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state is not valid JSON.");
2341
2259
  }
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.");
2260
+ if (isObject(parsed) && Object.hasOwn(parsed, "schemaVersion") && parsed.schemaVersion !== 5) {
2261
+ 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
2262
  }
2345
- if (!isGatewayPersistedStateV4(parsed)) {
2346
- throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict v4 schema validation.");
2263
+ if (!isGatewayPersistedStateV5(parsed, this.config.hostId)) {
2264
+ // A document that is a valid v5 state on its own terms but not under this
2265
+ // broker's host identity is not corrupt: it belongs to another host, and
2266
+ // the operator deserves that sentence rather than "failed validation".
2267
+ throw isGatewayPersistedStateV5(parsed)
2268
+ ? new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway state exceeds its configured bounds or host allowlist.")
2269
+ : new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict v5 schema validation.");
2347
2270
  }
2348
2271
  this.assertConfiguredBounds(parsed);
2349
2272
  return parsed;
@@ -2351,7 +2274,7 @@ export class GatewayStore {
2351
2274
  async persist(force = false) {
2352
2275
  if (this.persistenceDeferred && !force)
2353
2276
  return;
2354
- const state = this.requireState();
2277
+ const state = this.persistedProjection(this.requireState());
2355
2278
  const temporary = path.join(this.rootDir, `.gateway-state-${randomUUID()}.tmp`);
2356
2279
  const body = `${JSON.stringify(state, null, 2)}\n`;
2357
2280
  if (Buffer.byteLength(body, "utf8") > GATEWAY_MAX_STATE_FILE_BYTES) {
@@ -2410,10 +2333,13 @@ export class GatewayStore {
2410
2333
  }
2411
2334
  const installedCurrent = installed?.commit.id === state.commit.id &&
2412
2335
  installed.commit.sequence === state.commit.sequence;
2413
- if (installedCurrent && installed !== undefined) {
2414
- this.state = installed;
2336
+ // The rename crossed the commit point and the file holds this very
2337
+ // commit: the write succeeded. The live state stays as it is — the
2338
+ // file is its projection, minus the ephemeral rows that must never be
2339
+ // read back in, so installing the readback here would silently turn
2340
+ // every live ephemeral registration into nothing.
2341
+ if (installedCurrent)
2415
2342
  return;
2416
- }
2417
2343
  const installedPrior = !readbackFailed &&
2418
2344
  (prior === undefined
2419
2345
  ? installed === undefined