Skip to content

Stage a component build with activate:false and deploy it later by deployment_id - #2605

Merged
kriszyp merged 26 commits into
mainfrom
claude/harper-2315-step-6-3f85df
Sep 16, 2026
Merged

kriszyp merged 26 commits into
mainfrom
claude/harper-2315-step-6-3f85df

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

deploy_component gains two optional fields. activate: false builds, installs and certifies a release and then stops, leaving a dormant artifact under .deploy-staging; deployment_id makes that exact artifact live later, resolving, fetching and installing nothing. The work that takes time and can fail happens when the operator chooses, and the cut-over itself is two directory renames. Both calls replicate as ordinary deploys, so every node stages and every node activates — no cluster barrier, and #2301's stage/activate protocol stays retired.

flowchart TB
  REQ["deploy_component"]
  REQ -->|"package / payload"| DEP["mode = deploy<br/>publish root config, build, certify"]
  REQ -->|"package / payload<br/>+ activate: false"| STG["mode = stage (new)<br/>same build, but no file: directory sources<br/>and no links that leave the tree"]
  REQ -->|"deployment_id"| ACT["mode = activate (new)<br/>verify the artifact and its descriptor<br/>nothing is resolved, fetched or installed"]

  STG --> CMP2["write .artifact.json, then .complete"]
  CMP2 --> DORM(["dormant artifact, nothing swapped<br/>.deploy-staging/&lt;deployment_id&gt;/&lt;component&gt;<br/>returns status staged + deployment_id"])
  DORM -.->|"later, surviving restarts"| ACT

  DEP --> JRN
  ACT --> JRN

  subgraph SWAP["swap"]
    JRN["write .activation.json"] --> B1["1. live moved to .deploy-aside"]
    B1 --> PUB1["publish root config (activation only)"]
    PUB1 --> B2["2. candidate renamed to live — commit point"]
  end

  B2 --> DONE["returns status success + deployment_id<br/>(an activation's row also records activated_from)"]

  classDef added stroke:#2da44e,stroke-width:3px
  class STG,ACT,CMP2,DORM,PUB1 added
Loading

Green marks what this PR adds; everything else is the path a deploy already took.

The mode is chosen from the request, validated so an activation carries no build inputs, and the activate path branches around every payload, credential and install step — without that branch a replicated activation looks exactly like a blob-backed deploy and every peer waits out deployment_timeout for a payload its row never had.

A staged artifact outlives an ordinary deploy of the same component, so staging is also how a release becomes revertible: deploy V1, stage V2, deploy V3, and activating V2's id later puts V2 back. What cannot be recovered is a version that was only ever deployed — its artifact is consumed by the swap and the tree it displaced is swept — which is what #2315 step 5 is for.

Every refusal on these paths carries a status code rather than reaching the caller as a 500, through the same ClientError the rest of the deployment operations use: 400 for a component that cannot be staged at all (a file: directory source), and 409 for a conflict with what is on disk — a deployment id already taken, an artifact whose descriptor this build cannot activate, one whose tree stopped being what was certified, an incomplete build, an unsettled verdict, an in-flight journal, or another component's artifact. 404 now means one thing only: nothing on this node answers to that id, so a caller can tell "never existed, or already used" from "present, but not activatable".

This is step 6 of #2315. Step 4 bounded how many unactivated staged builds accumulate; nothing on main produced one, so its integration test had to plant them. This is the producer.

Four things make the delay safe, and each exists only because an immediate deploy did not need it. The artifact directory is named by the public deployment id while the deploy-lifecycle token stays per-invocation, because DeployLifecycle de-duplicates starts by the announced id and releases watcher suppression on the first matching end — two overlapping activations of one artifact sharing that id would count as one owner. A mandatory versioned .artifact.json is written before .complete, carrying the root-config entry the build would have published (explicitly null for a payload deploy), installationIsOpaque, and the admitted isolation intent; an optional record could not tell a payload build from a package build whose record was lost. Claiming an id is exclusive, because a public id can be repeated where a minted UUID could not. Claiming an id publishes ownership as part of the claim rather than at certification, and an unattributed directory is refused, never reclaimed: a build can hold an empty directory for minutes while it resolves and packs, so an empty readdir cannot tell a claim in flight from an abandoned one. And staging owns its bytes: a file: directory source is refused, and so is any symlink whose walk leaves the built tree at any prefix — certification follows no links, and the post-swap relocation repair runs past the commit point and can only warn.

Certification moved out of the swap primitive into prepareApplication, so a delayed activation does not re-walk and re-fsync a node_modules it certified at build time while holding the preparation lock. An activation that fails before it commits returns its artifact to dormant — journal removed, its parent synced, and the published root-config entry taken back — because otherwise the next preparation reads live-plus-candidate-plus-journal as an abandoned activation and deletes the artifact, or rolls a first deploy forward unasked.

For the human reviewer

