Skip to content

feat(deploy): cluster-wide stage/activate barrier and trusted peer phase operation - #2301

Closed
dawsontoth wants to merge 1 commit into
claude/deploy-component-two-phase-94969afrom
claude/deploy-peer-protocol-pr-b
Closed

feat(deploy): cluster-wide stage/activate barrier and trusted peer phase operation#2301
dawsontoth wants to merge 1 commit into
claude/deploy-component-two-phase-94969afrom
claude/deploy-peer-protocol-pr-b

Conversation

@dawsontoth

Copy link
Copy Markdown
Contributor

Draft. Stacked on #1849 — merge that first. The diff here is exactly the cluster coordination protocol, split out of #1849 so it can be reviewed on its own and land with real multi-node verification.

Why this is a separate PR

#1849 originally carried both halves: the per-node staged deploy, and the cluster-wide protocol that coordinates it. Across four review rounds every escaped defect traced to the second half, and twice a unit test passed against genuinely broken behavior — once because replicateOperation was stubbed, once because a payload was present but not replayable. That is not a coincidence: the unit harness mocks replication and enters the authorization bypass directly, so it cannot observe a peer at all.

So the halves were split. #1849 keeps everything a single node can prove. This PR carries the part that needs a cluster to prove, and should not merge on unit evidence alone.

What this adds on top of #1849

  • component_deploy_phase — a trusted peer-only operation carrying the phase and deployment id. Authorization travels in AsyncLocalStorage rather than on the request, so it is unreachable over HTTP with ordinary credentials, and an older peer rejects an unknown operation instead of misreading a phase marker as a one-shot deploy.
  • The cluster-wide barrier — the origin stages everywhere and waits for every node to report a good stage before any node activates. A node that cannot fetch or npm install fails during staging while the live component is untouched on every node.
  • The separated public phasesactivate: false returns a staged deployment_id; deployment_id activates it later; two_phase: false forces single-phase. Plus the harper stage / harper activate CLI verbs and the capability probe that stops a staged request reaching a server that would silently deploy it live.
  • The deployment row as the peer channel — the immutable activation specification, the payload blob peers read their bytes from, and staged-row retention.
  • Origin-owned row semantics — peers claim with persist: false so the replicated row has exactly one writer; their crash evidence is local activation artifacts, not the row. Without this, a peer's late activating could land after the origin wrote success and park a converged deploy in a non-terminal status forever.

What must happen before this leaves draft

  1. The three-node harper-pro suite has to run against this exact core revision. There is no in-repo end-to-end coverage of the trusted dispatch path — replication serialization, peer authorization context, peer-result wiring, and persist: false crash recovery are all unverified here. This is the whole reason for the split.
  2. Two-phase deploy: concurrent activations from different origins can leave the cluster on different versions with both reporting success #2294 — nothing orders two concurrently-originated deploys, so node A can end on D2 and node B on D1 with both reporting success. The barrier orders stage-before-activate, not deploy-against-deploy. Needs a cluster-wide per-project order (leader serialization, or a monotonic activation epoch checked under the component lock).
  3. Two-phase deploy: package deploys are resolved per node, so the stage barrier cannot guarantee every node staged the same bytes #2295 — a package: deploy resolves per node, so the barrier guarantees "everyone staged something", not "everyone staged the same bytes". A moving latest, a semver range, or a git branch can resolve differently per node while every stage reports success.

Review history

The code here has been through several cross-model rounds while it lived on #1849, and the fixes are in its commit history: revert compensation keyed on commit attempt rather than success, activation evidence classified under the component lock, the origin-owns-the-row change, retention protecting anything newer than the returning request, and the one-shot payload actually being replayable. Those rounds are also what surfaced the coverage gap above — they kept finding real defects that the tests could not.

…r phase operation

Restores the peer coordination protocol that #1849 carried before it was split, so
it can be reviewed on its own and land with real multi-node verification. This is
the half unit tests cannot validate: the harness mocks `replicateOperation` and
enters the authorization bypass directly, so it cannot observe a peer at all.

On top of #1849's per-node staged deploy, this adds:

- `component_deploy_phase`, a trusted peer-only operation carrying the phase and
  deployment id. Authorization travels in AsyncLocalStorage rather than on the
  request, so it is unreachable over HTTP with ordinary credentials, and an older
  peer rejects an unknown operation instead of misreading a phase marker as a
  one-shot deploy.
- The cluster-wide barrier: the origin stages everywhere and waits for every node
  to report a good stage before any node activates. A node that cannot fetch or
  install fails during staging while the live component is untouched everywhere.
- The separated public phases — `activate: false` returns a staged deployment_id,
  `deployment_id` activates it later, `two_phase: false` forces single-phase — plus
  the `harper stage` / `harper activate` CLI verbs and the capability probe that
  stops a staged request reaching a server that would silently deploy it live.
