agent-embassy 1.6.0 → 1.7.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 (97) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +9 -9
  3. package/README.zh-CN.md +9 -9
  4. package/SECURITY.md +34 -77
  5. package/dist/src/gateway/acp-client.d.ts +117 -0
  6. package/dist/src/gateway/acp-client.js +382 -0
  7. package/dist/src/gateway/acp-client.js.map +1 -0
  8. package/dist/src/gateway/acp-provider.d.ts +65 -0
  9. package/dist/src/gateway/acp-provider.js +163 -0
  10. package/dist/src/gateway/acp-provider.js.map +1 -0
  11. package/dist/src/gateway/claude-helper-protocol.d.ts +3 -1
  12. package/dist/src/gateway/claude-helper-protocol.js +15 -5
  13. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  14. package/dist/src/gateway/claude-helper-supervisor.d.ts +3 -1
  15. package/dist/src/gateway/claude-helper-supervisor.js +16 -2
  16. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  17. package/dist/src/gateway/claude-helper.js +4 -3
  18. package/dist/src/gateway/claude-helper.js.map +1 -1
  19. package/dist/src/gateway/claude-peer.d.ts +1 -1
  20. package/dist/src/gateway/claude-peer.js +11 -13
  21. package/dist/src/gateway/claude-peer.js.map +1 -1
  22. package/dist/src/gateway/claude-runtime.d.ts +0 -20
  23. package/dist/src/gateway/claude-runtime.js +6 -127
  24. package/dist/src/gateway/claude-runtime.js.map +1 -1
  25. package/dist/src/gateway/cli-copy.en.d.ts +1 -1
  26. package/dist/src/gateway/cli-copy.en.js +2 -2
  27. package/dist/src/gateway/cli-copy.zh-CN.d.ts +1 -1
  28. package/dist/src/gateway/cli-copy.zh-CN.js +2 -2
  29. package/dist/src/gateway/cli.d.ts +1 -1
  30. package/dist/src/gateway/cli.js +26 -5
  31. package/dist/src/gateway/cli.js.map +1 -1
  32. package/dist/src/gateway/codex-app-server.d.ts +5 -74
  33. package/dist/src/gateway/codex-app-server.js +9 -597
  34. package/dist/src/gateway/codex-app-server.js.map +1 -1
  35. package/dist/src/gateway/codex-local-transport.d.ts +8 -26
  36. package/dist/src/gateway/codex-local-transport.js +26 -76
  37. package/dist/src/gateway/codex-local-transport.js.map +1 -1
  38. package/dist/src/gateway/compatibility.d.ts +6 -66
  39. package/dist/src/gateway/compatibility.js +14 -296
  40. package/dist/src/gateway/compatibility.js.map +1 -1
  41. package/dist/src/gateway/config.d.ts +9 -0
  42. package/dist/src/gateway/config.js +6 -2
  43. package/dist/src/gateway/config.js.map +1 -1
  44. package/dist/src/gateway/control.d.ts +16 -1
  45. package/dist/src/gateway/control.js +89 -57
  46. package/dist/src/gateway/control.js.map +1 -1
  47. package/dist/src/gateway/dashboard-copy.d.ts +1 -1
  48. package/dist/src/gateway/dashboard-copy.en.d.ts +14 -44
  49. package/dist/src/gateway/dashboard-copy.en.js +37 -67
  50. package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
  51. package/dist/src/gateway/dashboard-copy.js +14 -44
  52. package/dist/src/gateway/dashboard-copy.js.map +1 -1
  53. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +14 -44
  54. package/dist/src/gateway/dashboard-copy.zh-CN.js +37 -67
  55. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
  56. package/dist/src/gateway/dashboard-model.d.ts +38 -76
  57. package/dist/src/gateway/dashboard-model.js +158 -256
  58. package/dist/src/gateway/dashboard-model.js.map +1 -1
  59. package/dist/src/gateway/dashboard.d.ts +0 -3
  60. package/dist/src/gateway/dashboard.js +75 -76
  61. package/dist/src/gateway/dashboard.js.map +1 -1
  62. package/dist/src/gateway/deepseek-detect.d.ts +7 -6
  63. package/dist/src/gateway/deepseek-detect.js +31 -115
  64. package/dist/src/gateway/deepseek-detect.js.map +1 -1
  65. package/dist/src/gateway/live-dashboard-app/app.js +166 -621
  66. package/dist/src/gateway/live-dashboard-command.js +2 -4
  67. package/dist/src/gateway/live-dashboard-command.js.map +1 -1
  68. package/dist/src/gateway/live-dashboard-http.d.ts +1 -2
  69. package/dist/src/gateway/live-dashboard-http.js +14 -15
  70. package/dist/src/gateway/live-dashboard-http.js.map +1 -1
  71. package/dist/src/gateway/progress-watch-machine.d.ts +1 -4
  72. package/dist/src/gateway/progress-watch-machine.js.map +1 -1
  73. package/dist/src/gateway/provenance-envelope.d.ts +3 -2
  74. package/dist/src/gateway/provenance-envelope.js +44 -11
  75. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  76. package/dist/src/gateway/providers.d.ts +12 -84
  77. package/dist/src/gateway/providers.js +69 -806
  78. package/dist/src/gateway/providers.js.map +1 -1
  79. package/dist/src/gateway/server.d.ts +8 -9
  80. package/dist/src/gateway/server.js +51 -99
  81. package/dist/src/gateway/server.js.map +1 -1
  82. package/dist/src/gateway/service.d.ts +22 -49
  83. package/dist/src/gateway/service.js +196 -432
  84. package/dist/src/gateway/service.js.map +1 -1
  85. package/dist/src/gateway/store.d.ts +20 -29
  86. package/dist/src/gateway/store.js +281 -827
  87. package/dist/src/gateway/store.js.map +1 -1
  88. package/dist/src/gateway/types.d.ts +55 -55
  89. package/dist/src/gateway/types.js +49 -91
  90. package/dist/src/gateway/types.js.map +1 -1
  91. package/docs/CONFIGURATION.md +11 -10
  92. package/docs/CONFIGURATION.zh-CN.md +10 -9
  93. package/docs/DASHBOARD.md +4 -38
  94. package/docs/DASHBOARD.zh-CN.md +4 -2
  95. package/docs/GATEWAY-ARCHITECTURE.md +61 -119
  96. package/package.json +1 -1
  97. package/skills/embassy-peer/SKILL.md +6 -6
@@ -6,9 +6,8 @@ import path from "node:path";
6
6
  import { BridgeError } from "../errors.js";
7
7
  import { KeyedMutex } from "../mutex.js";
8
8
  import { isCodexRegistrationGeneration } from "./codex-registration-generation.js";
9
- import { compatibilityCacheKey, isCompatibilityAttestation, isPersistedCompatibilityAttestation, } from "./compatibility.js";
10
9
  import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY, PROGRESS_WATCH_MAX_IDLE_MS, PROGRESS_WATCH_MIN_IDLE_MS, commitProgressWatchNudge, createProgressWatch, deferProgressWatchNudge, inspectProgressWatchDue, progressWatchJournalKinds, recordProgressWatchActivity, } from "./progress-watch-machine.js";
11
- import { CODEX_ENDPOINT_REFRESH_JOURNAL_CAPACITY, CODEX_ORPHAN_REMOVAL_JOURNAL_CAPACITY, compatibilityStates, connectorHealthStates, deliveryStates, gatewayPublicSnapshotLimits, gatewayProviders, messageDirections, projectGatewayPublicSnapshot, routeRegistrationModes, routeStates, } from "./types.js";
10
+ import { CODEX_ENDPOINT_REFRESH_JOURNAL_CAPACITY, CODEX_ORPHAN_REMOVAL_JOURNAL_CAPACITY, connectorHealthStates, directionId, deliveryStates, gatewayPublicSnapshotLimits, gatewayProviders, gatewayRegistrationIngressPrefixes, messageDirections, parseDirection, projectGatewayPublicSnapshot, routeRegistrationModes, routeStates, } from "./types.js";
12
11
  const STATE_MARKER = ".agent-embassy-state";
13
12
  const STATE_MARKER_CONTENT = "agent-embassy-state-v1\n";
14
13
  const STATE_FILE = "gateway-state.json";
@@ -17,7 +16,6 @@ const MAX_MARKER_FILE_BYTES = 128;
17
16
  const MAX_LOCK_FILE_BYTES = 4 * 1024;
18
17
  export const GATEWAY_MAX_STATE_FILE_BYTES = 8 * 1024 * 1024;
19
18
  const DEFAULT_RETAINED_BODY_BYTES = 1 * 1024 * 1024;
20
- const COMPATIBILITY_ATTESTATION_CAPACITY = 16;
21
19
  const ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
