rollbridge 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -240,9 +240,11 @@ later deploys continue to honor the original drain boundary.
240
240
 
241
241
  There is one explicit compatibility boundary: the first upgrade from a genuine
242
242
  pre-owner-replacement Rollbridge guardian and daemon cannot share its listeners
243
- on supported Node 20. After authenticating the guardian, exact daemon PID/socket,
244
- runtime authority, and durable owned-process state, `ensure-daemon` performs a
245
- one-time **disruptive** bridge. Existing proxy/control connections may close,
243
+ on supported Node 20. The same boundary applies to a retained transition guardian
244
+ that supports prepare/stage but predates the retired-owner commit command. After
245
+ an explicit capability probe and authentication of the guardian, exact daemon
246
+ PID/socket, runtime authority, and durable owned-process state, `ensure-daemon`
247
+ performs a one-time **disruptive** bridge. Existing proxy/control connections may close,
246
248
  the retained processes keep their exact PIDs under guardian supervision, and
247
249
  `status.ownerTransition` reports `mode: "legacy-first-upgrade"` with
248
250
  `disruptive: true`. The bridge requires the existing config identity unchanged;
@@ -7,3 +7,10 @@
7
7
  - Fail closed when an older retained guardian cannot commit that replacement
8
8
  atomically after the incumbent control socket disappears, preserving the
9
9
  incumbent owner, retained connections, and guardian-managed release processes.
10
+ - Explicitly classify retained guardian replacement capabilities before preparing
11
+ a transaction. Guardians with prepare/stage support but no retired-owner commit
12
+ command use the fully attested one-time disruptive legacy upgrade bridge;
13
+ malformed, stale, or ambiguous protocol responses continue to fail closed. The
14
+ partial guardian's prepared transaction remains the mutation fence through
15
+ candidate reconstruction and boundary revalidation, and failed preparation
16
+ notifies the incumbent to resume paused release drains.
package/docs/cli.md CHANGED
@@ -103,7 +103,8 @@ the candidate binds and validates its listeners, and an authenticated fenced
103
103
  transaction commits guardian authority and the final control socket. A lost
104
104
  control response is accepted only when the guardian confirms the exact committed
105
105
  transaction id. The first authenticated upgrade from a genuine pre-replacement
106
- guardian/daemon is the sole exception: `ensure-daemon` preserves its exact
106
+ guardian/daemon, or from a retained prepare/stage guardian that explicitly lacks
107
+ the retired-owner commit capability, is the sole exception: `ensure-daemon` preserves its exact
107
108
  guardian-owned processes but deliberately retires the old listeners before the
108
109
  Node 20 candidate binds, so existing proxy/control connections may close.
109
110
  Successful status JSON records this as `ownerTransition.disruptive: true` and
package/docs/config.md CHANGED
@@ -151,8 +151,10 @@ config reloads remain unchanged.
151
151
 
152
152
  The first upgrade from an authenticated pre-replacement Rollbridge guardian and
153
153
  daemon uses an explicitly disruptive compatibility bridge because that legacy
154
- owner cannot transfer listeners on Node 20. Rollbridge attests the exact guardian
155
- and daemon processes, sockets, runtime/config authority, and durable process
154
+ owner cannot transfer listeners on Node 20. A retained guardian that supports
155
+ replacement prepare/stage but lacks the retired-owner commit command is explicitly
156
+ classified and uses the same bridge. Rollbridge attests the exact guardian and
157
+ daemon processes, sockets, runtime/config authority, and durable process
156
158
  registrations before retiring the legacy listeners. Managed process PIDs and
157
159
  release state remain supervised, but live proxy/control connections may close.
158
160
  The resulting status includes `ownerTransition: {disruptive: true, mode:
@@ -11,6 +11,9 @@ cannot authenticate and attest an allowed owner transition.
11
11
 
12
12
  **Fix.** With `ownerRecovery`, a genuine pre-owner-replacement Rollbridge daemon
13
13
  and guardian can cross the documented one-time disruptive bridge automatically.
14
+ A retained guardian that implements replacement prepare/stage but lacks the
15
+ retired-owner commit capability uses that same bridge only after the exact
16
+ non-mutating capability signature and legacy process dispatch are confirmed.
14
17
  Its existing proxy/control connections may close; successful status reports
15
18
  `ownerTransition.mode: "legacy-first-upgrade"`. Keep the incumbent config
16
19
  identity unchanged for that first invocation, then apply config/socket changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/daemon.js CHANGED
@@ -29,7 +29,7 @@ const STATE_PERSIST_INTERVAL_MS = 5000
29
29
  * @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
30
30
  * @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
31
31
  * @typedef {{authority: JsonValue, config: import("./config.js").RollbridgeConfig, releaseConfigs?: Record<string, import("./config.js").RollbridgeConfig>, singletonReleaseIds?: Record<string, string>, snapshot: OwnerRecoverySnapshot}} PrivateOwnerState
32
- * @typedef {{boundaryCrossed: boolean, incumbentControl: Awaited<ReturnType<typeof openControlSession>>, incumbentStartTime: string, prepared: {ownerState: JsonValue, replacementId: string}, recoverySnapshot: OwnerRecoverySnapshot}} LegacyOwnerBridge
32
+ * @typedef {{boundaryCrossed: boolean, incumbentControl?: Awaited<ReturnType<typeof openControlSession>>, incumbentStartTime: string, legacyGuardian: GuardianClient, legacyInventory: {key: string, provenance: string}[], legacyPrepared?: {ownerState: JsonValue, replacementId: string}, legacySnapshot: OwnerRecoverySnapshot, prepared: {ownerState: JsonValue, replacementId: string}, recoverySnapshot: OwnerRecoverySnapshot}} LegacyOwnerBridge
33
33
  */
34
34
 
35
35
  export default class RollbridgeDaemon {
@@ -325,29 +325,38 @@ export default class RollbridgeDaemon {
325
325
  configDigest: persisted.recovery.configDigest,
326
326
  runtime: persisted.daemonRuntime ? {...persisted.daemonRuntime} : null
327
327
  }
328
- let legacyBridge
329
- let prepared
328
+ const replacementProtocol = await this.guardian.ownerReplacementProtocol()
329
+ const legacyBridge = replacementProtocol === "legacy"
330
+ ? await this.prepareLegacyOwnerReplacement({persisted, persistedAuthority})
331
+ : undefined
332
+ const prepared = legacyBridge?.prepared ?? await this.guardian.prepareOwnerReplacement(persistedAuthority, this.ownerAuthority())
333
+ let preparedStatus
334
+ let reservedProcessKey
335
+ let transfer
330
336
 
331
337
  try {
332
- prepared = await this.guardian.prepareOwnerReplacement(persistedAuthority, this.ownerAuthority())
338
+ preparedStatus = await this.guardian.replacementStatus()
339
+ transfer = /** @type {PrivateOwnerState} */ (prepared.ownerState)
340
+ if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
341
+ const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
342
+
343
+ reservedProcessKey = legacyBridge ? undefined : reconstructableOwnerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
344
+ .find((key) => registeredProcesses.has(key))
345
+ if (reservedProcessKey) this.guardian.reserveProcessRecovery(reservedProcessKey, /** @type {string} */ (registeredProcesses.get(reservedProcessKey)))
346
+ await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
333
347
  } catch (error) {
334
- legacyBridge = await this.prepareLegacyOwnerReplacement({
335
- error: error instanceof Error ? error : String(error),
336
- persisted,
337
- persistedAuthority
338
- })
339
- prepared = legacyBridge.prepared
348
+ legacyBridge?.incumbentControl?.close()
349
+ if (legacyBridge) {
350
+ try {
351
+ await this.abandonLegacyOwnerBridge(legacyBridge)
352
+ } catch (cleanupError) {
353
+ throw new AggregateError([error, cleanupError], "Legacy owner replacement reconstruction failed and its upgrade coordinator could not be abandoned", {cause: cleanupError})
354
+ }
355
+ } else {
356
+ this.guardian.disconnect()
357
+ }
358
+ throw error
340
359
  }
341
- const preparedStatus = await this.guardian.replacementStatus()
342
- const transfer = /** @type {PrivateOwnerState} */ (prepared.ownerState)
343
-
344
- if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
345
- const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
346
- const reservedProcessKey = legacyBridge ? undefined : ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
347
- .find((key) => registeredProcesses.has(key))
348
-
349
- if (reservedProcessKey) this.guardian.reserveProcessRecovery(reservedProcessKey, /** @type {string} */ (registeredProcesses.get(reservedProcessKey)))
350
- await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
351
360
  for (const release of this.releases.values()) release.preserveConfigOnRetirement = true
352
361
  this.logger("owner replacement candidate prepared", {activeReleaseId: this.activeRelease?.releaseId ?? null, replacementId: prepared.replacementId})
353
362
 
@@ -495,12 +504,13 @@ export default class RollbridgeDaemon {
495
504
  }
496
505
  if (legacyBridge && !legacyBridge.boundaryCrossed) {
497
506
  try {
498
- await this.guardian.abandonLegacyUpgrade()
507
+ await this.abandonLegacyOwnerBridge(legacyBridge)
499
508
  } catch (failure) {
500
509
  abortError = failure instanceof Error ? failure : new Error(String(failure))
501
510
  }
502
511
  } else {
503
512
  this.guardian.disconnect()
513
+ legacyBridge?.legacyGuardian.disconnect()
504
514
  }
505
515
  if (committed) {
506
516
  const commitmentError = await committed
@@ -537,13 +547,14 @@ export default class RollbridgeDaemon {
537
547
  if (!releaseId || !connections || typeof connections !== "object" || Array.isArray(connections)) {
538
548
  throw new Error("Incumbent listener sent invalid connection state")
539
549
  }
540
- const release = this.releases.get(releaseId)
541
-
542
- if (!release) throw new Error(`Incumbent listener reported unknown release ${releaseId}`)
543
- release.setTransferredConnections({
550
+ const transferredConnections = {
544
551
  http: requiredNonNegativeInteger(connections.http, "connections.http"),
545
552
  websocket: requiredNonNegativeInteger(connections.websocket, "connections.websocket")
546
- })
553
+ }
554
+ const release = this.releases.get(releaseId)
555
+
556
+ if (!release && (transferredConnections.http > 0 || transferredConnections.websocket > 0)) throw new Error(`Incumbent listener reported unknown release ${releaseId}`)
557
+ if (release) release.setTransferredConnections(transferredConnections)
547
558
  if (this.incumbentListenerControl === session && ![...this.releases.values()].some((candidate) => candidate.hasTransferredConnections())) {
548
559
  this.incumbentListenerControl = undefined
549
560
  session.close()
@@ -551,56 +562,74 @@ export default class RollbridgeDaemon {
551
562
  }
552
563
 
553
564
  /**
554
- * Authenticates and prepares the one-time disruptive bridge for a genuine pre-split guardian.
555
- * @param {{error: Error | string, persisted: OwnerRecoverySnapshot, persistedAuthority: {configDigest: string, runtime: import("./daemon-runtime.js").DaemonRuntimeIdentity | null}}} options - Legacy evidence.
565
+ * Authenticates and prepares the one-time disruptive bridge for an exact legacy guardian protocol.
566
+ * @param {{persisted: OwnerRecoverySnapshot, persistedAuthority: {configDigest: string, runtime: import("./daemon-runtime.js").DaemonRuntimeIdentity | null}}} options - Legacy evidence.
556
567
  * @returns {Promise<LegacyOwnerBridge>} Prepared bridge.
557
568
  */
558
- async prepareLegacyOwnerReplacement({error, persisted, persistedAuthority}) {
559
- const diagnostic = error instanceof Error ? error.message : String(error)
569
+ async prepareLegacyOwnerReplacement({persisted, persistedAuthority}) {
560
570
  const legacyProcessKey = ownerSnapshotProcessKeys(persisted, persisted.singletonReleaseIds)[0]
571
+ const legacyGuardian = this.guardian
572
+ let legacyPrepared
573
+
574
+ if (!legacyProcessKey) throw new Error("Legacy disruptive owner replacement requires an exact guardian-owned process registration in durable state")
575
+ if (!legacyGuardian) throw new Error("Legacy disruptive replacement is missing its authenticated guardian connection")
576
+ try {
577
+ legacyPrepared = await legacyGuardian.prepareOwnerReplacement(persistedAuthority, this.ownerAuthority())
578
+ } catch (error) {
579
+ const diagnostic = error instanceof Error ? error.message : String(error)
580
+
581
+ if (!isLegacyGuardianPrepareDiagnostic(diagnostic)) throw error
582
+ let probeAccepted = false
561
583
 
562
- if (!isLegacyGuardianPrepareDiagnostic(diagnostic)) throw error
563
- if (!legacyProcessKey) throw new Error("Legacy disruptive owner replacement requires an exact guardian-owned process registration in durable state", {cause: error})
564
- if (diagnostic !== "Unknown guardian command: prepare-owner-replacement") {
565
584
  try {
566
- await this.guardian?.request({
585
+ await legacyGuardian.request({
567
586
  authority: persistedAuthority,
568
587
  command: "prepare-owner-replacement",
569
588
  key: legacyProcessKey,
570
589
  nextAuthority: this.ownerAuthority()
571
590
  })
572
- throw new Error("Legacy guardian protocol probe unexpectedly accepted owner replacement")
591
+ probeAccepted = true
573
592
  } catch (probeError) {
574
593
  if (!(probeError instanceof Error) || probeError.message !== "Unknown guardian command: prepare-owner-replacement") {
575
594
  throw new Error("Guardian does not match the authenticated pre-split replacement protocol signature", {cause: probeError})
576
595
  }
577
596
  }
597
+ if (probeAccepted) throw new Error("Legacy guardian protocol probe unexpectedly accepted owner replacement", {cause: error})
578
598
  }
579
- if (persisted.recovery.configDigest !== this.ownerRecoveryConfigDigest()) {
580
- throw new Error("The one-time legacy guardian bridge requires the incumbent config identity unchanged; retry the config change after the bridge establishes split-3 authority")
581
- }
582
- if (!this.legacyIncumbentPid) throw new Error("The authenticated pre-split guardian requires an exact incumbent PID from ensure-daemon for the disruptive bridge")
583
- if (!this.guardianIdentity?.pid) throw new Error("The authenticated pre-split guardian state is missing its exact guardian PID")
584
- const legacyGuardian = this.guardian
585
-
586
- if (!legacyGuardian) throw new Error("Legacy disruptive replacement is missing its authenticated guardian connection")
587
-
588
- await verifyLegacyGuardianProcess(this.guardianIdentity.pid, this.guardianIdentity.socketPath)
589
- const incumbentStartTime = await verifyLegacyDaemonProcess(this.legacyIncumbentPid, this.configPath, persisted.control.path)
590
- const incumbentControl = await openControlSession(persisted.control.path)
599
+ let incumbentControl
591
600
  let upgraded
592
601
 
593
602
  try {
594
- const incumbentStatus = await incumbentControl.request({command: "status"})
603
+ if (persisted.recovery.configDigest !== this.ownerRecoveryConfigDigest()) {
604
+ throw new Error("The one-time legacy guardian bridge requires the incumbent config identity unchanged; retry the config change after the bridge establishes split-3 authority")
605
+ }
606
+ if (!this.legacyIncumbentPid) throw new Error("The authenticated legacy guardian requires an exact incumbent PID from ensure-daemon for the disruptive bridge")
607
+ if (!this.guardianIdentity?.pid) throw new Error("The authenticated legacy guardian state is missing its exact guardian PID")
608
+ await verifyLegacyGuardianProcess(this.guardianIdentity.pid, this.guardianIdentity.socketPath)
609
+ const incumbentStartTime = await verifyLegacyDaemonProcess(this.legacyIncumbentPid, this.configPath, persisted.control.path)
610
+ if (legacyPrepared) assertLegacyPrivateOwnerState(legacyPrepared.ownerState, persisted, persistedAuthority)
611
+ try {
612
+ incumbentControl = await openControlSession(persisted.control.path)
613
+ } catch (error) {
614
+ if (!legacyPrepared || !error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
615
+ }
616
+ if (incumbentControl) {
617
+ const incumbentStatus = await incumbentControl.request({command: "status"})
595
618
 
596
- assertLegacyIncumbentStatus(incumbentStatus, persisted)
597
- const ownerState = {
598
- authority: persistedAuthority,
599
- config: this.config,
600
- releaseConfigs: Object.fromEntries(persisted.releases.map((release) => [release.releaseId, this.config])),
601
- singletonReleaseIds: Object.fromEntries(persisted.singletons.map((singleton) => [singleton.id, persisted.activeReleaseId]).filter((entry) => entry[1] !== null)),
602
- snapshot: persisted
619
+ assertLegacyIncumbentStatus(incumbentStatus, persisted)
603
620
  }
621
+ const ownerState = legacyPrepared
622
+ ? legacyPrepared.ownerState
623
+ : {
624
+ authority: persistedAuthority,
625
+ config: this.config,
626
+ releaseConfigs: Object.fromEntries(persisted.releases.map((release) => [release.releaseId, this.config])),
627
+ singletonReleaseIds: Object.fromEntries(persisted.singletons.map((singleton) => [singleton.id, persisted.activeReleaseId]).filter((entry) => entry[1] !== null)),
628
+ snapshot: persisted
629
+ }
630
+ const legacyInventory = normalizeLegacyGuardianInventory(await legacyGuardian.inventory())
631
+
632
+ assertLegacyGuardianInventoryMembership(legacyInventory, /** @type {PrivateOwnerState} */ (ownerState))
604
633
  const upgradedIdentity = /** @type {{pid?: number, socketPath: string, token: string}} */ ({
605
634
  socketPath: `${this.statePath}.split3-guardian.sock`,
606
635
  token: crypto.randomBytes(32).toString("hex")
@@ -609,7 +638,6 @@ export default class RollbridgeDaemon {
609
638
  await assertPathAbsent(upgradedIdentity.socketPath, "Legacy upgrade guardian socket")
610
639
  upgraded = await legacyGuardian.upgradeLegacyGuardian({ownerState, ...upgradedIdentity})
611
640
  upgradedIdentity.pid = upgraded.pid
612
- legacyGuardian.disconnect()
613
641
  this.guardian = upgraded
614
642
  this.guardianIdentity = upgradedIdentity
615
643
  const prepared = await upgraded.prepareOwnerReplacement(persistedAuthority, this.ownerAuthority())
@@ -623,36 +651,102 @@ export default class RollbridgeDaemon {
623
651
  incumbentPid: this.legacyIncumbentPid,
624
652
  replacementId: prepared.replacementId
625
653
  })
626
- return {boundaryCrossed: false, incumbentControl, incumbentStartTime, prepared, recoverySnapshot}
654
+ return {boundaryCrossed: false, incumbentControl, incumbentStartTime, legacyGuardian, legacyInventory, legacyPrepared, legacySnapshot: persisted, prepared, recoverySnapshot}
627
655
  } catch (upgradeError) {
628
- incumbentControl.close()
629
- if (upgraded) await upgraded.abandonLegacyUpgrade()
656
+ incumbentControl?.close()
657
+ let cleanupError
658
+
659
+ if (upgraded) {
660
+ try {
661
+ await upgraded.abandonLegacyUpgrade()
662
+ } catch (error) {
663
+ cleanupError = error instanceof Error ? error : new Error(String(error))
664
+ }
665
+ }
666
+ legacyGuardian.disconnect()
667
+ if (cleanupError) {
668
+ throw new AggregateError([upgradeError, cleanupError], "Legacy owner replacement validation failed and its upgrade coordinator could not be abandoned", {cause: upgradeError})
669
+ }
630
670
  throw upgradeError
631
671
  }
632
672
  }
633
673
 
674
+ /**
675
+ * Abandons the uncommitted coordinator before releasing the incumbent guardian transaction.
676
+ * @param {LegacyOwnerBridge} bridge - Prepared bridge.
677
+ */
678
+ async abandonLegacyOwnerBridge(bridge) {
679
+ let cleanupError
680
+
681
+ try {
682
+ await this.guardian?.abandonLegacyUpgrade()
683
+ } catch (error) {
684
+ cleanupError = error instanceof Error ? error : new Error(String(error))
685
+ }
686
+ bridge.legacyGuardian.disconnect()
687
+ if (cleanupError) throw cleanupError
688
+ }
689
+
634
690
  /**
635
691
  * Crosses the explicitly disruptive legacy-only boundary after candidate reconstruction.
636
692
  * @param {LegacyOwnerBridge} bridge - Prepared bridge.
637
693
  */
638
694
  async crossLegacyDisruptiveBoundary(bridge) {
639
695
  if (!this.legacyIncumbentPid || !this.statePath) throw new Error("Legacy disruptive boundary is missing its exact incumbent identity")
696
+ if (!this.guardian) throw new Error("Legacy disruptive boundary is missing its authenticated upgraded guardian")
697
+ if (!bridge.legacyGuardian.pid) throw new Error("Legacy disruptive boundary is missing its authenticated retained guardian PID")
698
+ await verifyLegacyGuardianProcess(bridge.legacyGuardian.pid, bridge.legacyGuardian.socketPath)
640
699
  const currentStartTime = await verifyLegacyDaemonProcess(this.legacyIncumbentPid, this.configPath, bridge.recoverySnapshot.control.path)
641
700
 
642
701
  if (currentStartTime !== bridge.incumbentStartTime) throw new Error("Legacy incumbent PID identity changed before the disruptive boundary")
702
+ if (!isDeepStrictEqual(await readState(this.statePath), bridge.legacySnapshot)) {
703
+ throw new Error("Legacy owner recovery state changed before the disruptive boundary")
704
+ }
705
+ if (bridge.incumbentControl) {
706
+ assertLegacyIncumbentStatus(await bridge.incumbentControl.request({command: "status"}), bridge.legacySnapshot)
707
+ }
708
+ const currentInventory = normalizeLegacyGuardianInventory(await bridge.legacyGuardian.inventory())
709
+
710
+ if (!isDeepStrictEqual(currentInventory, bridge.legacyInventory)) {
711
+ throw new Error("Legacy guardian process inventory changed before the disruptive boundary")
712
+ }
713
+ const candidateOwnerState = {
714
+ authority: this.ownerAuthority(),
715
+ config: this.config,
716
+ releaseConfigs: Object.fromEntries([...this.releases].map(([releaseId, release]) => [releaseId, release.config])),
717
+ singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds),
718
+ snapshot: this.status()
719
+ }
720
+
721
+ assertLegacyGuardianInventoryMembership(currentInventory, candidateOwnerState)
722
+ let legacyCommitted
723
+
724
+ if (bridge.legacyPrepared) {
725
+ const status = await bridge.legacyGuardian.replacementStatus()
726
+
727
+ if (!status.ownerClaimed) throw new Error("Legacy guardian incumbent ownership changed before the disruptive boundary")
728
+ legacyCommitted = bridge.legacyGuardian.waitForEvent("replacement-committed")
729
+ const staged = await bridge.legacyGuardian.stageOwnerReplacement(bridge.legacyPrepared.replacementId, candidateOwnerState)
730
+
731
+ if (staged.committed) throw new Error("Legacy guardian committed replacement before the authenticated disruptive boundary")
732
+ }
643
733
  this.logger("legacy owner replacement disruptive boundary", {
644
734
  disruptive: true,
645
735
  incumbentPid: this.legacyIncumbentPid,
646
- reason: "pre-split guardian and daemon lacked atomic replacement protocol"
736
+ reason: "retained guardian and daemon lacked atomic replacement protocol"
647
737
  })
738
+ await this.guardian.beginLegacyOwnerClaim(bridge.prepared.replacementId, bridge.recoverySnapshot.recovery.reconnectGraceMs)
648
739
  process.kill(this.legacyIncumbentPid, "SIGKILL")
649
740
  bridge.boundaryCrossed = true
650
- await bridge.incumbentControl.closed()
741
+ await bridge.incumbentControl?.closed()
742
+ if (legacyCommitted) await legacyCommitted
743
+ bridge.legacyGuardian.disconnect()
744
+ await this.guardian.completeLegacyOwnerClaim(bridge.prepared.replacementId)
651
745
  await writeState(this.statePath, bridge.recoverySnapshot)
652
746
  this.ownerTransition = /** @type {OwnerTransition} */ ({
653
747
  disruptive: true,
654
748
  mode: "legacy-first-upgrade",
655
- reason: "pre-split guardian and daemon lacked atomic replacement protocol"
749
+ reason: "retained guardian and daemon lacked atomic replacement protocol"
656
750
  })
657
751
  }
658
752
 
@@ -2009,7 +2103,7 @@ export function isLegacyGuardianPrepareDiagnostic(diagnostic) {
2009
2103
  }
2010
2104
 
2011
2105
  /**
2012
- * @param {OwnerRecoverySnapshot} snapshot - Durable committed owner snapshot.
2106
+ * @param {DaemonStatus} snapshot - Serialized owner process snapshot.
2013
2107
  * @param {Record<string, string>} [singletonReleaseIds] - Exact singleton owner releases.
2014
2108
  * @returns {string[]} Exact guardian registration keys present in the snapshot.
2015
2109
  */
@@ -2028,6 +2122,61 @@ function ownerSnapshotProcessKeys(snapshot, singletonReleaseIds = {}) {
2028
2122
  return keys
2029
2123
  }
2030
2124
 
2125
+ /**
2126
+ * Selects only committed guardian registrations that restoreOwnerState will reconstruct.
2127
+ * @param {OwnerRecoverySnapshot} snapshot - Serialized owner process snapshot.
2128
+ * @param {Record<string, string>} [singletonReleaseIds] - Exact singleton owner releases.
2129
+ * @returns {string[]} Exact reconstructable guardian registration keys.
2130
+ */
2131
+ function reconstructableOwnerSnapshotProcessKeys(snapshot, singletonReleaseIds = {}) {
2132
+ const singletonOwnerReleaseIds = new Set(Object.values(singletonReleaseIds))
2133
+ const releaseProcessKeys = new Set()
2134
+
2135
+ for (const release of snapshot.releases) {
2136
+ const transitionCandidate = snapshot.generationTransition?.candidateReleaseId === release.releaseId && snapshot.generationTransition.phase !== "committed"
2137
+ const singletonOwner = singletonOwnerReleaseIds.has(release.releaseId)
2138
+
2139
+ if (release.state !== "active" && release.state !== "draining" && !transitionCandidate && !singletonOwner) continue
2140
+ for (const processStatus of release.processes) releaseProcessKeys.add(`release:${release.releaseId}:${processStatus.id}`)
2141
+ }
2142
+ return ownerSnapshotProcessKeys(snapshot, singletonReleaseIds)
2143
+ .filter((key) => !key.startsWith("release:") || releaseProcessKeys.has(key))
2144
+ }
2145
+
2146
+ /**
2147
+ * Canonicalizes the authenticated guardian inventory without relying on map insertion order.
2148
+ * @param {{key: string, provenance: string}[]} inventory - Guardian inventory response.
2149
+ * @returns {{key: string, provenance: string}[]} Exact key/provenance fence.
2150
+ */
2151
+ function normalizeLegacyGuardianInventory(inventory) {
2152
+ const normalized = inventory.map((entry) => {
2153
+ if (!entry || typeof entry.key !== "string" || !entry.key || typeof entry.provenance !== "string" || !entry.provenance) {
2154
+ throw new Error("Legacy guardian returned an invalid process inventory")
2155
+ }
2156
+ return {key: entry.key, provenance: entry.provenance}
2157
+ }).sort((left, right) => left.key.localeCompare(right.key))
2158
+
2159
+ if (new Set(normalized.map(({key}) => key)).size !== normalized.length) {
2160
+ throw new Error("Legacy guardian returned duplicate process registrations")
2161
+ }
2162
+ return normalized
2163
+ }
2164
+
2165
+ /**
2166
+ * Requires every and only the process registrations serialized by committed private owner state.
2167
+ * @param {{key: string, provenance: string}[]} inventory - Canonical guardian inventory.
2168
+ * @param {{snapshot: DaemonStatus, singletonReleaseIds?: Record<string, string>}} ownerState - Authenticated owner process snapshot.
2169
+ */
2170
+ function assertLegacyGuardianInventoryMembership(inventory, ownerState) {
2171
+ if (!ownerState?.snapshot) throw new Error("Legacy guardian owner state is missing its committed process snapshot")
2172
+ const inventoryKeys = inventory.map(({key}) => key)
2173
+ const snapshotKeys = ownerSnapshotProcessKeys(ownerState.snapshot, ownerState.singletonReleaseIds).sort((left, right) => left.localeCompare(right))
2174
+
2175
+ if (!isDeepStrictEqual(inventoryKeys, snapshotKeys)) {
2176
+ throw new Error("Legacy guardian process inventory does not match committed owner state")
2177
+ }
2178
+ }
2179
+
2031
2180
  /**
2032
2181
  * Verifies the exact authenticated guardian process and socket without scanning other PIDs.
2033
2182
  * @param {number} pid - Persisted guardian PID.
@@ -2037,7 +2186,7 @@ async function verifyLegacyGuardianProcess(pid, socketPath) {
2037
2186
  const args = await processArguments(pid, "legacy guardian")
2038
2187
  const script = args.find((argument) => argument.endsWith("process-guardian.js"))
2039
2188
 
2040
- if (!script || !args.includes(socketPath)) throw new Error(`Persisted guardian PID ${pid} does not match the pre-split guardian command and socket`)
2189
+ if (!script || !args.includes(socketPath)) throw new Error(`Persisted guardian PID ${pid} does not match the retained guardian command and socket`)
2041
2190
  await verifyProcessUser(pid, "legacy guardian")
2042
2191
  await verifyUnixSocketOwner(pid, socketPath, "legacy guardian")
2043
2192
  }
@@ -2056,7 +2205,7 @@ async function verifyLegacyDaemonProcess(pid, configPath, socketPath) {
2056
2205
  const configIndex = args.indexOf("--config")
2057
2206
 
2058
2207
  if (daemonIndex < 0 || configIndex < 0 || args[configIndex + 1] !== configPath) {
2059
- throw new Error(`Daemon PID ${pid} does not match the exact pre-split daemon config command`)
2208
+ throw new Error(`Daemon PID ${pid} does not match the exact retained daemon config command`)
2060
2209
  }
2061
2210
  await verifyProcessUser(pid, "legacy daemon")
2062
2211
  await verifyUnixSocketOwner(pid, socketPath, "legacy daemon")
@@ -2132,10 +2281,38 @@ function assertLegacyIncumbentStatus(status, persisted) {
2132
2281
  if (status.application !== persisted.application || !control || typeof control !== "object" || Array.isArray(control) || control.path !== persisted.control.path ||
2133
2282
  !runtime || typeof runtime !== "object" || Array.isArray(runtime) || runtime.digest !== persisted.daemonRuntime?.digest ||
2134
2283
  !ownerRecovery || typeof ownerRecovery !== "object" || Array.isArray(ownerRecovery) || ownerRecovery.configDigest !== persisted.recovery.configDigest) {
2135
- throw new Error("Responsive incumbent does not match the exact persisted pre-split daemon authority")
2284
+ throw new Error("Responsive incumbent does not match the exact persisted retained-daemon authority")
2136
2285
  }
2137
2286
  }
2138
2287
 
2288
+ /**
2289
+ * Validates private committed state returned by a partial transaction guardian.
2290
+ * @param {JsonValue} value - Authenticated guardian owner state.
2291
+ * @param {OwnerRecoverySnapshot} persisted - Durable expected identity.
2292
+ * @param {{configDigest: string, runtime: import("./daemon-runtime.js").DaemonRuntimeIdentity | null}} persistedAuthority - Exact committed authority.
2293
+ */
2294
+ function assertLegacyPrivateOwnerState(value, persisted, persistedAuthority) {
2295
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Partial legacy guardian returned invalid committed owner state")
2296
+ const state = /** @type {Record<string, JsonValue>} */ (value)
2297
+ const config = state.config
2298
+ const snapshot = state.snapshot
2299
+
2300
+ if (!isDeepStrictEqual(state.authority, persistedAuthority) || !config || typeof config !== "object" || Array.isArray(config) ||
2301
+ ownerConfigDigest(/** @type {import("./config.js").RollbridgeConfig} */ (config)) !== persistedAuthority.configDigest ||
2302
+ !snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
2303
+ throw new Error("Partial legacy guardian committed owner state does not match the persisted authority")
2304
+ }
2305
+ const privateSnapshot = /** @type {OwnerRecoverySnapshot} */ (snapshot)
2306
+
2307
+ assertLegacyIncumbentStatus(/** @type {Record<string, JsonValue>} */ (snapshot), persisted)
2308
+ if (!isDeepStrictEqual(
2309
+ ownerSnapshotProcessKeys(privateSnapshot, state.singletonReleaseIds && typeof state.singletonReleaseIds === "object" && !Array.isArray(state.singletonReleaseIds)
2310
+ ? /** @type {Record<string, string>} */ (state.singletonReleaseIds)
2311
+ : {}).sort(),
2312
+ ownerSnapshotProcessKeys(persisted, persisted.singletonReleaseIds).sort()
2313
+ )) throw new Error("Partial legacy guardian committed process membership does not match durable recovery state")
2314
+ }
2315
+
2139
2316
  /**
2140
2317
  * @param {string} candidatePath - Path that must not preexist.
2141
2318
  * @param {string} label - Diagnostic label.
@@ -54,7 +54,7 @@ export default class GuardianClient {
54
54
  }
55
55
 
56
56
  /**
57
- * Starts a current transaction guardian in front of this authenticated pre-split guardian.
57
+ * Starts a current transaction guardian in front of this authenticated legacy guardian.
58
58
  * @param {{ownerState: import("./json.js").JsonValue, socketPath: string, token: string}} options - Upgrade identity and exact committed state.
59
59
  * @returns {Promise<GuardianClient>} Current guardian client backed by the legacy supervisor.
60
60
  */
@@ -189,6 +189,32 @@ export default class GuardianClient {
189
189
  await this.request({command: "publish-owner-state", ownerState})
190
190
  }
191
191
 
192
+ /**
193
+ * Classifies the authenticated guardian's owner-replacement protocol without preparing a transaction.
194
+ * @returns {Promise<"atomic" | "legacy">} Compatible replacement route.
195
+ */
196
+ async ownerReplacementProtocol() {
197
+ try {
198
+ const response = await this.request({command: "owner-replacement-capabilities"})
199
+
200
+ if (!isOwnerReplacementCapabilities(response)) throw new Error("Guardian returned an invalid owner-replacement capability response")
201
+ return "atomic"
202
+ } catch (error) {
203
+ if (!(error instanceof Error) || !isLegacyCapabilityDispatchDiagnostic(error.message)) throw error
204
+ }
205
+
206
+ try {
207
+ await this.request({command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"})
208
+ throw new Error("Guardian unexpectedly accepted the retired-owner capability probe")
209
+ } catch (error) {
210
+ if (!(error instanceof Error)) throw error
211
+ if (error.message === "Owner replacement transaction is not the prepared candidate") return "atomic"
212
+ if (error.message === "Guardian commit-retired-owner-replacement requires a process key" ||
213
+ error.message === "Unknown guardian command: commit-retired-owner-replacement") return "legacy"
214
+ throw new Error("Guardian returned an ambiguous retired-owner capability response", {cause: error})
215
+ }
216
+ }
217
+
192
218
  /**
193
219
  * @param {import("./json.js").JsonValue} authority - Persisted current authority.
194
220
  * @param {import("./json.js").JsonValue} nextAuthority - Requested authority.
@@ -225,6 +251,23 @@ export default class GuardianClient {
225
251
  await this.request({command: "commit-retired-owner-replacement", key, replacementId})
226
252
  }
227
253
 
254
+ /**
255
+ * Begins acquiring the authenticated legacy backend owner channel at the disruptive boundary.
256
+ * @param {string} replacementId - Exact prepared candidate transaction.
257
+ * @param {number} graceMs - Event-driven incumbent disconnect grace.
258
+ */
259
+ async beginLegacyOwnerClaim(replacementId, graceMs) {
260
+ await this.request({command: "begin-legacy-owner-claim", graceMs, replacementId})
261
+ }
262
+
263
+ /**
264
+ * Waits until the upgraded guardian owns its authenticated legacy backend.
265
+ * @param {string} replacementId - Exact prepared candidate transaction.
266
+ */
267
+ async completeLegacyOwnerClaim(replacementId) {
268
+ await this.request({command: "complete-legacy-owner-claim", replacementId})
269
+ }
270
+
228
271
  /** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
229
272
  async finalizeOwnerReplacement(replacementId) {
230
273
  await this.request({command: "finalize-owner-replacement", replacementId})
@@ -496,6 +539,28 @@ function asProcessStatus(value) {
496
539
  return JSON.parse(JSON.stringify(value))
497
540
  }
498
541
 
542
+ /**
543
+ * @param {import("./json.js").JsonValue} value - Capability response.
544
+ * @returns {boolean} Whether the response explicitly guarantees retired-owner commit support.
545
+ */
546
+ function isOwnerReplacementCapabilities(value) {
547
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false
548
+ const response = /** @type {Record<string, import("./json.js").JsonValue>} */ (value)
549
+
550
+ return response.protocol === "owner-replacement" && Number.isInteger(response.version) && Number(response.version) >= 1 &&
551
+ Array.isArray(response.commands) && response.commands.every((command) => typeof command === "string") &&
552
+ response.commands.includes("commit-retired-owner-replacement")
553
+ }
554
+
555
+ /**
556
+ * @param {string} diagnostic - Exact old generic-dispatch response.
557
+ * @returns {boolean} Whether this is the narrow old unknown-command dispatch signature.
558
+ */
559
+ function isLegacyCapabilityDispatchDiagnostic(diagnostic) {
560
+ return diagnostic === "Guardian owner-replacement-capabilities requires a process key" ||
561
+ diagnostic === "Unknown guardian command: owner-replacement-capabilities"
562
+ }
563
+
499
564
  /**
500
565
  * @param {import("./json.js").JsonValue} value - Protocol value.
501
566
  * @returns {import("./managed-process.js").ManagedProcessLog} Process output entry.