- The deployment row as the peer channel: the immutable activation specification,
  the payload blob peers read their bytes from, and staged-row retention.
- Origin-owned row semantics: peers claim with `persist: false` so the replicated
  row has exactly one writer, with local activation artifacts as their crash
  evidence.

Known open work, carried from the review rounds on #1849 rather than hidden:

- No in-repo end-to-end coverage of the trusted dispatch path. This is the gap
  that motivated the split; the three-node harper-pro suite has to run against
  this revision before it merges.
- #2294 — nothing orders two concurrently-originated deploys, so both can report
  success with different versions live. The barrier orders stage-before-activate,
  not deploy-against-deploy.
- #2295 — a `package:` deploy resolves per node, so the barrier guarantees
  "everyone staged something", not "everyone staged the same bytes".

Stacked on #1849; the diff here is exactly the coordination protocol.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a two-phase component deployment process (stage then activate) to ensure cluster-wide deployments are all-or-nothing. It introduces the component_deploy_phase operation for peer replication, updates the CLI to support stage and activate verbs, adds capability checks to prevent sending staged deploys to older servers, and includes comprehensive unit and integration tests. Feedback on the tests suggests using optional chaining (?.) when asserting on nested properties of parsed JSON manifests or lockfiles to prevent unhandled TypeErrors during future structure changes.