22
20
  const HOST_PATTERN = /^[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
23
21
  const PRIVATE_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
@@ -39,7 +37,6 @@ class PostRenamePersistenceError extends BridgeError {
39
37
  }
40
38
  const PROVIDERS = new Set(gatewayProviders);
41
39
  const CONNECTOR_HEALTH = new Set(connectorHealthStates);
42
- const COMPATIBILITY = new Set(compatibilityStates);
43
40
  const ROUTE_STATES = new Set(routeStates);
44
41
  const REGISTRATION_MODES = new Set(routeRegistrationModes);
45
42
  const DIRECTIONS = new Set(messageDirections);
@@ -221,7 +218,6 @@ function isRouteRecord(value) {
221
218
  "registrationMode",
222
219
  "enabled",
223
220
  "state",
224
- "compatibility",
225
221
  "busyPolicy",
226
222
  "registeredAt",
227
223
  "updatedAt",
@@ -238,8 +234,6 @@ function isRouteRecord(value) {
238
234
  typeof value.enabled === "boolean" &&
239
235
  typeof value.state === "string" &&
240
236
  ROUTE_STATES.has(value.state) &&
241
- typeof value.compatibility === "string" &&
242
- COMPATIBILITY.has(value.compatibility) &&
243
237
  value.busyPolicy === "queue" &&
244
238
  isIsoTimestamp(value.registeredAt) &&
245
239
  isIsoTimestamp(value.updatedAt) &&
@@ -248,23 +242,19 @@ function isRouteRecord(value) {
248
242
  isRouteCounters(value.counters) &&
249
243
  (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
250
244
  }
251
- function isPairRecord(value) {
245
+ function isConsentEndpoint(value) {
252
246
  return (isObject(value) &&
253
- hasOnlyKeys(value, [
254
- "claudeAlias",
255
- "codexAlias",
256
- "claudeOwnerLease",
257
- "codexOwnerLease",
258
- "createdAt",
259
- "updatedAt",
260
- "counters",
261
- ]) &&
262
- typeof value.claudeAlias === "string" &&
263
- ALIAS_PATTERN.test(value.claudeAlias) &&
264
- typeof value.codexAlias === "string" &&
265
- ALIAS_PATTERN.test(value.codexAlias) &&
266
- isPrivateToken(value.claudeOwnerLease) &&
267
- isPrivateToken(value.codexOwnerLease) &&
247
+ hasOnlyKeys(value, ["alias", "provider", "ownerLease"]) &&
248
+ typeof value.alias === "string" && ALIAS_PATTERN.test(value.alias) &&
249
+ typeof value.provider === "string" && PROVIDERS.has(value.provider) &&
250
+ isPrivateToken(value.ownerLease));
251
+ }
252
+ function isConsentEdgeRecord(value) {
253
+ return (isObject(value) &&
254
+ hasOnlyKeys(value, ["endpoints", "createdAt", "updatedAt", "counters"]) &&
255
+ Array.isArray(value.endpoints) &&
256
+ value.endpoints.length === 2 &&
257
+ value.endpoints.every(isConsentEndpoint) &&
268
258
  isIsoTimestamp(value.createdAt) &&
269
259
  isIsoTimestamp(value.updatedAt) &&
270
260
  isRouteCounters(value.counters));
@@ -277,15 +267,16 @@ function progressWatchPairKey(watch) {
277
267
  .sort()
278
268
  .join("\0");
279
269
  }
280
- function progressWatchMatchesPair(watch, pair) {
281
- return ((watch.ownerAlias === pair.claudeAlias &&
282
- watch.ownerLease === pair.claudeOwnerLease &&
283
- watch.workerAlias === pair.codexAlias &&
284
- watch.workerLease === pair.codexOwnerLease) ||
285
- (watch.ownerAlias === pair.codexAlias &&
286
- watch.ownerLease === pair.codexOwnerLease &&
287
- watch.workerAlias === pair.claudeAlias &&
288
- watch.workerLease === pair.claudeOwnerLease));
270
+ function progressWatchMatchesConsentEdge(watch, pair) {
271
+ const [left, right] = pair.endpoints;
272
+ return ((watch.ownerAlias === left.alias &&
273
+ watch.ownerLease === left.ownerLease &&
274
+ watch.workerAlias === right.alias &&
275
+ watch.workerLease === right.ownerLease) ||
276
+ (watch.ownerAlias === right.alias &&
277
+ watch.ownerLease === right.ownerLease &&
278
+ watch.workerAlias === left.alias &&
279
+ watch.workerLease === left.ownerLease));
289
280
  }
290
281
  function progressWatchJournalEvent(watch, timestamp, transition) {
291
282
  return {
@@ -459,11 +450,10 @@ function isProgressWatchJournalEvent(value) {
459
450
  return value.actor === "gateway" || value.actor === "operator";
460
451
  }
461
452
  if (value.reason === "idle_timeout" ||
462
- value.reason === "tracking_disabled" ||
463
- value.reason === "legacy_upgrade") {
453
+ value.reason === "tracking_disabled") {
464
454
  return value.actor === "gateway";
465
455
  }
466
- return value.reason === "legacy_done" && value.actor === "unknown";
456
+ return false;
467
457
  }
468
458
  function isConnectorRecord(value) {
469
459
  if (!isObject(value) ||
@@ -472,7 +462,6 @@ function isConnectorRecord(value) {
472
462
  "hostId",
473
463
  "endpointGeneration",
474
464
  "health",
475
- "compatibility",
476
465
  "protocol",
477
466
  "protocolVersion",
478
467
  "updatedAt",
@@ -486,8 +475,6 @@ function isConnectorRecord(value) {
486
475
  }) &&
487
476
  typeof value.health === "string" &&
488
477
  CONNECTOR_HEALTH.has(value.health) &&
489
- typeof value.compatibility === "string" &&
490
- COMPATIBILITY.has(value.compatibility) &&
491
478
  typeof value.protocol === "string" &&
492
479
  PROTOCOL_PATTERN.test(value.protocol) &&
493
480
  typeof value.protocolVersion === "string" &&
@@ -628,444 +615,6 @@ function isAccounting(value) {
628
615
  }
629
616
  return Object.values(value).every(isNonNegativeInteger);
630
617
  }
631
- const PRE_UNCONFIRMED_ROUTE_COUNTER_KEYS = [
632
- "accepted",
633
- "delivered",
634
- "failed",
635
- "ambiguous",
636
- "expired",
637
- "cancelled",
638
- "abandoned",
639
- "rejected",
640
- "bytesAccepted",
641
- ];
642
- const PRE_UNCONFIRMED_ACCOUNTING_KEYS = [
643
- "accepted",
644
- "duplicates",
645
- "delivered",
646
- "failed",
647
- "ambiguous",
648
- "expired",
649
- "cancelled",
650
- "abandoned",
651
- "rejected",
652
- "bytesAccepted",
653
- "queuedBytes",
654
- ];
655
- /**
656
- * One narrow migration for unpublished dogfood state created before the
657
- * `unconfirmed` terminal outcome existed. Atomic state replacement means a
658
- * valid old file has either every old counter shape or none of them; mixed or
659
- * otherwise unfamiliar shapes continue to fail strict validation.
660
- */
661
- function migratePreUnconfirmedCounters(value) {
662
- if (!isObject(value) ||
663
- value.schemaVersion !== 1 ||
664
- !isObject(value.accounting) ||
665
- !hasOnlyKeys(value.accounting, PRE_UNCONFIRMED_ACCOUNTING_KEYS) ||
666
- Object.hasOwn(value.accounting, "unconfirmed") ||
667
- !Array.isArray(value.routes) ||
668
- !value.routes.every((route) => isObject(route) &&
669
- isObject(route.counters) &&
670
- hasOnlyKeys(route.counters, PRE_UNCONFIRMED_ROUTE_COUNTER_KEYS) &&
671
- !Object.hasOwn(route.counters, "unconfirmed"))) {
672
- return value;
673
- }
674
- return {
675
- ...value,
676
- accounting: { ...value.accounting, unconfirmed: 0 },
677
- routes: value.routes.map((route) => {
678
- const record = route;
679
- return {
680
- ...record,
681
- counters: {
682
- ...record.counters,
683
- unconfirmed: 0,
684
- },
685
- };
686
- }),
687
- };
688
- }
689
- const PRE_SUCCESSION_JOURNAL_STATE_KEYS = [
690
- "schemaVersion",
691
- "createdAt",
692
- "updatedAt",
693
- "eventSequence",
694
- "routes",
695
- "connectors",
696
- "queue",
697
- "inFlight",
698
- "events",
699
- "dedupe",
700
- "rateBuckets",
701
- "accounting",
702
- ];
703
- /** Add the nullable internal journal to exact legacy v1 state only. */
704
- function migratePreSuccessionJournal(value) {
705
- if (!isObject(value) ||
706
- value.schemaVersion !== 1 ||
707
- !hasOnlyKeys(value, PRE_SUCCESSION_JOURNAL_STATE_KEYS)) {
708
- return value;
709
- }
710
- return { ...value, codexSuccession: null };
711
- }
712
- const PRE_PAIR_GRAPH_STATE_KEYS = [
713
- ...PRE_SUCCESSION_JOURNAL_STATE_KEYS,
714
- "codexSuccession",
715
- ];
716
- /**
717
- * Preserve the old explicit singleton-selection consent when adding the pair
718
- * graph. This migration accepts only the exact prior v1 top-level shape; final
719
- * state validation still proves every route, lease, host, and edge.
720
- */
721
- function migratePrePairGraph(value) {
722
- if (!isObject(value) ||
723
- value.schemaVersion !== 1 ||
724
- !hasOnlyKeys(value, PRE_PAIR_GRAPH_STATE_KEYS) ||
725
- !Array.isArray(value.routes) ||
726
- !isIsoTimestamp(value.updatedAt)) {
727
- return value;
728
- }
729
- const routes = value.routes.filter(isObject);
730
- const claudeRoutes = routes.filter((route) => route.registrationMode === "selected_live_peer" &&
731
- isObject(route.binding) &&
732
- route.binding.provider === "claude");
733
- const codexRoutes = routes.filter((route) => route.registrationMode === "explicit_opt_in" &&
734
- isObject(route.binding) &&
735
- route.binding.provider === "codex");
736
- const pairs = [];
737
- for (const claude of claudeRoutes) {
738
- for (const codex of codexRoutes) {
739
- const claudeBinding = claude.binding;
740
- const codexBinding = codex.binding;
741
- if (claudeBinding.hostId !== codexBinding.hostId ||
742
- typeof claude.alias !== "string" ||
743
- typeof codex.alias !== "string" ||
744
- typeof claudeBinding.ownerLease !== "string" ||
745
- typeof codexBinding.ownerLease !== "string") {
746
- continue;
747
- }
748
- pairs.push({
749
- claudeAlias: claude.alias,
750
- codexAlias: codex.alias,
751
- claudeOwnerLease: claudeBinding.ownerLease,
752
- codexOwnerLease: codexBinding.ownerLease,
753
- createdAt: value.updatedAt,
754
- updatedAt: value.updatedAt,
755
- counters: emptyCounters(),
756
- });
757
- }
758
- }
759
- if (pairs.length > 256)
760
- return value;
761
- return { ...value, pairs };
762
- }
763
- const PRE_PROGRESS_WATCH_STATE_KEYS = [
764
- ...PRE_PAIR_GRAPH_STATE_KEYS,
765
- "pairs",
766
- ];
767
- /** Add empty opt-in watch state to the exact preceding v1 schema only. */
768
- function migratePreProgressWatches(value) {
769
- if (!isObject(value) ||
770
- value.schemaVersion !== 1 ||
771
- !hasOnlyKeys(value, PRE_PROGRESS_WATCH_STATE_KEYS)) {
772
- return value;
773
- }
774
- return {
775
- ...value,
776
- watchSequence: 0,
777
- progressWatches: [],
778
- progressWatchEvents: [],
779
- };
780
- }
781
- const PRE_COMPATIBILITY_ATTESTATION_STATE_KEYS = [
782
- ...PRE_PROGRESS_WATCH_STATE_KEYS,
783
- "watchSequence",
784
- "progressWatches",
785
- "progressWatchEvents",
786
- ];
787
- /** Add an empty compatibility cache to the exact preceding v1 schema only. */
788
- function migratePreCompatibilityAttestations(value) {
789
- if (!isObject(value) ||
790
- value.schemaVersion !== 1 ||
791
- !hasOnlyKeys(value, PRE_COMPATIBILITY_ATTESTATION_STATE_KEYS)) {
792
- return value;
793
- }
794
- return { ...value, compatibilityAttestations: [] };
795
- }
796
- const PRE_CODEX_ENDPOINT_REFRESH_STATE_KEYS = [
797
- ...PRE_COMPATIBILITY_ATTESTATION_STATE_KEYS,
798
- "compatibilityAttestations",
799
- ];
800
- /** Add an empty bounded private endpoint-refresh journal to exact legacy v1. */
801
- function migratePreCodexEndpointRefreshJournal(value) {
802
- if (!isObject(value) ||
803
- value.schemaVersion !== 1 ||
804
- !hasOnlyKeys(value, PRE_CODEX_ENDPOINT_REFRESH_STATE_KEYS)) {
805
- return value;
806
- }
807
- return {
808
- ...value,
809
- codexEndpointRefreshSequence: 0,
810
- codexEndpointRefreshEvents: [],
811
- };
812
- }
813
- const PRE_CODEX_ORPHAN_REMOVAL_STATE_KEYS = [
814
- ...PRE_CODEX_ENDPOINT_REFRESH_STATE_KEYS,
815
- "codexEndpointRefreshSequence",
816
- "codexEndpointRefreshEvents",
817
- ];
818
- /** Add an empty private orphan-removal journal to exact preceding v1 state. */
819
- function migratePreCodexOrphanRemovalJournal(value) {
820
- if (!isObject(value) ||
821
- value.schemaVersion !== 1 ||
822
- !hasOnlyKeys(value, PRE_CODEX_ORPHAN_REMOVAL_STATE_KEYS)) {
823
- return value;
824
- }
825
- return {
826
- ...value,
827
- codexOrphanRemovalSequence: 0,
828
- codexOrphanRemovalEvents: [],
829
- };
830
- }
831
- /**
832
- * v1.2.0 persisted hop counters on queue, in-flight, and event rows. Hop
833
- * policy no longer exists; accept only the old bounded shape and discard the
834
- * obsolete field before strict current-schema validation and persistence.
835
- */
836
- function migrateLegacyHopCounts(value) {
837
- if (!isObject(value) ||
838
- value.schemaVersion !== 1 ||
839
- !Array.isArray(value.queue) ||
840
- !Array.isArray(value.inFlight) ||
841
- !Array.isArray(value.events)) {
842
- return value;
843
- }
844
- const strip = (entry) => {
845
- if (!isObject(entry) || !Object.hasOwn(entry, "hopCount"))
846
- return entry;
847
- if (!isNonNegativeInteger(entry.hopCount) || entry.hopCount > 64) {
848
- return entry;
849
- }
850
- const { hopCount: _legacyHopCount, ...current } = entry;
851
- return current;
852
- };
853
- return {
854
- ...value,
855
- queue: value.queue.map(strip),
856
- inFlight: value.inFlight.map(strip),
857
- events: value.events.map(strip),
858
- };
859
- }
860
- function isLegacyCompatibilityCertification(value) {
861
- if (!isObject(value))
862
- return false;
863
- const required = ["depth", "outcome", "certifiedAt"];
864
- if (!hasOnlyKeys(value, required, ["safeErrorCode"]))
865
- return false;
866
- if ((value.depth !== "wire" &&
867
- value.depth !== "thread_ops" &&
868
- value.depth !== "turn") ||
869
- (value.outcome !== "pass" && value.outcome !== "fail") ||
870
- !isIsoTimestamp(value.certifiedAt) ||
871
- new Date(Date.parse(value.certifiedAt)).toISOString() !==
872
- value.certifiedAt ||
873
- (value.safeErrorCode !== undefined && !isSafeCode(value.safeErrorCode))) {
874
- return false;
875
- }
876
- return (value.outcome === "pass") === (value.safeErrorCode === undefined);
877
- }
878
- /**
879
- * v1.1-v1.2 compatibility rows could contain manual live-certification
880
- * evidence. Discard only an exact old certification, retain the automatic
881
- * bounded probes as schema-attested evidence, and leave malformed rows
882
- * untouched for strict state validation to reject.
883
- */
884
- function migrateLegacyCompatibilityCertifications(value) {
885
- if (!isObject(value) ||
886
- value.schemaVersion !== 1 ||
887
- !Array.isArray(value.compatibilityAttestations)) {
888
- return value;
889
- }
890
- let changed = false;
891
- const compatibilityAttestations = value.compatibilityAttestations.map((entry) => {
892
- let candidate = entry;
893
- if (isObject(entry) &&
894
- Object.hasOwn(entry, "certification") &&
895
- isLegacyCompatibilityCertification(entry.certification)) {
896
- const { certification: _legacyCertification, ...current } = entry;
897
- candidate =
898
- current.tier === "certified"
899
- ? { ...current, tier: "schema_attested" }
900
- : current;
901
- }
902
- if (!isPersistedCompatibilityAttestation(candidate))
903
- return entry;
904
- if (candidate !== entry)
905
- changed = true;
906
- return candidate;
907
- });
908
- return changed ? { ...value, compatibilityAttestations } : value;
909
- }
910
- function legacyProgressWatchGraphIsValid(value, watches) {
911
- if (!isObject(value) ||
912
- !Array.isArray(value.routes) ||
913
- !value.routes.every(isRouteRecord) ||
914
- !Array.isArray(value.pairs) ||
915
- value.pairs.length > 256 ||
916
- !value.pairs.every(isPairRecord) ||
917
- new Set(watches.map((watch) => watch.conversationId)).size !==
918
- watches.length ||
919
- new Set(watches.map(progressWatchPairKey)).size !== watches.length) {
920
- return false;
921
- }
922
- const routes = value.routes;
923
- const pairs = value.pairs;
924
- for (const watch of watches) {
925
- const owner = routes.find((route) => route.alias === watch.ownerAlias &&
926
- route.binding.ownerLease === watch.ownerLease);
927
- const worker = routes.find((route) => route.alias === watch.workerAlias &&
928
- route.binding.ownerLease === watch.workerLease);
929
- if (owner === undefined ||
930
- worker === undefined ||
931
- owner.binding.provider === worker.binding.provider ||
932
- owner.binding.hostId !== worker.binding.hostId) {
933
- return false;
934
- }
935
- const claude = owner.binding.provider === "claude" ? owner : worker;
936
- const codex = owner.binding.provider === "codex" ? owner : worker;
937
- if (!pairs.some((pair) => pair.claudeAlias === claude.alias &&
938
- pair.codexAlias === codex.alias &&
939
- pair.claudeOwnerLease === claude.binding.ownerLease &&
940
- pair.codexOwnerLease === codex.binding.ownerLease)) {
941
- return false;
942
- }
943
- }
944
- return true;
945
- }
946
- function mapLegacyProgressWatchEvent(event) {
947
- const base = {
948
- timestamp: event.timestamp,
949
- conversationId: event.conversationId,
950
- ownerAlias: event.ownerAlias,
951
- workerAlias: event.workerAlias,
952
- };
953
- switch (event.kind) {
954
- case "opened":
955
- return { ...base, kind: "opened", actor: "owner" };
956
- case "replaced":
957
- // v1.4 used this kind for both owner replacement and an automatic
958
- // duplicate migration. Its actor cannot be reconstructed honestly.
959
- return { ...base, kind: "replaced", actor: "unknown" };
960
- case "worker_reported_complete":
961
- return { ...base, kind: "settled", actor: "worker", reason: "done" };
962
- case "done":
963
- return {
964
- ...base,
965
- kind: "settled",
966
- actor: "unknown",
967
- reason: "legacy_done",
968
- };
969
- case "unresponsive":
970
- return {
971
- ...base,
972
- kind: "settled",
973
- actor: "gateway",
974
- reason: "idle_timeout",
975
- };
976
- case "pair_removed":
977
- return {
978
- ...base,
979
- kind: "settled",
980
- actor: "operator",
981
- reason: "pair_removed",
982
- };
983
- case "endpoint_retired":
984
- return {
985
- ...base,
986
- kind: "settled",
987
- actor: "gateway",
988
- reason: "endpoint_retired",
989
- };
990
- case "disabled":
991
- return {
992
- ...base,
993
- kind: "settled",
994
- actor: "gateway",
995
- reason: "tracking_disabled",
996
- };
997
- case "activity":
998
- case "nudge":
999
- case "capability_degraded":
1000
- case "conversation_rebound":
1001
- return undefined;
1002
- }
1003
- }
1004
- /**
1005
- * v1.4.x watches carried capability and phase state that no longer has product
1006
- * meaning. Strictly recognize the complete old graph, preserve representable
1007
- * history, and settle every live row once at upgrade. Rebase the bounded
1008
- * private sequence so a historical counter cannot make the new format fatal.
1009
- */
1010
- function migrateV14ProgressWatchState(value, migratedAt) {
1011
- if (!isObject(value) ||
1012
- value.schemaVersion !== 1 ||
1013
- !isIsoTimestamp(value.updatedAt) ||
1014
- !isIsoTimestamp(migratedAt) ||
1015
- !isNonNegativeInteger(value.watchSequence) ||
1016
- !Array.isArray(value.progressWatches) ||
1017
- !Array.isArray(value.progressWatchEvents)) {
1018
- return value;
1019
- }
1020
- const hasLegacyRows = value.progressWatches.some(isLegacyV14ProgressWatch) ||
1021
- value.progressWatchEvents.some(isLegacyV14ProgressWatchJournalEvent);
1022
- if (!hasLegacyRows)
1023
- return value;
1024
- if (value.progressWatches.length > PROGRESS_WATCH_HARD_CAPACITY ||
1025
- !value.progressWatches.every(isLegacyV14ProgressWatch) ||
1026
- value.progressWatchEvents.length > gatewayPublicSnapshotLimits.messages ||
1027
- !value.progressWatchEvents.every(isLegacyV14ProgressWatchJournalEvent)) {
1028
- return value;
1029
- }
1030
- const watches = value.progressWatches;
1031
- const legacyEvents = value.progressWatchEvents;
1032
- const watchSequence = value.watchSequence;
1033
- if (!legacyProgressWatchGraphIsValid(value, watches) ||
1034
- legacyEvents.some((event, index) => event.sequence > watchSequence ||
1035
- (index > 0 && event.sequence <= legacyEvents[index - 1].sequence))) {
1036
- return value;
1037
- }
1038
- const pending = [];
1039
- for (const event of legacyEvents) {
1040
- const mapped = mapLegacyProgressWatchEvent(event);
1041
- if (mapped !== undefined)
1042
- pending.push(mapped);
1043
- }
1044
- const settlements = watches.map((watch) => ({
1045
- timestamp: migratedAt,
1046
- conversationId: watch.conversationId,
1047
- ownerAlias: watch.ownerAlias,
1048
- workerAlias: watch.workerAlias,
1049
- kind: "settled",
1050
- actor: "gateway",
1051
- reason: "legacy_upgrade",
1052
- }));
1053
- const retainedHistoryCount = gatewayPublicSnapshotLimits.messages - settlements.length;
1054
- const retained = [
1055
- ...pending.slice(Math.max(0, pending.length - retainedHistoryCount)),
1056
- ...settlements,
1057
- ];
1058
- const progressWatchEvents = retained.map((event, index) => ({
1059
- ...event,
1060
- sequence: index + 1,
1061
- }));
1062
- return {
1063
- ...value,
1064
- watchSequence: progressWatchEvents.length,
1065
- progressWatches: [],
1066
- progressWatchEvents,
1067
- };
1068
- }
1069
618
  function isPersistedState(value) {
1070
619
  if (!isObject(value) ||
1071
620
  !hasOnlyKeys(value, [
@@ -1074,7 +623,7 @@ function isPersistedState(value) {
1074
623
  "updatedAt",
1075
624
  "eventSequence",
1076
625
  "routes",
1077
- "pairs",
626
+ "consentEdges",
1078
627
  "connectors",
1079
628
  "queue",
1080
629
  "inFlight",
@@ -1085,14 +634,13 @@ function isPersistedState(value) {
1085
634
  "watchSequence",
1086
635
  "progressWatches",
1087
636
  "progressWatchEvents",
1088
- "compatibilityAttestations",
1089
637
  "codexEndpointRefreshSequence",
1090
638
  "codexEndpointRefreshEvents",
1091
639
  "codexOrphanRemovalSequence",
1092
640
  "codexOrphanRemovalEvents",
1093
641
  "codexSuccession",
1094
642
  ]) ||
1095
- value.schemaVersion !== 1 ||
643
+ value.schemaVersion !== 2 ||
1096
644
  !isIsoTimestamp(value.createdAt) ||
1097
645
  !isIsoTimestamp(value.updatedAt) ||
1098
646
  !isNonNegativeInteger(value.eventSequence) ||
@@ -1101,19 +649,15 @@ function isPersistedState(value) {
1101
649
  !isNonNegativeInteger(value.codexOrphanRemovalSequence) ||
1102
650
  !Array.isArray(value.routes) ||
1103
651
  !value.routes.every(isRouteRecord) ||
1104
- !Array.isArray(value.pairs) ||
1105
- value.pairs.length > 256 ||
1106
- !value.pairs.every(isPairRecord) ||
652
+ !Array.isArray(value.consentEdges) ||
653
+ value.consentEdges.length > gatewayPublicSnapshotLimits.consentEdges ||
654
+ !value.consentEdges.every(isConsentEdgeRecord) ||
1107
655
  !Array.isArray(value.progressWatches) ||
1108
656
  value.progressWatches.length > PROGRESS_WATCH_HARD_CAPACITY ||
1109
657
  !value.progressWatches.every(isProgressWatch) ||
1110
658
  !Array.isArray(value.progressWatchEvents) ||
1111
659
  value.progressWatchEvents.length > gatewayPublicSnapshotLimits.messages ||
1112
660
  !value.progressWatchEvents.every(isProgressWatchJournalEvent) ||
1113
- !Array.isArray(value.compatibilityAttestations) ||
1114
- value.compatibilityAttestations.length >
1115
- COMPATIBILITY_ATTESTATION_CAPACITY ||
1116
- !value.compatibilityAttestations.every(isPersistedCompatibilityAttestation) ||
1117
661
  !Array.isArray(value.codexEndpointRefreshEvents) ||
1118
662
  value.codexEndpointRefreshEvents.length >
1119
663
  CODEX_ENDPOINT_REFRESH_JOURNAL_CAPACITY ||
@@ -1156,10 +700,6 @@ function isPersistedState(value) {
1156
700
  const watchPairKeys = new Set(candidate.progressWatches.map(progressWatchPairKey));
1157
701
  if (watchPairKeys.size !== candidate.progressWatches.length)
1158
702
  return false;
1159
- const compatibilityKeys = candidate.compatibilityAttestations.map(compatibilityCacheKey);
1160
- if (new Set(compatibilityKeys).size !== compatibilityKeys.length) {
1161
- return false;
1162
- }
1163
703
  for (const watch of candidate.progressWatches) {
1164
704
  const owner = candidate.routes.find((route) => route.alias === watch.ownerAlias &&
1165
705
  route.binding.ownerLease === watch.ownerLease);
@@ -1171,17 +711,10 @@ function isPersistedState(value) {
1171
711
  owner.binding.hostId !== worker.binding.hostId) {
1172
712
  return false;
1173
713
  }
1174
- const pairAliases = pairAliasesForRoutes(owner, worker);
1175
- if (!candidate.pairs.some((pair) => pair.claudeAlias === pairAliases.claudeAlias &&
1176
- pair.codexAlias === pairAliases.codexAlias &&
1177
- pair.claudeOwnerLease ===
1178
- (owner.binding.provider === "claude"
1179
- ? owner.binding.ownerLease
1180
- : worker.binding.ownerLease) &&
1181
- pair.codexOwnerLease ===
1182
- (owner.binding.provider === "codex"
1183
- ? owner.binding.ownerLease
1184
- : worker.binding.ownerLease))) {
714
+ const endpoints = canonicalConsentEndpoints(owner, worker);
715
+ if (!candidate.consentEdges.some((edge) => edge.endpoints.every((endpoint, index) => endpoint.alias === endpoints[index].alias &&
716
+ endpoint.provider === endpoints[index].provider &&
717
+ endpoint.ownerLease === endpoints[index].ownerLease))) {
1185
718
  return false;
1186
719
  }
1187
720
  }
@@ -1192,15 +725,18 @@ function isPersistedState(value) {
1192
725
  ];
1193
726
  const expectedQueuedBytes = candidate.queue.reduce((total, item) => total + item.bytes, 0);
1194
727
  const routeByAlias = new Map(candidate.routes.map((route) => [route.alias, route]));
1195
- const pairKeys = candidate.pairs.map((pair) => `${pair.claudeAlias}\0${pair.codexAlias}`);
1196
- const pairsValid = candidate.pairs.every((pair) => {
1197
- const claude = routeByAlias.get(pair.claudeAlias);
1198
- const codex = routeByAlias.get(pair.codexAlias);
1199
- return (claude?.binding.provider === "claude" &&
1200
- codex?.binding.provider === "codex" &&
1201
- claude.binding.hostId === codex.binding.hostId &&
1202
- claude.binding.ownerLease === pair.claudeOwnerLease &&
1203
- codex.binding.ownerLease === pair.codexOwnerLease);
728
+ const edgeKeys = candidate.consentEdges.map((edge) => consentEdgeKey(edge.endpoints));
729
+ const edgesValid = candidate.consentEdges.every((edge) => {
730
+ const [leftEndpoint, rightEndpoint] = edge.endpoints;
731
+ const left = routeByAlias.get(leftEndpoint.alias);
732
+ const right = routeByAlias.get(rightEndpoint.alias);
733
+ return (compareConsentEndpoints(leftEndpoint, rightEndpoint) < 0 &&
734
+ left?.binding.provider === leftEndpoint.provider &&
735
+ right?.binding.provider === rightEndpoint.provider &&
736
+ left.binding.provider !== right.binding.provider &&
737
+ left.binding.hostId === right.binding.hostId &&
738
+ left.binding.ownerLease === leftEndpoint.ownerLease &&
739
+ right.binding.ownerLease === rightEndpoint.ownerLease);
1204
740
  });
1205
741
  const claudeConnectorHosts = new Set(candidate.connectors
1206
742
  .filter((connector) => connector.provider === "claude")
@@ -1209,22 +745,23 @@ function isPersistedState(value) {
1209
745
  const isValidPersistedMessagePair = (message) => {
1210
746
  const source = routeByAlias.get(message.sourceAlias);
1211
747
  const target = routeByAlias.get(message.targetAlias);
1212
- const routeShapeValid = message.direction === "codex_to_claude"
1213
- ? source?.binding.provider === "codex" &&
1214
- ((target?.binding.provider === "claude" &&
1215
- target.binding.hostId === source.binding.hostId) ||
1216
- (target === undefined &&
1217
- claudeConnectorHosts.has(source.binding.hostId) &&
1218
- aliasHost(message.targetAlias) === source.binding.hostId))
1219
- : target?.binding.provider === "codex" &&
1220
- ((source?.binding.provider === "claude" &&
1221
- source.binding.hostId === target.binding.hostId) ||
1222
- (source === undefined &&
1223
- claudeConnectorHosts.has(target.binding.hostId) &&
1224
- aliasHost(message.sourceAlias) === target.binding.hostId));
748
+ const parsed = parseDirection(message.direction);
749
+ if (parsed === undefined)
750
+ return false;
751
+ const sourceValid = source?.binding.provider === parsed.sourceProvider ||
752
+ (source === undefined && parsed.sourceProvider === "claude" && target !== undefined &&
753
+ claudeConnectorHosts.has(target.binding.hostId) &&
754
+ aliasHost(message.sourceAlias) === target.binding.hostId);
755
+ const targetValid = target?.binding.provider === parsed.targetProvider ||
756
+ (target === undefined && parsed.targetProvider === "claude" && source !== undefined &&
757
+ claudeConnectorHosts.has(source.binding.hostId) &&
758
+ aliasHost(message.targetAlias) === source.binding.hostId);
759
+ const routeShapeValid = sourceValid && targetValid &&
760
+ (source === undefined || target === undefined ||
761
+ source.binding.hostId === target.binding.hostId);
1225
762
  return (routeShapeValid &&
1226
763
  (message.pair !== true ||
1227
- candidate.pairs.some((pair) => pairMatchesMessage(pair, message))));
764
+ candidate.consentEdges.some((edge) => consentEdgeMatchesMessage(edge, message))));
1228
765
  };
1229
766
  const sequencesStrictlyIncrease = candidate.events.every((event, index) => index === 0 ||
1230
767
  event.sequence > (candidate.events[index - 1]?.sequence ?? 0));
@@ -1250,8 +787,8 @@ function isPersistedState(value) {
1250
787
  (candidate.codexOrphanRemovalEvents[index - 1]?.sequence ?? 0) +
1251
788
  1);
1252
789
  return (aliases.size === candidate.routes.length &&
1253
- new Set(pairKeys).size === pairKeys.length &&
1254
- pairsValid &&
790
+ new Set(edgeKeys).size === edgeKeys.length &&
791
+ edgesValid &&
1255
792
  routeTargets.size === candidate.routes.length &&
1256
793
  ownerLeases.size === candidate.routes.length &&
1257
794
  connectorKeys.size === candidate.connectors.length &&
@@ -1267,10 +804,12 @@ function isPersistedState(value) {
1267
804
  endpointRefreshSequenceConsistent &&
1268
805
  orphanRemovalSequenceConsistent &&
1269
806
  candidate.routes.every((route) => route.alias.endsWith(`@${route.binding.hostId}`) &&
1270
- ((route.binding.provider === "codex" &&
1271
- route.registrationMode === "explicit_opt_in") ||
1272
- (route.binding.provider === "claude" &&
1273
- route.registrationMode === "selected_live_peer")) &&
807
+ ((route.binding.provider === "claude" &&
808
+ route.registrationMode === "selected_live_peer") ||
809
+ (route.binding.provider !== "claude" &&
810
+ route.registrationMode === "explicit_opt_in")) &&
811
+ (gatewayRegistrationIngressPrefixes[route.binding.provider] === undefined ||
812
+ route.alias.startsWith(gatewayRegistrationIngressPrefixes[route.binding.provider])) &&
1274
813
  route.queueDepth ===
1275
814
  candidate.queue.filter((item) => item.targetAlias === route.alias)
1276
815
  .length) &&
@@ -1281,8 +820,7 @@ function isPersistedState(value) {
1281
820
  }) &&
1282
821
  candidate.dedupe.every(isValidPersistedMessagePair) &&
1283
822
  candidate.rateBuckets.every((bucket) => aliases.has(bucket.sourceAlias) ||
1284
- candidate.routes.some((route) => route.binding.provider === "codex" &&
1285
- claudeConnectorHosts.has(route.binding.hostId) &&
823
+ candidate.routes.some((route) => claudeConnectorHosts.has(route.binding.hostId) &&
1286
824
  route.binding.hostId === aliasHost(bucket.sourceAlias))) &&
1287
825
  isPersistedSuccessionConsistent(candidate));
1288
826
  }
@@ -1466,41 +1004,46 @@ function renameRateBucket(state, previousAlias, newAlias) {
1466
1004
  existing.count = Math.min(Number.MAX_SAFE_INTEGER, existing.count + previous.count);
1467
1005
  state.rateBuckets = state.rateBuckets.filter((bucket) => bucket !== previous);
1468
1006
  }
1469
- function pairKey(claudeAlias, codexAlias) {
1470
- return `${claudeAlias}\0${codexAlias}`;
1007
+ function compareConsentEndpoints(left, right) {
1008
+ return gatewayProviders.indexOf(left.provider) -
1009
+ gatewayProviders.indexOf(right.provider) || left.alias.localeCompare(right.alias);
1471
1010
  }
1472
- function pairAliasesForRoutes(source, target) {
1473
- if (source.binding.provider === "claude" &&
1474
- target.binding.provider === "codex") {
1475
- return { claudeAlias: source.alias, codexAlias: target.alias };
1476
- }
1477
- if (source.binding.provider === "codex" &&
1478
- target.binding.provider === "claude") {
1479
- return { claudeAlias: target.alias, codexAlias: source.alias };
1480
- }
1481
- throw new BridgeError("INVALID_GATEWAY_ROUTE_PAIR", "Gateway pairs must connect one Claude route and one Codex route.");
1011
+ function canonicalConsentEndpoints(source, target) {
1012
+ const endpoints = [
1013
+ { alias: source.alias, provider: source.binding.provider,
1014
+ ownerLease: source.binding.ownerLease },
1015
+ { alias: target.alias, provider: target.binding.provider,
1016
+ ownerLease: target.binding.ownerLease },
1017
+ ];
1018
+ endpoints.sort(compareConsentEndpoints);
1019
+ return endpoints;
1020
+ }
1021
+ function consentEdgeKey(endpoints) {
1022
+ return endpoints.map(({ provider, alias }) => `${provider}\0${alias}`).join("\0");
1482
1023
  }
1483
- function pairMatchesMessage(pair, message) {
1484
- return (message.pair === true &&
1485
- ((message.sourceAlias === pair.claudeAlias &&
1486
- message.targetAlias === pair.codexAlias) ||
1487
- (message.sourceAlias === pair.codexAlias &&
1488
- message.targetAlias === pair.claudeAlias)));
1024
+ function consentEdgeMatchesMessage(pair, message) {
1025
+ const aliases = "endpoints" in pair
1026
+ ? pair.endpoints.map((endpoint) => endpoint.alias)
1027
+ : pair.aliases;
1028
+ return message.pair === true &&
1029
+ ((message.sourceAlias === aliases[0] && message.targetAlias === aliases[1]) ||
1030
+ (message.sourceAlias === aliases[1] && message.targetAlias === aliases[0]));
1489
1031
  }
1490
- function findPairForMessage(state, message) {
1491
- return state.pairs.find((pair) => pairMatchesMessage(pair, message));
1032
+ function findConsentEdgeForMessage(state, message) {
1033
+ return state.consentEdges.find((edge) => consentEdgeMatchesMessage(edge, message));
1492
1034
  }
1493
- function renamePairAlias(state, previousAlias, newAlias, now) {
1035
+ function renameConsentEdgeAlias(state, previousAlias, newAlias, now) {
1494
1036
  if (previousAlias === newAlias)
1495
1037
  return;
1496
- for (const pair of state.pairs) {
1497
- if (pair.claudeAlias === previousAlias)
1498
- pair.claudeAlias = newAlias;
1499
- if (pair.codexAlias === previousAlias)
1500
- pair.codexAlias = newAlias;
1501
- if (pair.claudeAlias === newAlias ||
1502
- pair.codexAlias === newAlias) {
1503
- pair.updatedAt = now.toISOString();
1038
+ for (const edge of state.consentEdges) {
1039
+ const endpoint = edge.endpoints.find(({ alias }) => alias === previousAlias);
1040
+ if (endpoint !== undefined) {
1041
+ const renamed = edge.endpoints.map((candidate) => candidate.alias === previousAlias
1042
+ ? { ...candidate, alias: newAlias }
1043
+ : candidate);
1044
+ renamed.sort(compareConsentEndpoints);
1045
+ edge.endpoints = renamed;
1046
+ edge.updatedAt = now.toISOString();
1504
1047
  }
1505
1048
  }
1506
1049
  }
@@ -1513,8 +1056,8 @@ function renameProgressWatchAlias(state, previousAlias, newAlias) {
1513
1056
  ? { ...watch, workerAlias: newAlias }
1514
1057
  : watch);
1515
1058
  }
1516
- function removePairsForAliases(state, aliases) {
1517
- state.pairs = state.pairs.filter((pair) => !aliases.has(pair.claudeAlias) && !aliases.has(pair.codexAlias));
1059
+ function removeConsentEdgesForAliases(state, aliases) {
1060
+ state.consentEdges = state.consentEdges.filter((edge) => !edge.endpoints.some(({ alias }) => aliases.has(alias)));
1518
1061
  }
1519
1062
  function routeTerminationMatches(state, aliases, message) {
1520
1063
  for (const alias of [message.sourceAlias, message.targetAlias]) {
@@ -1528,15 +1071,10 @@ function routeTerminationMatches(state, aliases, message) {
1528
1071
  return false;
1529
1072
  }
1530
1073
  function directionFor(source, target) {
1531
- if (source.binding.provider === "codex" &&
1532
- target.binding.provider === "claude") {
1533
- return "codex_to_claude";
1534
- }
1535
- if (source.binding.provider === "claude" &&
1536
- target.binding.provider === "codex") {
1537
- return "claude_to_codex";
1074
+ if (source.binding.provider !== target.binding.provider) {
1075
+ return directionId(source.binding.provider, target.binding.provider);
1538
1076
  }
1539
- throw new BridgeError("INVALID_GATEWAY_ROUTE_PAIR", "Gateway messages must cross from one provider to the other.");
1077
+ throw new BridgeError("INVALID_GATEWAY_ROUTE_PAIR", "Gateway messages must cross between distinct providers.");
1540
1078
  }
1541
1079
  export class GatewayStore {
1542
1080
  config;
@@ -1549,11 +1087,7 @@ export class GatewayStore {
1549
1087
  state;
1550
1088
  lockHandle;
1551
1089
  lockToken;
1552
- /**
1553
- * Startup admission may exercise migrated state in memory, but it must not
1554
- * strand an older binary by rewriting the durable schema before every
1555
- * provider and control-socket readiness check has passed.
1556
- */
1090
+ /** Startup may defer the first native-v2 persistence until admission passes. */
1557
1091
  persistenceDeferred = false;
1558
1092
  constructor(config, dependencies = {}) {
1559
1093
  this.config = config;
@@ -1579,12 +1113,12 @@ export class GatewayStore {
1579
1113
  const now = this.now();
1580
1114
  const loaded = await this.loadStateFile(now);
1581
1115
  this.state = loaded ?? {
1582
- schemaVersion: 1,
1116
+ schemaVersion: 2,
1583
1117
  createdAt: now.toISOString(),
1584
1118
  updatedAt: now.toISOString(),
1585
1119
  eventSequence: 0,
1586
1120
  routes: [],
1587
- pairs: [],
1121
+ consentEdges: [],
1588
1122
  connectors: [],
1589
1123
  queue: [],
1590
1124
  inFlight: [],
@@ -1595,15 +1129,14 @@ export class GatewayStore {
1595
1129
  watchSequence: 0,
1596
1130
  progressWatches: [],
1597
1131
  progressWatchEvents: [],
1598
- compatibilityAttestations: [],
1599
1132
  codexEndpointRefreshSequence: 0,
1600
1133
  codexEndpointRefreshEvents: [],
1601
1134
  codexOrphanRemovalSequence: 0,
1602
1135
  codexOrphanRemovalEvents: [],
1603
1136
  codexSuccession: null,
1604
1137
  };
1605
- if (this.state.pairs.length > this.config.limits.maxPairs) {
1606
- throw new BridgeError("PAIR_CAPACITY_REACHED", "The durable pair inventory exceeds the configured bound.");
1138
+ if (this.state.consentEdges.length > this.config.limits.maxConsentEdges) {
1139
+ throw new BridgeError("CONSENT_EDGE_CAPACITY_REACHED", "The durable consent-edge inventory exceeds the configured bound.");
1607
1140
  }
1608
1141
  if (this.state.progressWatches.length >
1609
1142
  (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
@@ -1661,7 +1194,6 @@ export class GatewayStore {
1661
1194
  const observed = {
1662
1195
  ...input.identity,
1663
1196
  health: input.health,
1664
- compatibility: input.compatibility,
1665
1197
  protocol: input.protocol,
1666
1198
  protocolVersion: input.protocolVersion,
1667
1199
  updatedAt: now.toISOString(),
@@ -1671,7 +1203,8 @@ export class GatewayStore {
1671
1203
  if (existingIndex >= 0)
1672
1204
  state.connectors[existingIndex] = observed;
1673
1205
  else {
1674
- if (state.connectors.length >= this.config.allowedHosts.length * 2) {
1206
+ if (state.connectors.length >=
1207
+ this.config.allowedHosts.length * gatewayProviders.length) {
1675
1208
  throw new BridgeError("CONNECTOR_CAPACITY_REACHED", "The bounded provider and host connector registry is full.");
1676
1209
  }
1677
1210
  state.connectors.push(observed);
@@ -1702,7 +1235,6 @@ export class GatewayStore {
1702
1235
  if (!sameEndpoint(route.binding, identity))
1703
1236
  continue;
1704
1237
  route.state = route.enabled ? "stale" : "disabled";
1705
- route.compatibility = "expired";
1706
1238
  route.updatedAt = now.toISOString();
1707
1239
  route.safeErrorCode = safeErrorCode;
1708
1240
  }
@@ -1715,9 +1247,8 @@ export class GatewayStore {
1715
1247
  this.assertAllowedIdentity(endpointOf(input.binding));
1716
1248
  const connector = state.connectors.find((candidate) => sameEndpoint(candidate, input.binding));
1717
1249
  if (!connector ||
1718
- !["healthy", "degraded"].includes(connector.health) ||
1719
- connector.compatibility !== "compatible") {
1720
- throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The exact compatible endpoint generation must be observed before a route can be registered.");
1250
+ !["healthy", "degraded"].includes(connector.health)) {
1251
+ throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The exact live endpoint generation must be observed before a route can be registered.");
1721
1252
  }
1722
1253
  const byAlias = state.routes.find((route) => route.alias === input.alias);
1723
1254
  const byBinding = state.routes.find((route) => sameRouteTarget(route.binding, input.binding));
@@ -1730,7 +1261,6 @@ export class GatewayStore {
1730
1261
  }
1731
1262
  byAlias.enabled = true;
1732
1263
  byAlias.state = input.state ?? "idle";
1733
- byAlias.compatibility = "compatible";
1734
1264
  byAlias.updatedAt = now.toISOString();
1735
1265
  byAlias.lastSeenAt = now.toISOString();
1736
1266
  delete byAlias.safeErrorCode;
@@ -1751,7 +1281,6 @@ export class GatewayStore {
1751
1281
  registrationMode: input.registrationMode,
1752
1282
  enabled: true,
1753
1283
  state: input.state ?? "idle",
1754
- compatibility: "compatible",
1755
1284
  busyPolicy: "queue",
1756
1285
  registeredAt: now.toISOString(),
1757
1286
  updatedAt: now.toISOString(),
@@ -1778,9 +1307,8 @@ export class GatewayStore {
1778
1307
  }
1779
1308
  const connector = state.connectors.find((candidate) => sameEndpoint(candidate, replacement.binding));
1780
1309
  if (!connector ||
1781
- !["healthy", "degraded"].includes(connector.health) ||
1782
- connector.compatibility !== "compatible") {
1783
- throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The exact compatible endpoint generation must be observed before a Claude selection can replace another.");
1310
+ !["healthy", "degraded"].includes(connector.health)) {
1311
+ throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The exact live endpoint generation must be observed before a Claude selection can replace another.");
1784
1312
  }
1785
1313
  const selectedClaudeRoutes = state.routes.filter((route) => route.binding.provider === "claude" &&
1786
1314
  route.registrationMode === "selected_live_peer");
@@ -1793,9 +1321,7 @@ export class GatewayStore {
1793
1321
  const retained = logicalMatches[0];
1794
1322
  if (retained !== undefined &&
1795
1323
  !sameBinding(retained.binding, replacement.binding) &&
1796
- (!retained.enabled ||
1797
- retained.state !== "stale" ||
1798
- retained.compatibility !== "expired")) {
1324
+ (!retained.enabled || retained.state !== "stale")) {
1799
1325
  throw new BridgeError("ROUTE_REBIND_IDENTITY_MISMATCH", "Only a stale exact Claude session may adopt a newly observed endpoint generation during replacement.");
1800
1326
  }
1801
1327
  const aliasOwner = state.routes.find((route) => route.alias === replacement.alias);
@@ -1818,7 +1344,7 @@ export class GatewayStore {
1818
1344
  this.settleProgressWatchesForAliases(state, retiredAliases, now);
1819
1345
  const settlements = this.terminateAffectedMessages(state, retiredAliases, now, "ROUTE_UNREGISTERED", settlementPlan);
1820
1346
  state.routes = state.routes.filter((route) => !retired.includes(route));
1821
- removePairsForAliases(state, retiredAliases);
1347
+ removeConsentEdgesForAliases(state, retiredAliases);
1822
1348
  state.rateBuckets = state.rateBuckets.filter((bucket) => !retiredAliases.has(bucket.sourceAlias));
1823
1349
  state.dedupe = state.dedupe.filter((record) => !retiredAliases.has(record.sourceAlias) &&
1824
1350
  !retiredAliases.has(record.targetAlias));
@@ -1828,7 +1354,6 @@ export class GatewayStore {
1828
1354
  retained.binding = { ...replacement.binding };
1829
1355
  retained.enabled = true;
1830
1356
  retained.state = replacement.state ?? "idle";
1831
- retained.compatibility = "compatible";
1832
1357
  retained.updatedAt = now.toISOString();
1833
1358
  retained.lastSeenAt = now.toISOString();
1834
1359
  delete retained.safeErrorCode;
@@ -1850,7 +1375,7 @@ export class GatewayStore {
1850
1375
  }
1851
1376
  }
1852
1377
  renameRateBucket(state, previousAlias, replacement.alias);
1853
- renamePairAlias(state, previousAlias, replacement.alias, now);
1378
+ renameConsentEdgeAlias(state, previousAlias, replacement.alias, now);
1854
1379
  renameProgressWatchAlias(state, previousAlias, replacement.alias);
1855
1380
  }
1856
1381
  }
@@ -1864,7 +1389,6 @@ export class GatewayStore {
1864
1389
  registrationMode: replacement.registrationMode,
1865
1390
  enabled: true,
1866
1391
  state: replacement.state ?? "idle",
1867
- compatibility: "compatible",
1868
1392
  busyPolicy: "queue",
1869
1393
  registeredAt: now.toISOString(),
1870
1394
  updatedAt: now.toISOString(),
@@ -1896,16 +1420,13 @@ export class GatewayStore {
1896
1420
  throw new BridgeError("ROUTE_OWNERSHIP_MISMATCH", "No enabled route matches the exact private binding and ownership lease.");
1897
1421
  }
1898
1422
  const endpointObservationValid = input.state === "stale"
1899
- ? connector?.health === "degraded" &&
1900
- connector.compatibility === "expired"
1423
+ ? connector?.health === "degraded"
1901
1424
  : connector !== undefined &&
1902
- ["healthy", "degraded"].includes(connector.health) &&
1903
- connector.compatibility === "compatible";
1425
+ ["healthy", "degraded"].includes(connector.health);
1904
1426
  if (!endpointObservationValid) {
1905
- throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The route observation must match the exact endpoint generation's current health and compatibility.");
1427
+ throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The route observation must match the exact endpoint generation's current health.");
1906
1428
  }
1907
1429
  route.state = input.state;
1908
- route.compatibility = input.compatibility;
1909
1430
  route.lastSeenAt = now.toISOString();
1910
1431
  route.updatedAt = now.toISOString();
1911
1432
  if (input.safeErrorCode)
@@ -1928,7 +1449,6 @@ export class GatewayStore {
1928
1449
  const aliases = new Set([route.alias]);
1929
1450
  const settlementPlan = this.validateAffectedInFlightSettlements(state, aliases, inFlightSettlements);
1930
1451
  route.state = "stale";
1931
- route.compatibility = "expired";
1932
1452
  route.updatedAt = now.toISOString();
1933
1453
  route.safeErrorCode = safeErrorCode;
1934
1454
  return this.terminateAffectedMessages(state, aliases, now, safeErrorCode, settlementPlan);
@@ -1955,7 +1475,6 @@ export class GatewayStore {
1955
1475
  const route = this.requireOwnedRoute(state, input.alias, input.currentOwnerLease);
1956
1476
  if (!route.enabled ||
1957
1477
  route.state !== "stale" ||
1958
- route.compatibility !== "expired" ||
1959
1478
  state.inFlight.some((item) => item.sourceAlias === route.alias || item.targetAlias === route.alias)) {
1960
1479
  throw new BridgeError("ROUTE_REBIND_NOT_SAFE", "Only an enabled, expired stale route without in-flight work can be rebound.");
1961
1480
  }
@@ -1979,9 +1498,8 @@ export class GatewayStore {
1979
1498
  }
1980
1499
  const connector = state.connectors.find((candidate) => sameEndpoint(candidate, input.newBinding));
1981
1500
  if (!connector ||
1982
- !["healthy", "degraded"].includes(connector.health) ||
1983
- connector.compatibility !== "compatible") {
1984
- throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The replacement endpoint generation must be positively observed and compatible.");
1501
+ !["healthy", "degraded"].includes(connector.health)) {
1502
+ throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The replacement endpoint generation must be positively observed and live.");
1985
1503
  }
1986
1504
  if (state.routes.some((candidate) => candidate !== route &&
1987
1505
  (candidate.alias === newAlias ||
@@ -1998,7 +1516,6 @@ export class GatewayStore {
1998
1516
  route.binding = { ...input.newBinding };
1999
1517
  route.alias = newAlias;
2000
1518
  route.state = input.state ?? "idle";
2001
- route.compatibility = "compatible";
2002
1519
  route.updatedAt = now.toISOString();
2003
1520
  route.lastSeenAt = now.toISOString();
2004
1521
  delete route.safeErrorCode;
@@ -2020,7 +1537,7 @@ export class GatewayStore {
2020
1537
  }
2021
1538
  }
2022
1539
  renameRateBucket(state, previousAlias, newAlias);
2023
- renamePairAlias(state, previousAlias, newAlias, now);
1540
+ renameConsentEdgeAlias(state, previousAlias, newAlias, now);
2024
1541
  }
2025
1542
  if (input.journalReason === "boot_reactivation") {
2026
1543
  this.appendCodexEndpointRefreshEvent(state, {
@@ -2058,9 +1575,8 @@ export class GatewayStore {
2058
1575
  this.assertAllowedIdentity(input.newEndpoint);
2059
1576
  const newConnector = state.connectors.find((candidate) => sameEndpoint(candidate, input.newEndpoint));
2060
1577
  if (newConnector === undefined ||
2061
- !["healthy", "degraded"].includes(newConnector.health) ||
2062
- newConnector.compatibility !== "compatible") {
2063
- throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The replacement Codex endpoint generation must be positively observed and compatible.");
1578
+ !["healthy", "degraded"].includes(newConnector.health)) {
1579
+ throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The replacement Codex endpoint generation must be positively observed and live.");
2064
1580
  }
2065
1581
  const aliases = new Set();
2066
1582
  const threadIds = new Set();
@@ -2095,7 +1611,6 @@ export class GatewayStore {
2095
1611
  route.registrationMode !== "explicit_opt_in" ||
2096
1612
  !route.enabled ||
2097
1613
  route.state !== "stale" ||
2098
- route.compatibility !== "expired" ||
2099
1614
  !sameEndpoint(route.binding, input.oldEndpoint) ||
2100
1615
  route.binding.routeHandle !== candidate.threadId ||
2101
1616
  route.binding.ownerLease !== candidate.ownerLease ||
@@ -2125,7 +1640,6 @@ export class GatewayStore {
2125
1640
  endpointGeneration: input.newEndpoint.endpointGeneration,
2126
1641
  };
2127
1642
  replacement.route.state = replacement.state;
2128
- replacement.route.compatibility = "compatible";
2129
1643
  replacement.route.updatedAt = now.toISOString();
2130
1644
  replacement.route.lastSeenAt = now.toISOString();
2131
1645
  delete replacement.route.safeErrorCode;
@@ -2155,18 +1669,15 @@ export class GatewayStore {
2155
1669
  }
2156
1670
  const route = this.requireStaleCodexOrphan(state, input.alias);
2157
1671
  const aliases = new Set([route.alias]);
2158
- const removedPairs = state.pairs
2159
- .filter((pair) => pair.claudeAlias === route.alias || pair.codexAlias === route.alias)
2160
- .map((pair) => ({
2161
- claudeAlias: pair.claudeAlias,
2162
- codexAlias: pair.codexAlias,
2163
- }));
1672
+ const removedEdges = state.consentEdges
1673
+ .filter((edge) => edge.endpoints.some(({ alias }) => alias === route.alias))
1674
+ .map((edge) => ({ aliases: edge.endpoints.map(({ alias }) => alias) }));
2164
1675
  if (state.codexOrphanRemovalSequence >= Number.MAX_SAFE_INTEGER) {
2165
1676
  throw new BridgeError("CODEX_ORPHAN_REMOVAL_SEQUENCE_EXHAUSTED", "The bounded Codex orphan-removal sequence is exhausted.");
2166
1677
  }
2167
1678
  this.settleProgressWatchesForAliases(state, aliases, now);
2168
1679
  state.routes = state.routes.filter((candidate) => candidate !== route);
2169
- removePairsForAliases(state, aliases);
1680
+ removeConsentEdgesForAliases(state, aliases);
2170
1681
  state.rateBuckets = state.rateBuckets.filter((bucket) => bucket.sourceAlias !== route.alias);
2171
1682
  state.dedupe = state.dedupe.filter((record) => record.sourceAlias !== route.alias &&
2172
1683
  record.targetAlias !== route.alias);
@@ -2178,7 +1689,7 @@ export class GatewayStore {
2178
1689
  return {
2179
1690
  alias: route.alias,
2180
1691
  binding: { ...route.binding },
2181
- removedPairs,
1692
+ removedEdges,
2182
1693
  };
2183
1694
  });
2184
1695
  }
@@ -2201,7 +1712,7 @@ export class GatewayStore {
2201
1712
  this.settleProgressWatchesForAliases(state, aliases, now, progressWatchSettlementActor);
2202
1713
  const settlements = this.terminateAffectedMessages(state, aliases, now, "ROUTE_UNREGISTERED", settlementPlan);
2203
1714
  state.routes = state.routes.filter((candidate) => candidate !== route);
2204
- removePairsForAliases(state, aliases);
1715
+ removeConsentEdgesForAliases(state, aliases);
2205
1716
  const remainingCodexHosts = new Set(state.routes
2206
1717
  .filter((candidate) => candidate.binding.provider === "codex")
2207
1718
  .map((candidate) => candidate.binding.hostId));
@@ -2246,7 +1757,7 @@ export class GatewayStore {
2246
1757
  record.targetAlias = newAlias;
2247
1758
  }
2248
1759
  renameRateBucket(state, alias, newAlias);
2249
- renamePairAlias(state, alias, newAlias, now);
1760
+ renameConsentEdgeAlias(state, alias, newAlias, now);
2250
1761
  renameProgressWatchAlias(state, alias, newAlias);
2251
1762
  });
2252
1763
  }
@@ -2262,8 +1773,7 @@ export class GatewayStore {
2262
1773
  }
2263
1774
  if (!route ||
2264
1775
  !route.enabled ||
2265
- !["idle", "busy", "awaiting_approval"].includes(route.state) ||
2266
- route.compatibility !== "compatible") {
1776
+ !["idle", "busy", "awaiting_approval"].includes(route.state)) {
2267
1777
  throw new BridgeError("ROUTE_UNAVAILABLE", "The selected route is not currently enabled and positively observed.", true);
2268
1778
  }
2269
1779
  return { ...route.binding };
@@ -2289,7 +1799,6 @@ export class GatewayStore {
2289
1799
  registrationMode: route.registrationMode,
2290
1800
  enabled: route.enabled,
2291
1801
  state: route.state,
2292
- compatibility: route.compatibility,
2293
1802
  ...(route.safeErrorCode === undefined
2294
1803
  ? {}
2295
1804
  : { safeErrorCode: route.safeErrorCode }),
@@ -2372,7 +1881,6 @@ export class GatewayStore {
2372
1881
  registrationMode: route.registrationMode,
2373
1882
  enabled: route.enabled,
2374
1883
  state: route.state,
2375
- compatibility: route.compatibility,
2376
1884
  }));
2377
1885
  });
2378
1886
  }
@@ -2432,7 +1940,7 @@ export class GatewayStore {
2432
1940
  if (!routeMatchesSuccessionIdentity(route, input.old)) {
2433
1941
  throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "The old succession identity does not exactly own its Codex route.");
2434
1942
  }
2435
- this.requireCompatibleObservedEndpoint(state, input.new.binding);
1943
+ this.requireObservedEndpoint(state, input.new.binding);
2436
1944
  this.assertNewSuccessionIdentityAvailable(state, route, input.new);
2437
1945
  state.codexSuccession = {
2438
1946
  schemaVersion: 1,
@@ -2477,9 +1985,9 @@ export class GatewayStore {
2477
1985
  }, ["published"]);
2478
1986
  this.assertSuccessionLedgerEmpty(state, journal.old.alias);
2479
1987
  const route = this.requireJournalRoute(state, journal, "old");
2480
- this.requireCompatibleObservedEndpoint(state, journal.new.binding);
1988
+ this.requireObservedEndpoint(state, journal.new.binding);
2481
1989
  this.assertNewSuccessionIdentityAvailable(state, route, journal.new);
2482
- this.replaceCodexRouteForSuccession(state, route, journal.new, now, input.state, "compatible");
1990
+ this.replaceCodexRouteForSuccession(state, route, journal.new, now, input.state);
2483
1991
  state.codexSuccession = { ...journal, stage: "activated" };
2484
1992
  });
2485
1993
  }
@@ -2583,30 +2091,27 @@ export class GatewayStore {
2583
2091
  registrationMode: route.registrationMode,
2584
2092
  enabled: route.enabled,
2585
2093
  state: route.state,
2586
- compatibility: route.compatibility,
2587
2094
  }));
2588
2095
  });
2589
2096
  }
2590
- /** Create one explicit, durable Claude↔Codex consent edge. */
2591
- async pairRoutes(input) {
2097
+ /** Create one explicit, durable consent edge between distinct providers. */
2098
+ async addConsentEdge(input) {
2592
2099
  return this.mutate(async (state, now) => {
2593
- const { claude, codex } = this.requirePairRoutes(state, input);
2594
- const existing = state.pairs.find((pair) => pair.claudeAlias === claude.alias && pair.codexAlias === codex.alias);
2100
+ const [left, right] = this.requireConsentEdgeRoutes(state, input);
2101
+ const endpoints = canonicalConsentEndpoints(left, right);
2102
+ const key = consentEdgeKey(endpoints);
2103
+ const existing = state.consentEdges.find((edge) => consentEdgeKey(edge.endpoints) === key);
2595
2104
  if (existing !== undefined) {
2596
- if (existing.claudeOwnerLease !== claude.binding.ownerLease ||
2597
- existing.codexOwnerLease !== codex.binding.ownerLease) {
2598
- throw new BridgeError("PAIR_AUTHORITY_MISMATCH", "The pair aliases no longer match their exact route authorities.");
2105
+ if (existing.endpoints.some((endpoint, index) => endpoint.ownerLease !== endpoints[index].ownerLease)) {
2106
+ throw new BridgeError("CONSENT_EDGE_AUTHORITY_MISMATCH", "The edge aliases no longer match their exact route authorities.");
2599
2107
  }
2600
2108
  return { created: false };
2601
2109
  }
2602
- if (state.pairs.length >= this.config.limits.maxPairs) {
2603
- throw new BridgeError("PAIR_CAPACITY_REACHED", "The bounded permission graph cannot accept another pair.", true);
2110
+ if (state.consentEdges.length >= this.config.limits.maxConsentEdges) {
2111
+ throw new BridgeError("CONSENT_EDGE_CAPACITY_REACHED", "The bounded permission graph cannot accept another consent edge.", true);
2604
2112
  }
2605
- state.pairs.push({
2606
- claudeAlias: claude.alias,
2607
- codexAlias: codex.alias,
2608
- claudeOwnerLease: claude.binding.ownerLease,
2609
- codexOwnerLease: codex.binding.ownerLease,
2113
+ state.consentEdges.push({
2114
+ endpoints,
2610
2115
  createdAt: now.toISOString(),
2611
2116
  updatedAt: now.toISOString(),
2612
2117
  counters: emptyCounters(),
@@ -2615,12 +2120,14 @@ export class GatewayStore {
2615
2120
  });
2616
2121
  }
2617
2122
  /** Bounded metadata-only graph inventory for controller inference. */
2618
- async inspectPairs() {
2123
+ async inspectConsentEdges() {
2619
2124
  return this.mutex.run("gateway", async () => {
2620
2125
  const state = this.requireState();
2621
- return state.pairs
2622
- .map(({ claudeAlias, codexAlias }) => ({ claudeAlias, codexAlias }))
2623
- .sort((left, right) => pairKey(left.claudeAlias, left.codexAlias).localeCompare(pairKey(right.claudeAlias, right.codexAlias)));
2126
+ return state.consentEdges
2127
+ .map(({ endpoints }) => ({
2128
+ aliases: endpoints.map(({ alias }) => alias),
2129
+ }))
2130
+ .sort((left, right) => left.aliases.join("\0").localeCompare(right.aliases.join("\0")));
2624
2131
  });
2625
2132
  }
2626
2133
  /** Resolve only an exact live watch; recovery may substitute its suffix. */
@@ -2647,11 +2154,11 @@ export class GatewayStore {
2647
2154
  watch.workerAlias === input.sourceAlias)));
2648
2155
  if (watch === undefined)
2649
2156
  return { markerActive: false };
2650
- const hasExactPair = state.pairs.some((candidate) => pairMatchesMessage(candidate, {
2157
+ const hasExactPair = state.consentEdges.some((candidate) => consentEdgeMatchesMessage(candidate, {
2651
2158
  sourceAlias: input.sourceAlias,
2652
2159
  targetAlias: input.targetAlias,
2653
2160
  pair: true,
2654
- }) && progressWatchMatchesPair(watch, candidate));
2161
+ }) && progressWatchMatchesConsentEdge(watch, candidate));
2655
2162
  if (!hasExactPair)
2656
2163
  return { markerActive: false };
2657
2164
  return {
@@ -2661,38 +2168,6 @@ export class GatewayStore {
2661
2168
  };
2662
2169
  });
2663
2170
  }
2664
- async inspectCompatibilityAttestation(surface, version) {
2665
- return this.mutex.run("gateway", async () => {
2666
- const attestation = this.requireState().compatibilityAttestations.find((candidate) => candidate.surface === surface && candidate.version === version);
2667
- return attestation === undefined
2668
- ? undefined
2669
- : structuredClone(attestation);
2670
- });
2671
- }
2672
- async inspectCompatibilityAttestations() {
2673
- return this.mutex.run("gateway", async () => this.requireState().compatibilityAttestations
2674
- .map((attestation) => structuredClone(attestation))
2675
- .sort((left, right) => compatibilityCacheKey(left).localeCompare(compatibilityCacheKey(right))));
2676
- }
2677
- async recordCompatibilityAttestation(attestation) {
2678
- if (!isCompatibilityAttestation(attestation)) {
2679
- throw new BridgeError("COMPAT_ATTESTATION_INVALID", "Compatibility evidence failed strict schema validation.");
2680
- }
2681
- await this.mutate(async (state) => {
2682
- const key = compatibilityCacheKey(attestation);
2683
- const index = state.compatibilityAttestations.findIndex((candidate) => compatibilityCacheKey(candidate) === key);
2684
- if (index >= 0) {
2685
- state.compatibilityAttestations[index] = structuredClone(attestation);
2686
- return;
2687
- }
2688
- if (state.compatibilityAttestations.length >=
2689
- COMPATIBILITY_ATTESTATION_CAPACITY) {
2690
- state.compatibilityAttestations.sort((left, right) => left.checkedAt.localeCompare(right.checkedAt));
2691
- state.compatibilityAttestations.shift();
2692
- }
2693
- state.compatibilityAttestations.push(structuredClone(attestation));
2694
- });
2695
- }
2696
2171
  async touchProgressWatchesForAlias(alias) {
2697
2172
  return this.mutate(async (state, now) => {
2698
2173
  if (!ALIAS_PATTERN.test(alias))
@@ -2788,15 +2263,15 @@ export class GatewayStore {
2788
2263
  return true;
2789
2264
  });
2790
2265
  }
2791
- async hasPair(input) {
2266
+ async hasConsentEdge(input) {
2792
2267
  return this.mutex.run("gateway", async () => {
2793
2268
  const state = this.requireState();
2794
2269
  try {
2795
- const { claude, codex } = this.requirePairRoutes(state, input, false);
2796
- return state.pairs.some((pair) => pair.claudeAlias === claude.alias &&
2797
- pair.codexAlias === codex.alias &&
2798
- pair.claudeOwnerLease === claude.binding.ownerLease &&
2799
- pair.codexOwnerLease === codex.binding.ownerLease);
2270
+ const [left, right] = this.requireConsentEdgeRoutes(state, input, false);
2271
+ const endpoints = canonicalConsentEndpoints(left, right);
2272
+ return state.consentEdges.some((edge) => edge.endpoints.every((endpoint, index) => endpoint.alias === endpoints[index].alias &&
2273
+ endpoint.provider === endpoints[index].provider &&
2274
+ endpoint.ownerLease === endpoints[index].ownerLease));
2800
2275
  }
2801
2276
  catch (error) {
2802
2277
  if (error instanceof BridgeError)
@@ -2805,32 +2280,34 @@ export class GatewayStore {
2805
2280
  }
2806
2281
  });
2807
2282
  }
2808
- async inspectAffectedPairInFlightMessages(input) {
2283
+ async inspectAffectedConsentEdgeInFlightMessages(input) {
2809
2284
  return this.mutex.run("gateway", async () => {
2810
2285
  const state = this.requireState();
2811
- const { claude, codex } = this.requirePairRoutes(state, input, false);
2812
- this.requireExactPair(state, claude, codex);
2286
+ const routes = this.requireConsentEdgeRoutes(state, input, false);
2287
+ const edge = this.requireExactConsentEdge(state, ...routes);
2813
2288
  return state.inFlight
2814
- .filter((item) => pairMatchesMessage(input, item))
2289
+ .filter((item) => consentEdgeMatchesMessage(edge, item))
2815
2290
  .map(({ messageId, deadlineAt }) => ({ messageId, deadlineAt }));
2816
2291
  });
2817
2292
  }
2818
2293
  /**
2819
2294
  * Remove exactly one consent edge and settle only work owned by that edge.
2820
- * Adjacent pairs, terminal token metadata, and unrelated messages survive.
2295
+ * Adjacent edges, terminal token metadata, and unrelated messages survive.
2821
2296
  */
2822
- async unpairRoutes(input) {
2297
+ async removeConsentEdge(input) {
2823
2298
  return this.mutate(async (state, now) => {
2824
- const { claude, codex } = this.requirePairRoutes(state, input, false);
2825
- const pair = this.requireExactPair(state, claude, codex);
2826
- const plan = this.validateAffectedPairInFlightSettlements(state, pair, input.inFlightSettlements ?? []);
2827
- this.settleProgressWatchesForPair(state, pair, now);
2828
- const settlements = this.terminateAffectedPairMessages(state, pair, now, "PAIR_REMOVED", plan);
2829
- state.pairs = state.pairs.filter((candidate) => candidate !== pair);
2830
- state.dedupe = state.dedupe.filter((record) => !pairMatchesMessage(pair, record));
2299
+ const routes = this.requireConsentEdgeRoutes(state, input, false);
2300
+ const pair = this.requireExactConsentEdge(state, ...routes);
2301
+ const plan = this.validateAffectedConsentEdgeInFlightSettlements(state, pair, input.inFlightSettlements ?? []);
2302
+ this.settleProgressWatchesForConsentEdge(state, pair, now);
2303
+ const settlements = this.terminateAffectedConsentEdgeMessages(state, pair, now, "PAIR_REMOVED", plan);
2304
+ state.consentEdges = state.consentEdges.filter((candidate) => candidate !== pair);
2305
+ state.dedupe = state.dedupe.filter((record) => !consentEdgeMatchesMessage(pair, record));
2831
2306
  return {
2832
2307
  settlements,
2833
- claudeRouteUnreferenced: !state.pairs.some((candidate) => candidate.claudeAlias === pair.claudeAlias),
2308
+ unreferencedAliases: pair.endpoints
2309
+ .filter(({ alias }) => !state.consentEdges.some((edge) => edge.endpoints.some((endpoint) => endpoint.alias === alias)))
2310
+ .map(({ alias }) => alias),
2834
2311
  };
2835
2312
  });
2836
2313
  }
@@ -2866,8 +2343,7 @@ export class GatewayStore {
2866
2343
  targetRoute: target,
2867
2344
  };
2868
2345
  try {
2869
- const pairAliases = pairAliasesForRoutes(source, target);
2870
- this.requireExactPair(state, pairAliases.claudeAlias === source.alias ? source : target, pairAliases.codexAlias === source.alias ? source : target);
2346
+ this.requireExactConsentEdge(state, source, target);
2871
2347
  sides.pair = true;
2872
2348
  }
2873
2349
  catch (error) {
@@ -2889,24 +2365,26 @@ export class GatewayStore {
2889
2365
  async enqueueNativeIngress(input) {
2890
2366
  return this.mutate(async (state, now) => {
2891
2367
  const target = this.requireAvailableRoute(state, input.targetAlias);
2892
- if (target.binding.provider !== "codex") {
2893
- throw new BridgeError("INVALID_NATIVE_INGRESS_TARGET", "Native Claude ingress requires an explicitly registered Codex target.");
2894
- }
2895
2368
  this.validateTransientNativeClaudePeer(state, input.source, target);
2369
+ const direction = directionId("claude", target.binding.provider);
2896
2370
  const selectedClaude = state.routes.find((route) => route.alias === input.source.alias &&
2897
2371
  route.binding.provider === "claude" &&
2898
2372
  route.registrationMode === "selected_live_peer" &&
2899
2373
  route.enabled &&
2900
- route.compatibility === "compatible" &&
2901
2374
  ["idle", "busy", "awaiting_approval"].includes(route.state) &&
2902
2375
  sameBinding(route.binding, input.source.binding));
2903
2376
  const pair = selectedClaude === undefined
2904
2377
  ? undefined
2905
- : state.pairs.find((candidate) => candidate.claudeAlias === selectedClaude.alias &&
2906
- candidate.codexAlias === target.alias &&
2907
- candidate.claudeOwnerLease ===
2908
- selectedClaude.binding.ownerLease &&
2909
- candidate.codexOwnerLease === target.binding.ownerLease);
2378
+ : (() => {
2379
+ try {
2380
+ return this.requireExactConsentEdge(state, selectedClaude, target);
2381
+ }
2382
+ catch (error) {
2383
+ if (error instanceof BridgeError)
2384
+ return undefined;
2385
+ throw error;
2386
+ }
2387
+ })();
2910
2388
  if (input.authorizedPairTeardownReply === true &&
2911
2389
  selectedClaude === undefined) {
2912
2390
  throw new BridgeError("SENDER_NOT_PAIRED", "The retained reply no longer matches the exact selected Claude route.");
@@ -2920,48 +2398,47 @@ export class GatewayStore {
2920
2398
  this.recordRejection(state, {
2921
2399
  sourceAlias: input.source.alias,
2922
2400
  targetAlias: target.alias,
2923
- direction: "claude_to_codex",
2401
+ direction,
2924
2402
  targetRoute: target,
2925
2403
  }, bytes, now, "SENDER_NOT_PAIRED", input.steer, input.conversationIdSuffix);
2926
- throw new BridgeError("SENDER_NOT_PAIRED", "The native Claude sender is not the exact session paired with this Codex task.");
2404
+ throw new BridgeError("SENDER_NOT_PAIRED", "The native Claude sender does not share exact consent with this route.");
2927
2405
  }
2928
2406
  return this.enqueueResolvedMessage(state, now, input, {
2929
2407
  sourceAlias: input.source.alias,
2930
2408
  targetAlias: target.alias,
2931
- direction: "claude_to_codex",
2409
+ direction,
2932
2410
  targetRoute: target,
2933
2411
  ...(pair === undefined ? {} : { pair: true }),
2934
2412
  });
2935
2413
  });
2936
2414
  }
2937
2415
  /**
2938
- * Queue a correlated Codex reply for a caller-attested transient Claude
2416
+ * Queue a correlated provider reply for a caller-attested transient Claude
2939
2417
  * peer. The service owns conversation correlation and the live dispatch
2940
2418
  * capability; the store retains only bounded public-alias metadata.
2941
2419
  */
2942
2420
  async enqueueNativeReply(input) {
2943
2421
  return this.mutate(async (state, now) => {
2944
2422
  const source = this.requireAvailableRoute(state, input.sourceAlias);
2945
- if (source.binding.provider !== "codex") {
2946
- throw new BridgeError("INVALID_NATIVE_REPLY_SOURCE", "A native Claude reply requires an explicitly registered Codex source.");
2947
- }
2948
2423
  this.validateTransientNativeClaudePeer(state, input.target, source);
2949
- if (input.pair === true) {
2950
- const target = state.routes.find((route) => route.alias === input.target.alias &&
2424
+ const target = input.pair === true
2425
+ ? state.routes.find((route) => route.alias === input.target.alias &&
2951
2426
  route.binding.provider === "claude" &&
2952
- sameBinding(route.binding, input.target.binding));
2427
+ sameBinding(route.binding, input.target.binding))
2428
+ : undefined;
2429
+ if (input.pair === true) {
2953
2430
  if (target === undefined) {
2954
2431
  throw new BridgeError("SENDER_NOT_PAIRED", "The correlated reply no longer has its exact paired Claude route.");
2955
2432
  }
2956
- this.requireExactPair(state, target, source);
2433
+ this.requireExactConsentEdge(state, target, source);
2957
2434
  }
2958
2435
  return this.enqueueResolvedMessage(state, now, input, {
2959
2436
  sourceAlias: source.alias,
2960
2437
  targetAlias: input.target.alias,
2961
- direction: "codex_to_claude",
2438
+ direction: directionId(source.binding.provider, "claude"),
2962
2439
  sourceRoute: source,
2963
- ...(input.pair === true
2964
- ? { pair: true }
2440
+ ...(target !== undefined
2441
+ ? { pair: true, targetRoute: target }
2965
2442
  : { transientTarget: true }),
2966
2443
  });
2967
2444
  });
@@ -3235,7 +2712,6 @@ export class GatewayStore {
3235
2712
  provider: connector.provider,
3236
2713
  host: connector.hostId,
3237
2714
  health: connector.health,
3238
- compatibility: connector.compatibility,
3239
2715
  protocol: connector.protocol,
3240
2716
  protocolVersion: connector.protocolVersion,
3241
2717
  ...(connector.lastSeenAt ? { lastSeenAt: connector.lastSeenAt } : {}),
@@ -3261,7 +2737,6 @@ export class GatewayStore {
3261
2737
  host: route.binding.hostId,
3262
2738
  enabled: route.enabled,
3263
2739
  state: route.state,
3264
- compatibility: route.compatibility,
3265
2740
  busyPolicy: route.busyPolicy,
3266
2741
  ...(route.lastSeenAt ? { lastSeenAt: route.lastSeenAt } : {}),
3267
2742
  queueDepth: route.queueDepth,
@@ -3273,14 +2748,13 @@ export class GatewayStore {
3273
2748
  };
3274
2749
  })
3275
2750
  .sort((left, right) => left.alias.localeCompare(right.alias));
3276
- const pairs = state.pairs
3277
- .map((pair) => ({
3278
- claudeAlias: pair.claudeAlias,
3279
- codexAlias: pair.codexAlias,
3280
- host: pair.claudeAlias.slice(pair.claudeAlias.lastIndexOf("@") + 1),
3281
- counters: { ...pair.counters },
2751
+ const consentEdges = state.consentEdges
2752
+ .map((edge) => ({
2753
+ endpoints: edge.endpoints.map(({ alias, provider }) => ({ alias, provider })),
2754
+ host: edge.endpoints[0].alias.slice(edge.endpoints[0].alias.lastIndexOf("@") + 1),
2755
+ counters: { ...edge.counters },
3282
2756
  }))
3283
- .sort((left, right) => `${left.claudeAlias}\0${left.codexAlias}`.localeCompare(`${right.claudeAlias}\0${right.codexAlias}`));
2757
+ .sort((left, right) => consentEdgeKey(left.endpoints).localeCompare(consentEdgeKey(right.endpoints)));
3284
2758
  const progressWatches = state.progressWatches
3285
2759
  .map((watch) => ({
3286
2760
  conversationIdSuffix: watch.conversationId.slice(-8),
@@ -3368,14 +2842,14 @@ export class GatewayStore {
3368
2842
  return `${left.code}\0${left.provider ?? ""}\0${left.host ?? ""}\0${left.alias ?? ""}`.localeCompare(`${right.code}\0${right.provider ?? ""}\0${right.host ?? ""}\0${right.alias ?? ""}`);
3369
2843
  });
3370
2844
  return projectGatewayPublicSnapshot({
3371
- schemaVersion: 1,
2845
+ schemaVersion: 2,
3372
2846
  generatedAt: now.toISOString(),
3373
2847
  inboundMode: this.config.inboundMode,
3374
2848
  health,
3375
2849
  connectors,
3376
2850
  availablePeers: [],
3377
2851
  routes,
3378
- pairs,
2852
+ consentEdges,
3379
2853
  progressWatches,
3380
2854
  progressWatchEvents,
3381
2855
  deadlinePressure: {
@@ -3394,7 +2868,7 @@ export class GatewayStore {
3394
2868
  connectors: 0,
3395
2869
  availablePeers: 0,
3396
2870
  routes: 0,
3397
- pairs: 0,
2871
+ consentEdges: 0,
3398
2872
  progressWatches: 0,
3399
2873
  progressWatchEvents: 0,
3400
2874
  messages: 0,
@@ -3517,7 +2991,7 @@ export class GatewayStore {
3517
2991
  throw new BridgeError("CODEX_SUCCESSION_BINDING_COLLISION", "The new Codex identity collides with another persisted route.");
3518
2992
  }
3519
2993
  }
3520
- replaceCodexRouteForSuccession(state, route, identity, now, routeState, compatibility) {
2994
+ replaceCodexRouteForSuccession(state, route, identity, now, routeState) {
3521
2995
  const oldAlias = route.alias;
3522
2996
  const affectedAliases = new Set([oldAlias, identity.alias]);
3523
2997
  this.settleProgressWatchesForAliases(state, affectedAliases, now);
@@ -3526,7 +3000,6 @@ export class GatewayStore {
3526
3000
  route.registrationMode = "explicit_opt_in";
3527
3001
  route.enabled = true;
3528
3002
  route.state = routeState;
3529
- route.compatibility = compatibility;
3530
3003
  route.registeredAt = now.toISOString();
3531
3004
  route.updatedAt = now.toISOString();
3532
3005
  route.queueDepth = 0;
@@ -3545,7 +3018,7 @@ export class GatewayStore {
3545
3018
  record.targetAlias !== identity.alias);
3546
3019
  state.rateBuckets = state.rateBuckets.filter((bucket) => bucket.sourceAlias !== oldAlias &&
3547
3020
  bucket.sourceAlias !== identity.alias);
3548
- removePairsForAliases(state, affectedAliases);
3021
+ removeConsentEdgesForAliases(state, affectedAliases);
3549
3022
  }
3550
3023
  assertAllowedIdentity(identity) {
3551
3024
  if (!isPrivateEndpointIdentity(identity)) {
@@ -3555,12 +3028,11 @@ export class GatewayStore {
3555
3028
  throw new BridgeError("GATEWAY_HOST_NOT_ALLOWED", "The route host is not in the fixed gateway allowlist.");
3556
3029
  }
3557
3030
  }
3558
- requireCompatibleObservedEndpoint(state, binding) {
3031
+ requireObservedEndpoint(state, binding) {
3559
3032
  const connector = state.connectors.find((candidate) => sameEndpoint(candidate, binding));
3560
3033
  if (!connector ||
3561
- !["healthy", "degraded"].includes(connector.health) ||
3562
- connector.compatibility !== "compatible") {
3563
- throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The exact compatible endpoint generation must be observed before Codex succession can publish or activate it.");
3034
+ !["healthy", "degraded"].includes(connector.health)) {
3035
+ throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The exact live endpoint generation must be observed before Codex succession can publish or activate it.");
3564
3036
  }
3565
3037
  }
3566
3038
  validateRouteInput(input) {
@@ -3574,11 +3046,15 @@ export class GatewayStore {
3574
3046
  if (aliasHost !== input.binding.hostId) {
3575
3047
  throw new BridgeError("ROUTE_HOST_MISMATCH", "The public alias host must match the allowlisted private route host.");
3576
3048
  }
3577
- if ((input.binding.provider === "codex" &&
3578
- input.registrationMode !== "explicit_opt_in") ||
3579
- (input.binding.provider === "claude" &&
3580
- input.registrationMode !== "selected_live_peer")) {
3581
- throw new BridgeError("ROUTE_OPT_IN_REQUIRED", "Codex routes require explicit self opt-in and Claude routes require a selected live peer.");
3049
+ const requiredPrefix = gatewayRegistrationIngressPrefixes[input.binding.provider];
3050
+ if (requiredPrefix !== undefined && !input.alias.startsWith(requiredPrefix)) {
3051
+ throw new BridgeError("INVALID_GATEWAY_ALIAS", `The ${input.binding.provider} registration alias must use its required ingress prefix.`);
3052
+ }
3053
+ if ((input.binding.provider === "claude" &&
3054
+ input.registrationMode !== "selected_live_peer") ||
3055
+ (input.binding.provider !== "claude" &&
3056
+ input.registrationMode !== "explicit_opt_in")) {
3057
+ throw new BridgeError("ROUTE_OPT_IN_REQUIRED", "Non-Claude routes require explicit opt-in and Claude routes require a selected live peer.");
3582
3058
  }
3583
3059
  }
3584
3060
  requireOwnedRoute(state, alias, ownerLease) {
@@ -3602,7 +3078,6 @@ export class GatewayStore {
3602
3078
  route.registrationMode !== "explicit_opt_in" ||
3603
3079
  !route.enabled ||
3604
3080
  route.state !== "stale" ||
3605
- route.compatibility !== "expired" ||
3606
3081
  route.queueDepth !== 0 ||
3607
3082
  state.queue.some((item) => item.sourceAlias === route.alias || item.targetAlias === route.alias) ||
3608
3083
  state.inFlight.some((item) => item.sourceAlias === route.alias || item.targetAlias === route.alias)) {
@@ -3631,52 +3106,46 @@ export class GatewayStore {
3631
3106
  const route = state.routes.find((candidate) => candidate.alias === alias);
3632
3107
  if (!route ||
3633
3108
  !route.enabled ||
3634
- !["idle", "busy", "awaiting_approval"].includes(route.state) ||
3635
- route.compatibility !== "compatible") {
3109
+ !["idle", "busy", "awaiting_approval"].includes(route.state)) {
3636
3110
  throw new BridgeError("ROUTE_UNAVAILABLE", "The selected route is not currently enabled and positively observed.", true);
3637
3111
  }
3638
3112
  return route;
3639
3113
  }
3640
- requirePairRoutes(state, input, requireAvailable = true) {
3114
+ requireConsentEdgeRoutes(state, input, requireAvailable = true) {
3641
3115
  if (!isObject(input) ||
3642
- typeof input.claudeAlias !== "string" ||
3643
- typeof input.codexAlias !== "string" ||
3644
- !ALIAS_PATTERN.test(input.claudeAlias) ||
3645
- !ALIAS_PATTERN.test(input.codexAlias) ||
3646
- input.claudeAlias === input.codexAlias) {
3647
- throw new BridgeError("INVALID_GATEWAY_PAIR", "A pair requires distinct normalized Claude and Codex aliases.");
3116
+ !Array.isArray(input.aliases) ||
3117
+ input.aliases.length !== 2 ||
3118
+ input.aliases.some((alias) => typeof alias !== "string" || !ALIAS_PATTERN.test(alias)) ||
3119
+ input.aliases[0] === input.aliases[1]) {
3120
+ throw new BridgeError("INVALID_CONSENT_EDGE", "A consent edge requires two distinct normalized aliases.");
3648
3121
  }
3649
3122
  const resolve = (alias) => {
3650
3123
  if (requireAvailable)
3651
3124
  return this.requireAvailableRoute(state, alias);
3652
3125
  const route = state.routes.find((candidate) => candidate.alias === alias);
3653
3126
  if (route === undefined) {
3654
- throw new BridgeError("PAIR_ROUTE_NOT_FOUND", "The pair references a route that is not registered.");
3127
+ throw new BridgeError("CONSENT_EDGE_ROUTE_NOT_FOUND", "The consent edge references a route that is not registered.");
3655
3128
  }
3656
3129
  return route;
3657
3130
  };
3658
- const claude = resolve(input.claudeAlias);
3659
- const codex = resolve(input.codexAlias);
3660
- if (claude.binding.provider !== "claude" ||
3661
- claude.registrationMode !== "selected_live_peer" ||
3662
- codex.binding.provider !== "codex" ||
3663
- codex.registrationMode !== "explicit_opt_in" ||
3664
- claude.binding.hostId !== codex.binding.hostId) {
3665
- throw new BridgeError("INVALID_GATEWAY_PAIR", "A pair must connect one selected Claude route and one registered Codex route on the same host.");
3666
- }
3667
- return { claude, codex };
3668
- }
3669
- requireExactPair(state, claude, codex) {
3670
- const pair = state.pairs.find((candidate) => candidate.claudeAlias === claude.alias &&
3671
- candidate.codexAlias === codex.alias);
3131
+ const left = resolve(input.aliases[0]);
3132
+ const right = resolve(input.aliases[1]);
3133
+ if (left.binding.provider === right.binding.provider ||
3134
+ left.binding.hostId !== right.binding.hostId) {
3135
+ throw new BridgeError("INVALID_CONSENT_EDGE", "A consent edge must connect distinct providers on the same host.");
3136
+ }
3137
+ return [left, right];
3138
+ }
3139
+ requireExactConsentEdge(state, left, right) {
3140
+ const endpoints = canonicalConsentEndpoints(left, right);
3141
+ const pair = state.consentEdges.find((candidate) => consentEdgeKey(candidate.endpoints) === consentEdgeKey(endpoints));
3672
3142
  if (pair === undefined ||
3673
- pair.claudeOwnerLease !== claude.binding.ownerLease ||
3674
- pair.codexOwnerLease !== codex.binding.ownerLease) {
3143
+ pair.endpoints.some((endpoint, index) => endpoint.ownerLease !== endpoints[index].ownerLease)) {
3675
3144
  throw new BridgeError("SENDER_NOT_PAIRED", "The two exact route authorities do not share a consent edge.");
3676
3145
  }
3677
3146
  return pair;
3678
3147
  }
3679
- validateTransientNativeClaudePeer(state, peer, codexRoute) {
3148
+ validateTransientNativeClaudePeer(state, peer, route) {
3680
3149
  if (!isObject(peer) ||
3681
3150
  !hasOnlyKeys(peer, ["alias", "binding"]) ||
3682
3151
  typeof peer.alias !== "string" ||
@@ -3687,17 +3156,16 @@ export class GatewayStore {
3687
3156
  }
3688
3157
  this.assertAllowedIdentity(endpointOf(peer.binding));
3689
3158
  const aliasHost = peer.alias.slice(peer.alias.lastIndexOf("@") + 1);
3690
- if (codexRoute.binding.provider !== "codex" ||
3159
+ if (route.binding.provider === "claude" ||
3691
3160
  aliasHost !== peer.binding.hostId ||
3692
- peer.binding.hostId !== codexRoute.binding.hostId ||
3693
- peer.alias === codexRoute.alias) {
3694
- throw new BridgeError("NATIVE_PEER_SCOPE_MISMATCH", "The transient Claude peer and registered Codex route must have distinct aliases on the same allowlisted host.");
3161
+ peer.binding.hostId !== route.binding.hostId ||
3162
+ peer.alias === route.alias) {
3163
+ throw new BridgeError("NATIVE_PEER_SCOPE_MISMATCH", "The transient Claude peer and registered route must be distinct providers on the same allowlisted host.");
3695
3164
  }
3696
3165
  const connector = state.connectors.find((candidate) => sameEndpoint(candidate, peer.binding));
3697
3166
  if (!connector ||
3698
- !["healthy", "degraded"].includes(connector.health) ||
3699
- connector.compatibility !== "compatible") {
3700
- throw new BridgeError("NATIVE_PEER_ENDPOINT_NOT_OBSERVED", "The transient Claude peer's exact compatible connector generation is not live.", true);
3167
+ !["healthy", "degraded"].includes(connector.health)) {
3168
+ throw new BridgeError("NATIVE_PEER_ENDPOINT_NOT_OBSERVED", "The transient Claude peer's exact connector generation is not live.", true);
3701
3169
  }
3702
3170
  }
3703
3171
  enqueueResolvedMessage(state, now, input, sides) {
@@ -3841,7 +3309,7 @@ export class GatewayStore {
3841
3309
  this.transientBodies.set(messageId, input.body);
3842
3310
  state.accounting.accepted += 1;
3843
3311
  state.accounting.bytesAccepted += bytes;
3844
- const pair = findPairForMessage(state, metadata);
3312
+ const pair = findConsentEdgeForMessage(state, metadata);
3845
3313
  if (pair !== undefined) {
3846
3314
  pair.counters.accepted += 1;
3847
3315
  pair.counters.bytesAccepted += bytes;
@@ -3931,7 +3399,7 @@ export class GatewayStore {
3931
3399
  state.accounting.rejected += 1;
3932
3400
  if (sides.sourceRoute)
3933
3401
  sides.sourceRoute.counters.rejected += 1;
3934
- const pair = findPairForMessage(state, sides);
3402
+ const pair = findConsentEdgeForMessage(state, sides);
3935
3403
  if (pair !== undefined) {
3936
3404
  pair.counters.rejected += 1;
3937
3405
  pair.updatedAt = now.toISOString();
@@ -4035,13 +3503,13 @@ export class GatewayStore {
4035
3503
  throw new BridgeError("INVALID_PROGRESS_WATCH", "The message watch activity is malformed or contradictory.");
4036
3504
  }
4037
3505
  const pair = sides.pair === true
4038
- ? state.pairs.find((candidate) => pairMatchesMessage(candidate, sides))
3506
+ ? state.consentEdges.find((candidate) => consentEdgeMatchesMessage(candidate, sides))
4039
3507
  : undefined;
4040
3508
  const index = state.progressWatches.findIndex((watch) => watch.conversationId === activity.conversationId);
4041
3509
  const existing = state.progressWatches[index];
4042
3510
  if (existing !== undefined) {
4043
3511
  if (pair === undefined ||
4044
- !progressWatchMatchesPair(existing, pair) ||
3512
+ !progressWatchMatchesConsentEdge(existing, pair) ||
4045
3513
  !((sides.sourceAlias === existing.ownerAlias &&
4046
3514
  sides.targetAlias === existing.workerAlias) ||
4047
3515
  (sides.sourceAlias === existing.workerAlias &&
@@ -4080,12 +3548,8 @@ export class GatewayStore {
4080
3548
  conversationId: activity.conversationId,
4081
3549
  ownerAlias: sides.sourceAlias,
4082
3550
  workerAlias: sides.targetAlias,
4083
- ownerLease: sides.sourceAlias === pair.claudeAlias
4084
- ? pair.claudeOwnerLease
4085
- : pair.codexOwnerLease,
4086
- workerLease: sides.targetAlias === pair.claudeAlias
4087
- ? pair.claudeOwnerLease
4088
- : pair.codexOwnerLease,
3551
+ ownerLease: pair.endpoints.find(({ alias }) => alias === sides.sourceAlias).ownerLease,
3552
+ workerLease: pair.endpoints.find(({ alias }) => alias === sides.targetAlias).ownerLease,
4089
3553
  idleMs: activity.openIdleMs,
4090
3554
  at: now.getTime(),
4091
3555
  });
@@ -4121,7 +3585,7 @@ export class GatewayStore {
4121
3585
  });
4122
3586
  }
4123
3587
  installProgressWatch(state, watch, pair, now) {
4124
- const replacedIndex = state.progressWatches.findIndex((candidate) => progressWatchMatchesPair(candidate, pair));
3588
+ const replacedIndex = state.progressWatches.findIndex((candidate) => progressWatchMatchesConsentEdge(candidate, pair));
4125
3589
  if (replacedIndex < 0 &&
4126
3590
  state.progressWatches.length >=
4127
3591
  (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
@@ -4183,11 +3647,11 @@ export class GatewayStore {
4183
3647
  this.appendProgressWatchEvents(state, events);
4184
3648
  state.progressWatches = retained;
4185
3649
  }
4186
- settleProgressWatchesForPair(state, pair, now) {
3650
+ settleProgressWatchesForConsentEdge(state, pair, now) {
4187
3651
  const retained = [];
4188
3652
  const events = [];
4189
3653
  for (const watch of state.progressWatches) {
4190
- if (!progressWatchMatchesPair(watch, pair)) {
3654
+ if (!progressWatchMatchesConsentEdge(watch, pair)) {
4191
3655
  retained.push(watch);
4192
3656
  continue;
4193
3657
  }
@@ -4203,7 +3667,7 @@ export class GatewayStore {
4203
3667
  finishMetadata(state, metadata, deliveryState, now, safeErrorCode) {
4204
3668
  const counterKey = deliveryState;
4205
3669
  state.accounting[counterKey] += 1;
4206
- const pair = findPairForMessage(state, metadata);
3670
+ const pair = findConsentEdgeForMessage(state, metadata);
4207
3671
  if (pair !== undefined) {
4208
3672
  pair.counters[counterKey] += 1;
4209
3673
  pair.updatedAt = now.toISOString();
@@ -4263,12 +3727,12 @@ export class GatewayStore {
4263
3727
  }
4264
3728
  return byMessageId;
4265
3729
  }
4266
- validateAffectedPairInFlightSettlements(state, pair, requested) {
3730
+ validateAffectedConsentEdgeInFlightSettlements(state, pair, requested) {
4267
3731
  if (requested.length > this.config.limits.maxInFlightMessages) {
4268
3732
  throw new BridgeError("INVALID_PAIR_TERMINATION_PLAN", "Unpairing requires one normalized terminal settlement per affected in-flight message.");
4269
3733
  }
4270
3734
  const affectedIds = new Set(state.inFlight
4271
- .filter((item) => pairMatchesMessage(pair, item))
3735
+ .filter((item) => consentEdgeMatchesMessage(pair, item))
4272
3736
  .map((item) => item.messageId));
4273
3737
  const byMessageId = new Map();
4274
3738
  for (const settlement of requested) {
@@ -4321,10 +3785,10 @@ export class GatewayStore {
4321
3785
  }
4322
3786
  return settlements;
4323
3787
  }
4324
- terminateAffectedPairMessages(state, pair, now, safeErrorCode, inFlightSettlements) {
3788
+ terminateAffectedConsentEdgeMessages(state, pair, now, safeErrorCode, inFlightSettlements) {
4325
3789
  const settlements = [];
4326
- const queued = state.queue.filter((item) => pairMatchesMessage(pair, item));
4327
- state.queue = state.queue.filter((item) => !pairMatchesMessage(pair, item));
3790
+ const queued = state.queue.filter((item) => consentEdgeMatchesMessage(pair, item));
3791
+ state.queue = state.queue.filter((item) => !consentEdgeMatchesMessage(pair, item));
4328
3792
  for (const item of queued) {
4329
3793
  this.transientBodies.delete(item.messageId);
4330
3794
  state.accounting.queuedBytes -= item.bytes;
@@ -4334,8 +3798,8 @@ export class GatewayStore {
4334
3798
  const deadlineReached = Date.parse(item.deadlineAt) <= now.getTime();
4335
3799
  settlements.push(this.finishMetadata(state, item, deadlineReached ? "expired" : "abandoned", now, deadlineReached ? "MESSAGE_EXPIRED" : safeErrorCode));
4336
3800
  }
4337
- const inFlight = state.inFlight.filter((item) => pairMatchesMessage(pair, item));
4338
- state.inFlight = state.inFlight.filter((item) => !pairMatchesMessage(pair, item));
3801
+ const inFlight = state.inFlight.filter((item) => consentEdgeMatchesMessage(pair, item));
3802
+ state.inFlight = state.inFlight.filter((item) => !consentEdgeMatchesMessage(pair, item));
4339
3803
  for (const item of inFlight) {
4340
3804
  const requested = inFlightSettlements.get(item.messageId);
4341
3805
  if (requested === undefined) {
@@ -4372,13 +3836,11 @@ export class GatewayStore {
4372
3836
  }
4373
3837
  for (const connector of state.connectors) {
4374
3838
  connector.health = "offline";
4375
- connector.compatibility = "expired";
4376
3839
  connector.updatedAt = now.toISOString();
4377
3840
  connector.safeErrorCode = "REOBSERVATION_REQUIRED";
4378
3841
  }
4379
3842
  for (const route of state.routes) {
4380
3843
  route.state = route.enabled ? "stale" : "disabled";
4381
- route.compatibility = "expired";
4382
3844
  route.updatedAt = now.toISOString();
4383
3845
  route.safeErrorCode = "REOBSERVATION_REQUIRED";
4384
3846
  route.queueDepth = 0;
@@ -4424,7 +3886,7 @@ export class GatewayStore {
4424
3886
  const route = this.requireAnyJournalRoute(state, journal);
4425
3887
  this.assertSuccessionLedgerEmpty(state, route.alias);
4426
3888
  this.assertNewSuccessionIdentityAvailable(state, route, journal.new);
4427
- this.replaceCodexRouteForSuccession(state, route, journal.new, now, "stale", "expired");
3889
+ this.replaceCodexRouteForSuccession(state, route, journal.new, now, "stale");
4428
3890
  state.codexSuccession = {
4429
3891
  ...journal,
4430
3892
  stage: "recovery_forbidden",
@@ -4650,7 +4112,7 @@ export class GatewayStore {
4650
4112
  // Never remove a lock that cannot be proven to belong to this instance.
4651
4113
  }
4652
4114
  }
4653
- async loadStateFile(now) {
4115
+ async loadStateFile(_now) {
4654
4116
  let body;
4655
4117
  try {
4656
4118
  body = await this.readPrivateFile(this.stateFilePath, GATEWAY_MAX_STATE_FILE_BYTES);
@@ -4668,23 +4130,15 @@ export class GatewayStore {
4668
4130
  catch {
4669
4131
  throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state is not valid JSON.");
4670
4132
  }
4671
- parsed = migratePreUnconfirmedCounters(parsed);
4672
- parsed = migratePreSuccessionJournal(parsed);
4673
- parsed = migratePrePairGraph(parsed);
4674
- parsed = migratePreProgressWatches(parsed);
4675
- parsed = migratePreCompatibilityAttestations(parsed);
4676
- parsed = migratePreCodexEndpointRefreshJournal(parsed);
4677
- parsed = migratePreCodexOrphanRemovalJournal(parsed);
4678
- parsed = migrateLegacyHopCounts(parsed);
4679
- parsed = migrateLegacyCompatibilityCertifications(parsed);
4680
- parsed = migrateV14ProgressWatchState(parsed, now.toISOString());
4681
4133
  if (!isPersistedState(parsed)) {
4682
4134
  throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict schema validation.");
4683
4135
  }
4684
4136
  if (parsed.routes.some((route) => !this.config.allowedHosts.includes(route.binding.hostId)) ||
4685
4137
  parsed.connectors.some((connector) => !this.config.allowedHosts.includes(connector.hostId)) ||
4686
4138
  parsed.routes.length > this.config.limits.maxRoutes ||
4687
- parsed.connectors.length > this.config.allowedHosts.length * 2 ||
4139
+ parsed.connectors.length >
4140
+ this.config.allowedHosts.length * gatewayProviders.length ||
4141
+ parsed.consentEdges.length > this.config.limits.maxConsentEdges ||
4688
4142
  parsed.rateBuckets.length > this.config.limits.maxRoutes ||
4689
4143
  parsed.progressWatches.length >
4690
4144
  (this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY) ||