The planning gate never cleared on one axis, and it is the first thing to rule on.

  1. Mixed-version clusters: detection, not prevention — settled, not open. A stage replicates as an ordinary deploy_component, so a peer running a pre-5.3 build ignores activate: false and deploys the release immediately. The origin reports it afterwards from a staged: true marker each node returns; nothing in core or in harper-pro's replicator carries a peer version or capability, so it cannot refuse in advance. The reviewer's alternative — replicate the stage as a distinct operation an old peer rejects as unknown — was declined by @dawsontoth: Harper clusters are kept at consistent versions, the UI will steer operators away from the mixed case, the docs will state the prerequisite, and adding a second wire path to cover a case that does not occur in practice would encumber the 99.9% path with more code that can break. The planning gate's better-alternative-exists verdict on this axis is recorded as overruled by the owner, not carried as open. Reversing it later is a wire-format change, not an API change: the two request fields stay as they are.

  2. Where the root-config publish sits, and the window that remains. A package artifact's entry is published between B1 (the live tree moved aside) and the commit rename — the only point where the on-disk state recovery would find rolls forward to the certified artifact. Publishing any earlier, journal or no journal, leaves live present and the candidate present with no rollback record, which settlement reads as an activation that never started: it deletes the artifact and the next boot re-resolves that package from the registry instead. Three review rounds landed on this from different angles before it was right, so it is the first thing to check if you disagree. The window that remains is the inverse — a crash after the roll-forward state exists but before the publish leaves the certified artifact live under the previous release's config, because rollForward() publishes nothing. That includes its isolation intent, which is a containment boundary rather than a version string: a component staged to run isolated comes back non-isolated after an ordinary crash, with nothing in the operation reporting it. Closing it needs config to be an effect of the journal, which is step 3 — and that, or gating delayed activation to payload artifacts until step 3 lands, is the open call on @kriszyp's thread.

  3. A component with absolute or escaping internal symlinks cannot be staged. Staging refuses any link whose walk leaves the built tree at any prefix, including absolute ones under node_modules — where npm writes them on Windows for file:/workspace dependencies. The alternative was to trust repairRelocatedDependencyLinks, but that runs past the commit point and only warns (it logs and continues on a failed re-point, and skips a whole subtree on EACCES/EMFILE), so a component could go live holding a link to a path that no longer exists while the operation reported success. The cost is that such a component deploys immediately but cannot be staged until its links are relative; the error says so. The same rule is re-run at activation rather than trusted from .complete, which is a durability marker over the bytes and not a seal on them — a dormant artifact can be edited while it waits.

  4. Staged artifacts share deployment_stagingRetention_maxCount with incidental dormant builds, and only the in-flight request is pinned — so an operator's staged release can be aged out by unrelated activity. One filter in pruneDormantBuilds if you want them to have their own lifetime.

  5. A dormant artifact's contents are not sealed, only its links are re-checked. Activation re-runs the link rule and the load validation before the swap, but validateComponentLoads is a no-op on the main thread and the operations API deploys there, so an artifact whose files were edited while it sat dormant still activates. Detecting that means binding .complete to a manifest or digest of the tree — a whole-tree hash at stage time and again at activation, on a path where that cost is affordable. @kriszyp asked for it; I did the link and load re-checks he named as the minimum and left the manifest out, because what it should cover (every file? the installed tree? the descriptor too?) and how it interacts with repairRelocatedDependencyLinks rewriting links past the commit point are design questions, not a patch.

  6. A deployment id names one artifact only while that artifact exists. Activation consumes it (the swap is a rename) and retention can prune it, after which the id is free to name different bytes. Durable identity after removal would need a tombstone store with its own retention and recovery. Cheap to add later; impossible to remove once operators script against the stronger promise.

  7. A redelivered stage is refused, not answered idempotently. A replayed replication hits the .complete refusal and that peer records a failed deploy for an artifact that is staged and usable. Idempotent success was available and is what the reviewer preferred. I kept the refusal because _deploymentId is operator-forgeable, and answering "success" for bytes that were never staged is the worse failure. Backward-compatible to change later.

  8. staged is a terminal status on its own row, and activation gets a second row linked by activated_from, rather than one row transitioning staged→success. This is what makes delete_deployment_payload and SSE tails behave, but it is the shape operators will script against, and it is hard to reverse after release.

  9. The config undo is best-effort, and for an existing component a failed undo strands config. addConfig does not fsync and there is no publication lock, so a failed activation restores the previous entry without durability guarantees — that is Staged component deploys: land build-aside-then-swap as a sequence of small PRs #2315 step 3's work, deliberately not started here. It takes back only what it published, re-reading from disk first because set_configuration writes the file without refreshing this process's config object, so a change acknowledged while the swap was retrying is not overwritten by a stale snapshot. That narrows the window rather than closing it. An undo that fails keeps the journal only for a first-ever deploy — the one shape recovery can still roll forward from, because the live path is absent. For a component that already had a tree, compensation has just put that tree back and taken the rollback record with it, so the next settle returns the artifact to dormant whatever the journal says: config names the staged release, the previous one is live, and an operator has to resolve it.

  10. A partially successful clustered activation cannot converge by retrying the same id. The origin activates locally before peer fanout, and the swap consumes its staged directory — so if this node activates and a peer fails, retrying the same deployment_id is refused here (nothing is staged under it any more) before the retry ever reaches the peer that still holds the dormant artifact. The operator's route back is a fresh deploy, not a retry. Making the retry converge means either deferring the local swap until peers confirm, which gives up "every node activates the same way it deploys", or answering a deployment_id this node already activated as success, which needs the deployment row to be authoritative for what is live. Neither is small; both are cheaper before release than after. A local run turned up the same gap without a cluster: crash a first-ever activation after its journal lands, and recovery rolls the artifact forward at the next boot — the component comes up live from exactly those bytes, and the operator's retry of that deployment_id then answers 404, because nothing is staged under it any more. Their activation succeeded; what they are told is that it never existed.

Smaller carried items: concurrent deploys of different components can over-admit the global isolated-worker budget (adjudicated pre-existing — the same race beforePrepare already had); activated_from is registered for fresh installs only, matching every other attribute on hdb_deployment; an activation re-walks the artifact tree under the preparation lock, measured by a reviewer at 11,615 directories and 148 links in 380ms warm; every refusal of a deployment_id is a 404, so a caller cannot tell "never existed" from "already used"; and a crash inside the claim window — between the exclusive mkdir and the sidecar write, microseconds with only an lstat between them — burns that deployment id and leaves an empty directory nothing reclaims, since refusing an unattributed directory is what removed the only self-heal that path had. A claim that cannot record who owns it now takes back the directory it made, so only a process death in that window — not an ordinary write failure — burns the id. Closing what remains needs claims serialized on the staging root; a staleness threshold would contradict this code's rule against wall-clock verdicts.

Verification

Unit tree — run per directory rather than as one test:unit:main, because unitTests/components/applicationSpawn.test.js wedges on this machine at a process-group termination test (it wedges identically at the merge base, and the diff touches zero lines of that code), and .mocharc.json sets timeout: 0, so one hang swallows the whole run. Excluding that file: resources 2500 pass, components 1662 pass / 5 fail, server 960 pass / 10 fail, utility 759 pass, config 314 pass / 1 fail, validation 260 pass / 1 fail, bin 247 pass, sqlTranslator 67, upgrade 19.

Every one of those failures is pre-existing. The same three directories were run at the merge base (3ece37294) for comparison: server and config have identical failure sets, and components fails 16 at base against 5 here — every branch failure is also a base failure, none the other way. The named failures are RuntimeModuleTracker, nonInteractiveSpawn git credential scoping, uWS UDS adapter, process-group reclaim ordering, package-specifier resolution and RootConfigWatcher; two are explained outright by this environment — validation's is getDomainSocketPathLengthWarning, which this worktree's own path length trips, and dataLayer's suite fails to load at all without HARPER_STORAGE_ENGINE=lmdb.

Live server, not only tests. A Harper 5.3.0-alpha.1 instance on an isolated data root, driven through the operations API, with three builds of a component serving {"build":"V1|V2|V3"} over REST plus a real component (three as a dependency). A normal deploy returned its deployment_id; activate: false left V1 serving and wrote .artifact.json/.complete/.component beside the tree; the artifact survived a full Harper restart; activating it made V2 live carrying a sentinel file planted in the dormant tree, which is what proves the staged bytes were swapped rather than rebuilt; restart: true on the activation brought the server back serving V3. On the real component the split was 1.7s to stage, 0.1s to activate, with a sentinel written inside the stage-time node_modules/three present in the live tree.

Refusals were exercised the same way: a consumed id, an unknown id, another component's artifact (preserved), restart with activate: false, deployment_id alongside package/payload, a non-boolean activate, a non-UUID id, an id already holding a completed build, an id another component owns, an empty unattributed claim directory (refused and left untouched — the concurrent-claim fix), an artifact whose tree was removed, and five malformed descriptors including one whose isolation intent disagreed with what it publishes. An artifact tampered with after staging — ln -s /etc planted in the dormant tree — was refused and preserved. Retention settled at maxCount + the pinned in-flight build and stayed there across further stages; drop_component reclaimed that component's artifacts and left its neighbour's alone; a stage whose package could not resolve left no residue; delete_deployment_payload on a staged row succeeded and the artifact still activated afterwards, which is the point of certifying at stage time. SSE phases are symmetric for both calls (prepare/start → prepare/done → replicate → staged|success).

Recovery was driven on a real boot by planting crash states and restarting: an interrupted activation returned its artifact to dormant ("Returned the staged build … to dormant after an activation that never moved its live tree aside"), and a described artifact carrying a stale .unsettled with no journal had the verdict cleared ("Cleared a stale unsettled verdict … its activation was already settled"). Both artifacts activated successfully afterwards. Two rounds of this exercise are what produced the status codes above; nothing else it found needed a code change.

Docs: HarperFast/documentation#670.

Complexity: complicated

Review-Coverage: authored=claude; ran=gemini,codex,cursor-grok,cursor-composer; adjudicated=domain; rounds=20; full=8 @ e7e1a91

Human-Review-Need: 4 (decisions: exported-for-testability, caught-failures-reclaim-crashes-do-not, one-404-class-for-missing-and-consumed, isolation-loss-documented-not-closed, artifact-consumed-by-rename, failed-clear-fails-closed, fail-open-peer-confirmation, dangling-internal-links-fail-closed) @ e7e1a91

dawsontoth and others added 8 commits September 14, 2026 10:44
…ployment id

deploy_component gains two optional fields. `activate: false` builds, installs
and certifies a release and then stops, leaving a dormant artifact under
.deploy-staging; `deployment_id` makes that exact artifact live later, resolving,
fetching and installing nothing. Both replicate as ordinary deploys, so every
node stages and every node activates, with no cluster barrier.

Four things make the delay safe, and each exists only because an immediate
deploy did not need it:

- The artifact directory is named by the PUBLIC deployment id, while the
  deploy-lifecycle token stays per-invocation. DeployLifecycle de-duplicates
  starts by the announced id and releases watcher suppression on the first
  matching end, so two overlapping activations of one artifact sharing that id
  would count as one owner.
- .artifact.json is mandatory and versioned, written before .complete so the
  marker vouches for it. It carries the root-config entry the build would have
  published (explicitly null for a payload deploy), installationIsOpaque, and
  the admitted isolation intent — everything an activation cannot re-derive. An
  optional record could not tell a payload build from a package build whose
  record was lost.
- Claiming an id is exclusive. The builder used to tolerate an existing
  deployment directory because a fresh UUID could not collide; a public id can
  be repeated, so a claim rejects another component's directory and any carrying
  .complete, and rebuilds only over an uncertified partial. The named id is also
  pinned through the preparation preamble, or retention would evict the artifact
  the request is about to use.
- Staging owns its bytes: a file: directory source is refused, and so is any
  symlink resolving outside the built tree (bar the node_modules/harper links
  the loader owns). Certification follows no links and relocation repair leaves
  external targets alone, so a link out of the build is a hole only a delay
  makes reachable.

Certification moves out of activateCandidateApplication into prepareApplication:
markCandidateComplete fsyncs the whole candidate tree, and a delayed activation
must not re-walk a node_modules it certified at build time while holding the
preparation lock.

An activation that fails before it commits now returns its artifact to dormant —
the journal is removed and its parent synced after compensation restores the
live tree — because otherwise the next preparation reads live-plus-candidate as
an abandoned activation and deletes the artifact, or (first deploy) rolls it
forward unasked.

Root config for a staged package deploy is recorded with the artifact rather
than published, so a staged release nobody activated cannot leave config naming
it; the activation publishes it in the same order a normal deploy does.
A payload artifact publishes none, so its isolation admission reads effective
configuration rather than the saved bit.

Known and accepted (#2315): a peer running a build without this change ignores
activate:false and deploys immediately. Nothing in core or harper-pro's
replicator carries a peer version, so the origin reports it after the fact —
a staging node answers `staged: true`, and the origin fails the stage naming any
peer that did not confirm.

Refs #2315

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root config: a delayed package activation published its entry before the swap
and never took it back, so a pre-commit failure left config naming a release
that was not live — and installApplications() would resolve and install that
package from scratch at the next boot, over a component whose certified
artifact was sitting beside it. publishRootConfig now returns its own undo,
run when the activation fails while it is still compensable.

Journal: the journal write sat OUTSIDE the pre-commit failure boundary, so a
durable write that landed the file and then failed its parent flush escaped
with the journal in place — and the next settlement destroys a certified
artifact on that evidence, or rolls a first deploy forward after the operator
was told it failed. It is inside the boundary now.

Claim: an EEXIST whose owner could not be determined was rm -rf'd. An
unattributable directory is either empty or a build in flight that has not
written its sidecar yet, and the preparation lock does not span components, so
that second case is another component's candidate. Reclaim now requires either
emptiness or positive ownership.

Isolation: an activation ran the early admission check against CURRENT config,
which refuses the activation whose artifact turns isolation off for a component
whose isolated state is itself the refusal. admitIsolation already re-derives
from the descriptor under the lock; the early call skips activation mode.

Links: the loader-owned exemption matched node_modules/harper at any depth,
but the loader only repairs the component's own top-level link — so a nested
dep/node_modules/harper could point anywhere and still certify.

Peer confirmation: the message asserted an unconfirmed peer was serving the
release. Unreachable and old look identical from the origin, so it now names
both possibilities.

Tests: the two compensation cases were vacuous — chmod 000 on the deployment
directory made verification throw before any journal was written, so the
compensate-then-return-to-dormant path was never exercised. They now block the
aside staging directory, which fails the first step INSIDE the boundary. Adds
coverage for the config undo, the unattributable claim, and the nested
loader-link case, and an install counter to the integration test so "no
rebuild" is proven by the install not running rather than by the tree matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous fixture made the aside staging path a regular file, which makes
the preparation preamble's own lstat succeed, set recoveryPending, and throw
EEXIST from ensureExtractionStagingDirectory — before verification, before
publishRootConfig, before the journal. Every assertion passed vacuously, for
the second time.

Only one injection reaches the window: a read-only components ROOT. Reads still
succeed so verification passes, the deployment directory and the lock root stay
writable so the journal is written and later removed, and the rename whose
parent is the root fails — B1 for an existing component, B2 for a first deploy.
The rejection is asserted to be a permission error and NOT one of verification's
own messages, which is what pins the window; the ~5s each case now takes is the
rename retry budget, the same evidence from another angle.

Also: default `project` from the working directory on a CLI activation, so
`harper deploy deployment_id=<id>` is not the one deploy form that demands an
explicit project; correct the retention-pin comment, which claimed the bound
holds when a pinned build in the eviction tail leaves maxCount+1 until the next
preamble; and trim the comments that narrated history or addressed the reviewer
rather than stating a constraint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chmod is ignored by root and is not how Windows models this, so the read-only
root that makes the rename fail does nothing in those environments — and the
assert.rejects would then fail spuriously on a CI container running as root.
The fixtures now probe whether a read-only directory actually denies this
process a write, and skip when it does not, so an environment that cannot host
the injection says so rather than passing vacuously or going red.

Also trims the integration test's header comment to what the file is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A staged deploy no longer drops its payload automatically. The tarball was
reclaimed in the same terminal write that recorded `staged`, leaving the
artifact as the only copy — and staged artifacts share
deployment_stagingRetention_maxCount with incidental dormant builds, so the
sixth dormant build of that component evicts it. Stage a 50 MB release, stage
five more, and the first was unrecoverable with the row still reporting
`staged`. An operator can still reclaim it deliberately.

Deployment ids are lowercase-only. randomUUID() mints lowercase, but the regex
accepted uppercase, and a case-insensitive filesystem resolves an uppercase id
to the real directory while every string compare against it fails — the
retention pin would not recognise the artifact the request named and could
evict it, immediately at maxCount 0.

Staging rejects an absolute symlink to its own tree. The target is inside the
build, so the ownership walk passed it, but it names .deploy-staging/<id>/…,
which activation renames away; relocation repair only re-points links under
node_modules, so it would dangle in the release the stage certified.

A stage whose peers did not confirm records the row as `staged`, not `failed`.
The origin's artifact is dormant and activatable and the error text tells the
operator to activate it, so a `failed` row kept it out of
list_deployments status=staged.

activated_from is registered in hdb_deployment's attribute list, as rollback_of
already is, so it appears in describe_table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A crash between publishing a package artifact's root-config entry and writing
the activation journal left config naming the staged release, the live tree
still on the previous one, and no journal to settle. The next boot's
installApplications() then re-resolves and installs that package identifier
from scratch rather than activating the certified artifact sitting in
.deploy-staging — the exact substitution this step exists to prevent. The
publish now runs inside activateCandidateApplication, immediately after the
journal is durable, so the same crash is a roll-forward to the certified bytes.
It is still inside the pre-commit boundary, so a failure there is compensated
and the entry is taken back.

readInstalledPackageMetadata moves inside that boundary too: an I/O error while
comparing package metadata would otherwise have skipped the undo.

A stage whose replication hard-fails now records `staged` as well — only the
"did not confirm" path carried that marker, so a peer that failed outright left
the row `failed` for an artifact that is on disk and activatable, and out of
list_deployments status=staged. That error also said "was deployed on the origin
node" for a request that deployed nothing.

The claim's readdir tolerates ENOENT: a failed build under a duplicated id
discards its whole directory, which is an empty directory by another name and
should not surface as a raw filesystem error.

New validation tests use node:assert, per AGENTS.md — matching the surrounding
chai file is not an exemption — and cover the uppercase-UUID rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ward from

Moving the publish after the journal was not enough: between the journal and
B1 the on-disk state is live-present, candidate-present, no rollback record,
which settlement reads as an activation that never started — it deletes the
deployment directory, and the next boot re-resolves the published package
identifier from the registry. The journal alone does not select roll-forward;
the displaced-tree record does.

So the publish now sits between B1 and the commit rename, the one point where
recovery finds live displaced, the candidate complete and the rollback record
present, and rolls forward to the certified artifact. The remaining window is
the inverse and smaller: a crash after that state exists but before the publish
leaves the certified artifact live with config naming the previous release.
Closing it needs config to be an effect of the journal, which is #2315 step 3.

Staging no longer rejects absolute symlinks under node_modules. It should never
have: repairRelocatedDependencyLinks re-points exactly that subtree after the
swap, and npm writes absolute junctions there on Windows for a file:/workspace
dependency — so a component that deploys immediately could never be staged on
Windows. Outside node_modules the rejection stands, since nothing re-points
those.

A stage that completed locally records `staged` whatever fails afterwards,
including a rejection thrown by the replication layer itself, which carried no
marker of its own and left the row `failed` for an artifact on disk and
activatable.

returnToDormant tells the two failures apart: a journal that could not be
removed means not retryable, while a removal that could not be flushed means
retryable now but resurrectable by power loss. One message claimed the first
for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pair

Round 6 exempted absolute links under node_modules because npm writes them on
Windows for file:/workspace dependencies, and repairRelocatedDependencyLinks
re-points exactly that subtree. But that repair runs PAST the commit point and
can only warn: it logs and continues when a re-point fails, and logs and skips
a whole subtree on EACCES/EMFILE. Relying on it means a component can go live
holding a junction to a path that no longer exists while the operation reports
"Successfully deployed".

So staging refuses every absolute link into its own tree again, and the error
now names the remedy and the Windows case, because that is the real cost: such
a component deploys immediately but cannot be staged until its links are
relative. A refusal naming the path beats a release that loads on POSIX and
not on Windows.

Also: the activateCandidateApplication docstring still said config is published
immediately before calling it, which stopped being true when the publish moved
inside; three test comments that restated the code they sat above are gone; and
qa701-deployment-payload-ops' copy of TERMINAL_STATUSES, which declares itself a
mirror, now includes `staged`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth dawsontoth added this to the v5.3 milestone Sep 14, 2026
@dawsontoth
dawsontoth requested a review from kriszyp September 14, 2026 20:30

@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 the ability to stage a component build and activate it later (deploy_component { activate: false }). It introduces a mandatory, versioned .artifact.json descriptor to record build decisions, enforces exclusive claims on deployment IDs, and restricts external or absolute symlinks within staged trees to ensure integrity. The activation process is updated to swap the pre-certified artifact into the live path without rebuilding or reinstalling. A review comment suggests optimizing the staging process by caching the result of readlink to avoid duplicate filesystem reads.

Comment thread components/Application.ts Outdated
dawsontoth and others added 3 commits September 14, 2026 16:50
assertOwnedArtifactTree validated where a link RESOLVES, not the expression it
resolves through. A relative target that climbs out of the candidate and back
in — ../../../<id>/<component>/shared — is inside the candidate today and names
a path that does not exist once activation renames the tree, because the same
expression is then evaluated from components/<component>/. repairRelocated-
DependencyLinks cannot rescue it: it scans only node_modules, and its relative()
resolves against cwd rather than the link's directory. The walk now tracks the
link's depth below the candidate root and refuses any expression that ascends
past it.

The root-config undo ran one stack frame above the pre-commit boundary, after
returnToDormant had already durably retired the journal — so an activation that
failed at B2, compensated, and then died left config naming the staged release
with no journal to roll forward, and the next boot re-resolved that package from
the registry. The undo is now returned by afterJournal and run inside the
boundary, before the journal is retired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…undo fails

The depth counter only saw `..` spelled in the link's own target, so an
intermediate symlink defeated it: `a/b/up -> ..` followed by
`a/b/link -> up/../../../<id>/<component>/shared` never dips the counter
negative, and realpath lands inside the candidate, so both gates passed — while
after the swap the same expression ascends to the components root and names
nothing. Every prefix of the walk is now resolved with symlinks followed as the
filesystem follows them, and each one has to still be inside the candidate.
Covered by a test built from that counterexample.

A root-config undo that itself fails (ENOSPC, EACCES) was logged and then
followed by retiring the journal, leaving the old tree live, config naming the
staged release, and nothing to roll forward — the substitution the publish
ordering exists to prevent, reached by the one path that skipped it. A failed
undo now keeps the journal, which is exactly the state it exists for.

The stage confirmation read `peer.staged` off entries whose shape belongs to
harper-pro's replicator; normalizePeerResult already tolerates two shapes, so
assuming a flat body could make every peer in a fully-upgraded cluster read as
unconfirmed. It now accepts the marker flat or wrapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A crash between the durable journal write and the B1 rename leaves live
present, the candidate present and no rollback record — which settlement reads
as an activation that never started and answers by removing the whole
deployment directory. Right for an immediate deploy, whose candidate came from
a payload the operator still has. Wrong for a build somebody staged
deliberately, whose payload may already have been reclaimed: it destroys the
only copy and the operator is told to rebuild something they were told was
ready.

The descriptor is what distinguishes the two, and it is on disk precisely so
recovery can. With one present, recovery now removes only the journal and
leaves the artifact dormant — exactly the state deployment_id expects. This
closes the crash edge that has been carried since round 4 as "needs step 1's
recovery rule to change"; the rule did not need to change, only to notice the
descriptor.

An unknown, consumed, or foreign deployment id now answers 404 rather than the
500 a bare Error gets from the operations error handler. Naming an artifact
that is not there is the caller's mistake, not a server fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review September 15, 2026 14:23
Comment thread components/operations.js Outdated
@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

1 blocker, unchanged, still carried over from kriszyp's open threads (r4009459237, r4019827364): root config — isolation included — still isn't a journaled, durable effect, so a crash after roll-forward, or a failed config-undo for an existing component, can leave config naming a release that isn't live. Both threads remain unresolved; this push doesn't touch that code path, and only updates the test fixture to assert the divergence explicitly rather than close it.

This push's two targeted fixes are correct and each has a new test that fails against the prior commit: link-target splitting now treats \ as a separator only on Windows, so a POSIX file literally named ..\asset no longer false-triggers the escape guard; and publishClaimOwnership now removes the directory it just created (and only that one) if it can't record ownership, so a write failure between the exclusive mkdir and the .component write no longer permanently burns the deployment id. No new blockers or suggestions from this push.

The peer-confirmation check is the only safety net for the mixed-version
hazard #2315 records as accepted — a node running a build that predates staged
deploys treats `activate: false` as an ordinary deploy and serves the release —
and it had no test, so a regression in it would fail silently. Raised by the
repo's review bot.

Split the predicate out of deployComponent so it can be tested without a
cluster, and cover the three things that matter: a fully-upgraded cluster is
not reported as unconfirmed whichever shape harper-pro's replicator returns
(flat, `value`, or `body` — assuming flat would misreport every peer), an old
peer and a failed peer are both "did not confirm", and a missing or non-array
aggregate reports nothing rather than throwing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Reviewed with Codex

Comment thread components/Application.ts
Comment thread components/Application.ts Outdated
Comment thread components/Application.ts Outdated
…s links

Two review findings from the staged-deploy protocol.

A deployment directory was only attributed once the build wrote its candidate
tree or its `.component` sidecar, so for the whole of resolve-and-pack — minutes
for a git-reference package — it named nobody. A second component claiming the
same id read it as empty and deleted it, then both builds shared the recreated
directory. Ownership is now published as part of the claim, and an unattributed
directory is refused rather than reclaimed: emptiness cannot distinguish a claim
in flight from an abandoned one, and only the claimant's own preparation lock
serializes it.

`.complete` is a durability marker over the bytes, not a seal on them. A dormant
artifact can be edited while it waits, and every check that made it safe to
activate ran at stage time, so the ownership/link rule and the load validation
are both re-run before the swap. The link rule is the one that must not be
skipped: `repairRelocatedDependencyLinks` runs past the commit point and can only
warn, so a link planted after staging would otherwise reach the live path with
the operation reporting success. Re-running the load validation also gives the
activate path the `prepare`/`done` event it emitted a `start` for and never
closed.

Content tampering is still undetected; that needs a manifest `.complete` is bound
to, and the load validation is a no-op on the main thread until #2315 step 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread components/Application.ts Outdated
…that cannot happen

Round-11 review, three findings in the recovery and compensation paths.

The branch that returns a staged artifact to dormant returns early and so
reached none of the settled tail, including the step that clears an earlier
failed recovery's `.unsettled`. An artifact left carrying that marker is refused
by `deployment_id` and then deleted by the next retention pass as a stale
unsettled build — the opposite of what returning it to dormant is for. The clear
is now shared by both paths and keeps its fail-closed semantics.

Keeping the journal when the root-config undo failed claimed recovery could roll
the certified build forward. It cannot, for a component that already had a tree:
compensation has just put that tree back and taken the rollback record with it,
so the next settle reads live-plus-candidate-with-no-record and returns the
artifact to dormant regardless. The journal is now kept only where it changes the
outcome — a first-ever deploy, whose live path is absent — and the warning says
which state the operator is in. The config-undo test was vacuous: a read-only
root fails B1, so the publish it asserted about never ran. It now fails between
the publish and the commit and asserts the publish happened.

The undo also restored its snapshot unconditionally, so a `set_configuration`
acknowledged while the swap was retrying could be overwritten. It now takes back
only what it published, comparing values that went through the same write-and-parse
round trip.

Claiming an id no longer removes a directory it found absent: the exclusive
`mkdir` decides instead, since another component can claim it between that read
and the removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread components/operations.js Outdated
dawsontoth and others added 6 commits September 15, 2026 14:39
Round-12 review, two findings on the compensating paths.

Settlement's dormant branch removed the journal and then flushed that removal
with a throwing sync. A failure there is recorded by the caller writing
`.unsettled`, so the artifact ended up marked unsettled with no journal left to
settle it: unactivatable, and deleted by the next retention pass as stale. The
flush is now best-effort, the same rule the settled tail follows — nothing may
throw past the journal removal. An unflushed removal fails in the safe
direction, since a power loss resurrects a journal that settles idempotently.

The root-config undo compared against this process's config object, which
`set_configuration` does not refresh when it writes the file, so the guard
compared against a value that predated the very change it exists to protect.
It re-reads from disk first. That narrows the window rather than closing it;
serializing config publication is #2315 step 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts config entry

The comparison and its read-back had no test: every test touching the undo path
supplies its own `publishRootConfig` to `prepareApplication` and never reaches
the real closure. Split out the way `unconfirmedStagingPeers` was, so both ways
it can regress are pinned — comparing against the entry as passed rather than as
read back, and reading without refreshing first, which compares against a value
that predates the change the guard exists to protect.

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dormant return unlinked `.unsettled` and the activation journal and flushed
both with one sync at the end. Crash ordering between two unlinks in the same
directory is not the order they were issued in, so the journal's removal could
persist alone — leaving a verdict no settlement will ever revisit, since
settlement keys on the journal, and the next retention pass deletes the build
as a stale unsettled one. The marker's removal is now flushed first; a failure
there is safe, because the journal survives and the next start settles again.

The settled tail needs no such barrier: it removes the whole deployment
directory afterwards. This branch keeps it, which is the difference.

Windows cannot fsync a directory, so the ordering is unenforced there. Recorded
in DESIGN.md, because unlike the rest of this design — where a lost directory
update degrades to a roll back — here it degrades to a deleted artifact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…deleting it

The durability barrier added for the dormant return cannot run on Windows, where
a directory fsync is a no-op — so crash ordering there can still leave
`.unsettled` with no journal, which the residue pass deleted as a stale build.
Ordering cannot be the only defence.

`fail()` only ever writes `.unsettled` beside a journal it keeps, so a marker
with no journal says settlement finished and only the marker's own removal was
lost. For a described artifact that is a build somebody staged deliberately,
whose payload may already have been reclaimed; the pass now clears the verdict
and retains it, which is the idempotent completion of the settlement that wrote
it. An undescribed build in that state stays disposable, the rule that predates
staging and still has its own coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ding against

`dormantBuildAfterClearingVerdict` reported a filesystem fault the same way it
reported "not a retainable build" — as `undefined` — and the caller's other
branch on that answer deletes. So a marker that would not clear deleted the
certified artifact the branch had just been added to protect.

The clear and the verdict are now separate: `clearStaleVerdict` says only
whether the marker is gone, and a false leaves the artifact and its marker in
place for the next pass. Cleared-but-still-not-a-build — incomplete or treeless
— falls through to removal as before, so residue is still collected.

Covered with a directory-shaped `.unsettled`, the same corruption this code
already reasons about for `.activation.json`: it fails the non-recursive removal
on every platform while leaving the deployment directory removable, which is
what separates retention from deletion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing quiet

The previous commit stopped an unclearable `.unsettled` from deleting the
artifact it was attached to, but returned without recording anything. The marker
survives, every worker fails the component closed on it, and main reporting no
failure is exactly the split the settled tail throws to avoid: main serves what
every worker refuses. The residue path now fails the component too, so both
reach the same verdict and the artifact waits for the next pass.

That also collapses the duplicate helper: `clearUnsettledVerdict` is the one
clear for both paths, and its flush is the barrier the dormant return needed
between its two unlinks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread components/Application.ts
dawsontoth and others added 2 commits September 15, 2026 15:43
…version string

Documentation only; no behavior change.

The window between the roll-forward state becoming durable and the root-config
publish was recorded as "config names the previous release", which reads like a
version mismatch. It is not only that: the staged entry carries the isolation
the build admitted, `rollForward()` publishes nothing, and a component staged to
run isolated therefore comes back non-isolated after an ordinary crash, with
nothing in the operation reporting it. Isolation is a containment boundary, so
the window should be weighed by that.

Still #2315 step 3's to close; raised again on the review thread that is waiting
on a fix-or-gate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by exercising a live server: a component whose tree links outside itself,
and a `file:` directory asked to be staged, both reached the caller as a bare
500. Neither is a server fault. Staging refusals are the operator's input — the
same component deploys immediately — so they are 400; an activation refused
because the artifact it named is no longer what was certified is 409, since the
artifact exists and belongs to that component.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread components/Application.ts Outdated
dawsontoth and others added 3 commits September 16, 2026 11:35
A second live pass found the rest of the family: claiming an id another
component holds, or one that already carries a completed build, or one whose
directory is claimed but not yet attributed, and every artifact-descriptor
rejection — unreadable, wrong version, wrong component, missing runtime
decisions, or an isolation intent that disagrees with what it publishes — all
reached the caller as a 500.

They are conflicts over on-disk state the caller named, so they are 409, which
is what the first two live findings settled for this family.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s code

`ClientError(message, statusCode)` is the established convention and is already
what `deploymentOperations.ts` uses in this branch for the same kind of
operator-caused refusal. Five hand-rolled `Error & { statusCode?: number }`
helpers here become one import, including the 404 family that predates the
status-code work.

No behavior change: every code re-verified against a live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every activation-state refusal was a 404, so "no artifact with that id here"
and "the artifact is present but not something this can activate" were
indistinguishable to a caller — the reviewer raised it across three rounds, and
it is the same question an operator asks after a retry. Only the absent case
stays 404 now; a wrong owner, an incomplete build, an unsettled verdict, an
in-flight journal, a missing tree and a missing descriptor are 409, matching the
rest of that family.

`clearUnsettledVerdict` also announced that it had "settled the interrupted
activation" when the residue path calls it on a journal-less stale marker, where
no activation was settled at all. It now says what it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth requested a review from kriszyp September 16, 2026 15:53

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Reviewed with Codex

Comment thread components/Application.ts
Comment thread components/Application.ts Outdated
Comment thread components/Application.ts Outdated
… left behind

Two findings from @kriszyp.

A backslash is only a separator on Windows. On POSIX it is an ordinary filename
character, so splitting a link target on it turned `..\asset` — one legal entry
inside the candidate, which keeps resolving there after relocation — into `..`
plus `asset`, walked out of the build, and refused a component that never left
its own tree. Link targets are now split by the rules of the platform they are
on. Confirmed: the new test fails on the previous commit with exactly that
false escape.

Claiming an id wins an exclusive `mkdir` and then records who owns it, and a
failure between the two — a full disk, an EIO on the temp write or its sync —
left an unattributed directory that every retry of the same id is refused by,
permanently, since unattributed is a deliberate permanent refusal. The claim now
takes back the directory it created when it cannot name it, scoped to that
directory alone: one found by EEXIST may be another claimant's, and removing
that is the race the refusal exists to prevent. The write is a parameter so the
failure is testable, the way `publishedEntryStillStands` took its accessors.

The config-divergence fixture now asserts the divergence rather than checking
only trees and journals: the published entry is pinned as still standing while
the previous release is live. Whoever closes #2315 step 3 should find that
assertion inverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Excellent!
🤖 Reviewed with Codex

Comment thread components/Application.ts
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.

2 participants