Comment on lines +892 to +893
const applicationLock = JSON.parse(await fs.readFile(path.join(configRoot, 'harper-application-lock.json')));
assert.strictEqual(applicationLock.applications[project], undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When asserting on nested properties of parsed JSON manifests or lockfiles (such as harper-application-lock.json), use optional chaining (?.) to ensure that future structure changes lead to a focused assertion failure rather than an unhandled TypeError.

Suggested change
const applicationLock = JSON.parse(await fs.readFile(path.join(configRoot, 'harper-application-lock.json')));
assert.strictEqual(applicationLock.applications[project], undefined);
const applicationLock = JSON.parse(await fs.readFile(path.join(configRoot, 'harper-application-lock.json')));
assert.strictEqual(applicationLock?.applications?.[project], undefined);
References
  1. When asserting on nested properties of parsed JSON manifests (such as package.json or lockfiles) in tests, use optional chaining (?.) to ensure that future structure changes lead to a focused assertion failure rather than an unhandled TypeError.


await operations.deployComponent({ project, deployment_id: staged.deployment_id });

assert.strictEqual(readConfigFile()[project].package, packageIdentifier);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When asserting on nested properties of parsed JSON manifests (such as harper-config.yaml read via readConfigFile()), use optional chaining (?.) to prevent unhandled TypeErrors if the project entry is missing.

Suggested change
assert.strictEqual(readConfigFile()[project].package, packageIdentifier);
assert.strictEqual(readConfigFile()?.[project]?.package, packageIdentifier);
References
  1. When asserting on nested properties of parsed JSON manifests (such as package.json or lockfiles) in tests, use optional chaining (?.) to ensure that future structure changes lead to a focused assertion failure rather than an unhandled TypeError.

Comment thread bin/cliOperations.ts
Comment on lines +961 to +963
const requestsStagedDeploy =
req._cliVerb !== undefined || req.activate === false || req.deployment_id !== undefined || req.two_phase === true;
if (target && requestsStagedDeploy && !(await targetSupportsStagedDeploy(options))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staged-deploy capability probe also gates harper revert

What: requestsStagedDeploy is true whenever req._cliVerb !== undefined (line 961-962). _cliVerb is set for all three deploy-family CLI verbs, including revert (OP_VERB_PROPS.revert = { operation: 'revert_component', _cliVerb: 'revert' }, line ~44). So harper revert against a remote target now requires the target to advertise componentDeployTwoPhase capability via registration_info, even though revert_component is a pre-existing, unrelated operation that doesn't depend on two-phase deploy support at all.

Why it matters: Against any remote target that supports revert_component but hasn't yet advertised the new componentDeployTwoPhase capability (i.e. every currently-deployed Harper server, or any node mid-rolling-upgrade), harper revert will now fail with "Target Harper does not advertise staged-deploy support" — a false rejection of an operation the probe was never meant to cover. This contradicts the PR's own comment on the revert entry in OP_VERB_PROPS ("_cliVerb here only drives the missing-target guard below") and the asymmetric, correctly-scoped precedent two lines below at line 974 (req.operation === 'deploy_component' && ... for the streaming-deploy probe).

Suggested fix: Scope requestsStagedDeploy to the staged-deploy verbs only, e.g. drop the bare req._cliVerb !== undefined and instead check req._cliVerb === 'stage' || req._cliVerb === 'activate' (or gate on req.operation === 'deploy_component' alongside the existing activate/deployment_id/two_phase checks), so revert_component requests never trigger the probe.

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Found one blocker (inline): the staged-deploy capability probe in cliOperations.ts also gates harper revert, which is unrelated to two-phase deploy support and would falsely fail against targets that don't yet advertise the new capability.

dawsontoth added a commit that referenced this pull request Aug 25, 2026
Review of the shrunk PR found the leftovers, which is what a review of a large
deletion is for.

The handler still branched on `activate`, `two_phase` and `deployment_id` after
the validator dropped them — and the schema allows unknown keys, so a caller
still sending the never-released staged contract would have had `activate: false`
silently ignored and received a full deploy instead. Those branches are gone and
the fields are now `forbidden()`, naming #2301, so they fail fast rather than
doing the opposite of what was asked. The first test to hit that was one of ours
that had been passing `activate: false` as a no-op.

`applicationConfigFromActivationSpec` dereferenced `spec.package` without a null
guard, and startup reconciliation feeds it `row.activation_spec` from rows it did
not write — a row from before the spec was recorded, or a hand-edited one. That
threw during boot and left the component failed closed on every restart with no
recovery but repairing the row by hand. A missing spec now degrades to a config
no-op.

`drop_component` settled only `staged`/`activating` rows, but a deploy interrupted
mid-stage rests at `staging`, and nothing else settles it: payload retention only
reclaims terminal rows, so the tarball stayed pinned and `get_deployment` never
converged for an already-dropped component.

Docs and comments: I had deleted the caveat that the pre-swap load check is a
no-op on the main thread — which is where the operations API runs deploys — so
DESIGN.md was claiming a guarantee the code does not provide on the origin. It is
restored and scoped explicitly. Also removed the activate-by-id consequence
paragraph, and corrected the comments still describing two-phase orchestration,
the `component_deploy_phase` fan-out, and `harper activate`.

`claimStagedDeployment` and `settleStagedRows` have no caller in this tree now.
They are marked RESERVED FOR #2301 rather than deleted: that PR is stacked
directly on this one and is their only consumer, so deleting here would just mean
re-adding there. The comments say plainly that no resting `staged` row producer
exists in-tree until it lands.

Reported by cursor-composer pre-push.
dawsontoth added a commit that referenced this pull request Aug 25, 2026
…the split took

`claimStagedDeployment`, `settleStagedRows`, and `expireOldStagedDeployments` have
no caller in this tree — they exist only for the stacked coordination PR, and were
carried here behind RESERVED comments to save re-adding them there. That is exactly
backwards for a PR whose point was to shrink to what this tree can verify: dead code
reviewed here is dead code nobody can exercise here. They land with their caller,
along with the ten unit tests that were their only consumer.

Removing the staged-deploy capability probe also deleted the whole
`deploy_component cross-version compatibility` block, which covered more than the
probe: the pre-5.1 package-JSON downgrade, the pre-5.1 directory/CBOR downgrade,
the 5.1+ streaming path, and a failed version probe assuming modern. Those branches
are still live in `cliOperations`, so a request-format change could have silently
broken deploying to an older Harper with nothing to catch it. Restored, and checked
against a mutation that disables the downgrade.

DESIGN.md claimed two guarantees this branch does not provide: that every staged
deploy validates the candidate loads (it is a no-op on the main thread, which is
where the operations API deploys — the caveat was already documented 300 lines
later, contradicting it), and that automatic payload pruning appends a
`payload_dropped` event to the rows it prunes (it deliberately does not — `event_log`
is append-only and a read-copy-write would lose a concurrent writer's entry). Both
now describe what actually happens.

Also drops comments that narrated code or restated a contract validation now
forbids.

Reported by codex pre-push.
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Reference only — this PR will not merge, and will not be folded into #1849

We're re-planning this work as a sequence of small, independently mergeable PRs. #2315 is the coordination issue.

The original plan was to merge this branch back into #1849 so there'd be one reference. That isn't possible any more, and it shouldn't be forced:

Merging would mean hand-reconciling a design we've decided not to ship. So both branches stay as separate references instead, each internally coherent:

Where this design went

Step 5 in #2315 supersedes the barrier. main already does build-once-replicate-the-artifact for payload deploys — the origin tees the payload through a hash-and-size tap into the deployment row's payload_blob, and peers install from the replicated row. Extending that to package: and ref: deploys (#2295) gets bit-identical bytes across the cluster without a barrier at all, which is the goal this PR was reaching for by a heavier route.

What the barrier uniquely solved — ordering two concurrently-originated deploys — remains open as #2294, and is the one piece that genuinely needs multi-node test infrastructure we don't have. That's why it's tracked as an issue rather than queued as a PR.

The code here stays available to lift from; it went through several rounds of cross-model review.

Reviewers: no action needed. Follow #2315.

@dawsontoth

Copy link
Copy Markdown
Contributor Author

Closing as reference-only — see #2315 for the sequence of small PRs that replaces this work.

The branch is not deleted: its diff and history stay browsable here, and the individual steps in #2315 will lift already-reviewed code from it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant