Skip to content

Implement get_deployment_payload and delete_deployment_payload operations - #1898

Merged
kriszyp merged 4 commits into
mainfrom
kris/1893-deployment-payload-ops
Jul 23, 2026
Merged

kriszyp merged 4 commits into
mainfrom
kris/1893-deployment-payload-ops

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 22, 2026 •

Copy link
Copy Markdown
Member

Both operations have been documented (operations-api reference + 5.1 release notes) and enum-declared since 5.1, but were never registered — calling them returned Operation 'delete_deployment_payload' not found (#1893, reported from a Fabric quota-cleanup workflow).

What this adds

  • get_deployment_payload (components/deploymentOperations.ts) — streams the stored deployment tarball back as raw bytes with content-type: application/octet-stream + a content-disposition filename, via the existing Readable+headers pipe path in serverHandlers.js (the get_backup mechanism). Deliberately never base64: payloads can be hundreds of MB, and a base64 round-trip materializes multi-hundred-MB V8 strings (hard ERR_STRING_TOO_LONG near ~400MB). The stream is marked preCompressed so the already-gzipped tar isn't re-gzipped.
  • delete_deployment_payload — nulls payload_blob on the row and re-puts it; the RecordEncoder retained-blob check unlinks the local blob file and the replicated null makes every peer drop its copy too (the same cluster-wide reclaim mechanism as the automatic post-deploy retention drop from feat(deploy): auto-drop large deployment payload blobs after a successful deploy #1496). Row metadata + event_log are retained as the audit trail, with a payload_dropped event recording deleted_by. Guards: 409 on non-terminal deployments (their blob may still be the replication channel peers install from); idempotent success (freed_bytes: 0) when the payload is already gone; 404 for unknown ids.
  • Registration + authorization for both, mirroring get_deployment's super_user permission shape.

Hardening found by review (in this PR)

  • serverHandlers.js gzip pipe now forwards source-stream errors. .pipe() doesn't propagate them, so an async read error on a returned stream (e.g. get_backup's file stream) fired 'error' with no listener → uncaughtException → whole worker exits. Latent before this PR; blob streams made it much more reachable. Now the error destroys the gzip stream and Fastify aborts just that response.
  • MCP operations adapter returns a clean isError for streaming (Readable) results instead of JSON.stringify-ing stream internals, and destroys the stream so file-backed sources release fds. delete_deployment_payload added to DESTRUCTIVE_OPERATIONS. (Neither payload op is in the MCP default-allow surface; this covers explicit mcp.operations.allow opt-ins, and get_backup as well.)

Considered decisions (flagging for review)

  • Delegability: both ops use get_deployment's permission shape, meaning an SU can grant them to a role via its operations allowlist (gate-2). Considered self-enforcing super_user like the secrets ops (tarballs can embed secrets), but delegation requires a deliberate per-op SU grant, and a non-SU cleanup-automation principal is exactly the delete_deployment_payload operation not implemented #1893 use case. Happy to tighten if reviewers disagree.
  • Rollback/redeploy-by-reference is deliberately out of scope — that belongs with the two-phase deploy work in feat(deploy): two-phase stage/activate for deploy_component #1849 (design comment there).

Verification

  • Unit: 105 passing across the four touched test files (new: handler behaviors, gzip error-forwarding, preCompressed pass-through, MCP streaming guard).
  • Live smoke (scratch instance from this branch's dist): deployed 1.2MB payload → real blob file on disk; get_deployment_payload byte-identical round-trip (sha256 match) with and without Accept-Encoding: gzip; delete_deployment_payload → freed_bytes correct, blob file physically unlinked, payload_blob_present:false, payload_dropped audit event, idempotent second call, 404s on reclaimed/unknown.

Cross-model coverage: codex ✓ / gemini ✓ / Harper-domain pass ✓ (thorough), + final-artifact pass after the review fixes. All blockers/concerns from review addressed above.

Docs: companion PR documentation#600 adds the delete response shape, terminal-status requirement, and raw-bytes (not base64) clarification.

Fixes #1893

🤖 Generated with Claude Code (Opus 4.8)

@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 introduces support for retrieving and deleting deployment payloads. It adds two new operations: get_deployment_payload (which streams the stored tarball as raw bytes) and delete_deployment_payload (which deletes the payload while retaining metadata and event logs for auditing). It also updates the server handlers to bypass recompression for pre-compressed streams and forward stream errors to prevent worker crashes. The review feedback suggests using pipeline from node:stream instead of .pipe() when chaining streams in serverHandlers.js to ensure proper stream destruction and prevent resource leaks if a client disconnects.

Comment thread server/serverHelpers/serverHandlers.js Outdated
@claude

claude Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

kriszyp added a commit that referenced this pull request Jul 22, 2026
recordCommitLatency() only uses commitResolution.then() for timing and
never touches the resolved value, but its parameter was typed as
Promise<void>. TS's control-flow narrowing widens an assignment of
Promise<void> to a Promise<number | void> variable back to
Promise<number | void> (the union member it's assignable to, not the
literal assigned type), so passing that variable at the call site
failed to typecheck. Widen the parameter to Promise<unknown>, which
matches what the function actually needs, and drop the now-unnecessary
`as Promise<void>` cast on the commit() call.

Fixes the "Next.js adapter integration" CI failures on PR #1898 (all
Node versions), which build harper fresh via `npm run build` and hit
this compile error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review July 22, 2026 15:39
@kriszyp

kriszyp commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Reply to the components/mcp/tools/operations.ts thread (posting as a top-level comment — a direct review-reply collided with your in-progress pending review):

Good catch, and confirmed the mechanism exactly: Readable.from()'s _destroy calls iterator.return(), and per the async-generator spec, calling .return() on a generator that never had .next() called resolves it straight to done without entering the body — so readTxn.done() never runs.

Fixed in 9ba73f1 with your first option: STREAMING_OPERATIONS (get_backup, get_deployment_payload) now short-circuits before chooseOperation/processLocalTransaction are even called, so the fd/txn are never opened for a rejected MCP call. Kept the instanceof Readable check as a defense-in-depth backstop for an op outside that set unexpectedly returning a stream (distinct message so the two paths are distinguishable), but that's belt-and-suspenders now, not the real fix. New test asserts chooseOperation is never reached for both known streaming ops.

Thread marked resolved. — Claude (Opus 4.8)

Comment thread components/deploymentOperations.ts
@kriszyp

kriszyp commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Reply to the claude-bot finding on components/deploymentOperations.ts:251 (handleDeleteDeploymentPayload spreading row) — posting top-level since a direct review-reply collides with the in-progress pending review.

Investigated by instrumenting the live code path rather than trusting either the finding or my own earlier smoke test at face value, since they seemed to disagree. Booted a scratch instance from this branch, deployed a payload, and logged row.constructor.name, Object.keys(row), Object.keys({...row}), and row.getId right before the delete write. Results:

ctor: "RecordObject"
ownKeys: ["deployment_id","project","package_identifier","payload_hash","payload_size",
          "payload_blob","status","phase","event_log","peer_results","origin_node",
          "restart_mode","started_at","completed_at","user","rollback_of","credentials",
          "error","__updatedtime__","__createdtime__"]
spreadKeys: <identical to ownKeys>
getIdFn: "undefined"   // no such method

table.get(id) here — a direct programmatic call on the Table object (databases.system.hdb_deployment), not a Resource/REST-mediated request — returns a plain decoded storage record (RecordObject) with every declared attribute as a genuine own property, not a TableResource instance backed by the assignTrackedAccessors prototype getters the finding described (that mechanism applies to the Resource/HTTP-request layer, a different code path than a direct table.get() call). {...row} therefore does carry every field, data[this.primaryKey] resolves correctly on .put(), and there is no getId() to fall back on because none is needed. The finding's premise doesn't hold for this call site — the earlier live round-trip test (byte-identical payload, correct audit event, idempotent re-delete, all against the same row) is consistent with this, not contradicting it as it first appeared.

No code change from this thread. — Claude (Opus 4.8)

kriszyp and others added 3 commits July 22, 2026 12:02
…ions

Both operations have been documented (and enum-declared) since 5.1 but never
registered, so they returned "Operation not found" (#1893).

- get_deployment_payload streams the stored tarball as raw bytes (never
  base64: V8's ~512MiB string cap makes large payloads hard-fail) via the
  existing Readable+headers pipe path, marked preCompressed since the
  payload is already a gzipped tar.
- delete_deployment_payload nulls payload_blob on terminal-status rows,
  retaining row metadata/event_log as audit; the replicated null unlinks the
  blob file locally and on every peer (the #1495 reclaim mechanism).
  Idempotent; 409 on non-terminal rows whose blob may still be the
  replication source.
- serverHandlers gzip pipe now forwards source-stream errors; an async blob
  read error previously fired 'error' with no listener and killed the whole
  worker via uncaughtException (also latent for get_backup).
- MCP operations adapter fails cleanly (isError) on streaming results
  instead of JSON.stringify-ing stream internals, and destroys the stream;
  delete_deployment_payload added to DESTRUCTIVE_OPERATIONS.

Fixes #1893

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client disconnect destroys only the stream Fastify was handed (the gzip
transform); without the reverse hook the source file/blob read stays open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Kris caught in review: destroying an already-returned stream isn't
sufficient cleanup for get_backup's LMDB path — it opens an fd and read
transaction, then returns Readable.from(asyncGenerator). Per spec, calling
.return() (which .destroy() triggers) on a generator that was never
iterated resolves it to done and skips its body, including the
readTxn.done() cleanup at the tail — so the fd/transaction leaked on every
rejected MCP call.

STREAMING_OPERATIONS (get_backup, get_deployment_payload) now short-circuits
before chooseOperation/processLocalTransaction ever run, so those resources
are never opened. The prior data instanceof Readable check remains as a
defense-in-depth backstop for an operation outside that set unexpectedly
returning a stream, with its own message so the two paths are distinguishable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/1893-deployment-payload-ops branch from 9ba73f1 to 0afdc1f Compare July 22, 2026 18:04
Comment thread utility/operation_authorization.ts
cb1kenobi (review on #1898, responding to the PR's own flagged open
question): get_deployment_payload streams the full tarball, which can embed
secrets — unlike get_deployment/list_deployments, which only ever return
stripped metadata. Registering it with get_deployment's permission shape
lets an SU delegate it to a non-SU role via the operations allowlist
(gate-2), handing that role full source+secret read. That's exactly the
property that makes the secrets-store ops self-enforce super_user in the
handler so they can't be gate-2 delegated.

get_deployment_payload now does the same (mirroring secretOperations'
requireSuperUser). delete_deployment_payload is intentionally left as-is —
cb1kenobi's recommendation was scoped to the payload-read op; the #1893
non-SU cleanup-automation use case only needs delete, which doesn't expose
source/secrets.

Verified live: a non-SU role granted get_deployment_payload via
add_role({operations:[...]}) now gets 403; the same role's
delete_deployment_payload call still succeeds; an SU caller still gets a
byte-identical download.

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

kriszyp commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Reply to cb1kenobi's finding on utility/operation_authorization.ts:299 (get_deployment_payload delegability) — posting top-level since a direct review-reply collides with the in-progress pending review.

Agreed, and this was exactly the open question I flagged in the PR description ("Happy to tighten if reviewers disagree"). Fixed in fba0fcb: get_deployment_payload now self-enforces super_user in the handler, mirroring secretOperations.requireSuperUser — it can no longer be delegated to a non-SU role via the operations allowlist (gate-2), regardless of what a role grants. delete_deployment_payload is intentionally left as-is per your scoped recommendation — the #1893 non-SU cleanup-automation use case only needs delete, which never exposes source/secrets.

Verified live against a real non-SU role granted both ops via add_role: get_deployment_payload now 403s for it, delete_deployment_payload still succeeds for it, and an SU caller still gets a byte-identical download. New unit tests cover the forbidden path for both no-hdb_user and non-SU-role callers.

Thanks for catching this — nice catch on the asymmetry with stripBlob. — Claude (Opus 4.8)

@cb1kenobi cb1kenobi 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.

LGTM

@kriszyp
kriszyp merged commit 23186eb into main Jul 23, 2026
97 of 98 checks passed
@kriszyp
kriszyp deleted the kris/1893-deployment-payload-ops branch July 23, 2026 14:55
Ethan-Arrowood pushed a commit to HarperFast/documentation that referenced this pull request Jul 27, 2026
…contracts (#600)

* Document get_deployment_payload / delete_deployment_payload response contracts

Companion to HarperFast/harper#1898 (which implements the two operations —
they were documented but returned "Operation not found", harper#1893):
raw-bytes response for get_deployment_payload, and the terminal-status
requirement, idempotency, response shape, and audit event for
delete_deployment_payload.

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

* docs: note get_deployment_payload's stricter super_user enforcement

get_deployment_payload checks requireSuperUser() directly in the handler,
unlike delete_deployment_payload and nearly every other super_user op,
which can be granted to a non-super_user role via that role's operations
allowlist. Document the asymmetry so admins don't hit an unexplained 403.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Sep 4, 2026
…oyment payload operations (#2431)

* Promote two QA anchors: TLS cert-table swap reachability and the deployment payload ops

Adds two integration suites, no product change.

integrationTests/deploy/qa701-deployment-payload-ops.test.ts pins the two operations
added by #1898, which shipped with unit coverage only. It asserts that
delete_deployment_payload performs a real on-disk unlink with freed_bytes exactly
equal to payload_size at ~4 KB and again at 12 MB (rather than the metadata-only drop
seen on drop_attribute/drop_table), and that the authorization asymmetry is the
contract: a non-super_user role explicitly granted both operations can delete but is
still 403 on get, because that handler self-enforces super_user over and above the
registered permission. Boundaries covered alongside: unknown id 404 with no download
headers on the error path, 409 on a non-terminal deployment, 404 after delete,
idempotent second delete, a running component's route surviving its payload delete,
and redeploy independence.

integrationTests/security/qa877-tls-cert-swap.test.ts pins the narrowing behind #2004:
an ordinary create_attribute reaches every worker's resetDatabases() yet does not swap
the hdb_certificate table object, so a cert rotation immediately afterwards still
propagates to every worker with zero stale certificates across 30 fresh handshakes, at
threads=1 and threads=4. If that leg ever goes red, #2004 has become reachable from a
far more common trigger than currently understood.

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

* Address pre-push review: unique payload sizes, a real 409 window, and worker-count/convergence guards

Codex and Cursor/Grok independently flagged the deployment suite's disk oracle: it identifies the
blob under test by size within a 64-byte tolerance, and every fixture was built at 4 KB, so an
unrelated surviving blob could read as a leak of the one just deleted. Each fixture now has a
distinct size and the pre-delete precondition requires exactly one match, so a future collision
fails loudly on the precondition instead of false-failing as DEFECT-LEAK.

The 409 probe never reached the guard: deploy_component only responds once the row is terminal, so
deleting after awaiting the deploy always hit a settled row and the assertion was skipped on every
run. The deploy now runs unawaited and the in-flight row is caught through list_deployments, which
makes the 409 deterministic; the refused delete is then confirmed to have left payload_blob in
place once the deployment settles.

For the TLS suite: the threads=4 arm never verified four workers actually started, so it could pass
vacuously against a single survivor — it now carries cert-reload.test.ts's system_information guard.
Both rotation legs waited for the FIRST worker to serve the new serial and then immediately required
every worker to be current, which can false-fail on a loaded runner; they now poll the whole
fan-out for convergence. create_attribute's result is read back through describe_table rather than
checked for truthiness.

Also from the review: an error listener and request teardown on the multipart helper, the real
request path instead of a hardcoded '/', a clear error when the poll helper never ran, and trimmed
comment narration.

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

* Round 2: outlast the updateTLS debounce, and stop three silent-skip paths

The schema-change leg asserts a non-event (the served cert does not change) but waited exactly
1500ms — the same interval each worker debounces its updateTLS() rebuild with. A swap landing just
after the probe would have read as "unchanged", so the leg could have passed on the behavior it
exists to catch. The wait is now a named constant clearly past the debounce.

Three paths could skip work silently: the 409 window poll ended on a transient list_deployments
failure instead of retrying to its deadline; both readiness polls treated a 5xx as ready, turning a
broken fixture into a cryptic downstream timeout; and the cert directory was removed after
teardownHarper rather than in a finally, so a teardown throw leaked it.

Also destroys the upload body when the request errors, matching the reverse direction already there.

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

* Round 3: make the 409 assertion unconditional, and fix a busy-loop and a mislabeled check

The 409 probe still depended on winning a race against local I/O, so on a fast host it skipped its
assertion and a regression in the guard could have merged green. deploy_component writes the
deployment row before it ingests the payload stream, so the upload is now paced in chunks: the row
is held non-terminal for a known ~2s regardless of host speed, and the assertion runs
unconditionally. Verified over three consecutive runs. The fixture shrinks from 6 MB to 256 KB in
the process, since pacing rather than payload size now supplies the window.

The round-2 fix that stopped the poll bailing out on a transient error left it spinning without a
backoff; it now sleeps between polls.

The super_user sanity check at the end of the delegation probe claimed to prove a byte-identical
download still worked, but called get_deployment (metadata) against a row whose payload had already
been deleted two probes earlier. It now downloads the redeployed row's still-present payload, which
is what it always claimed to do.

The suite's readiness poll fell through silently when its deadline expired, so a never-routed
instance would have surfaced as confusing downstream failures instead of a setup error.

Comment volume was raised by both reviewers in every round: headers are trimmed to the invariant,
the proof boundary, and the cross-references, with narration and reviewer-directed framing removed.

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

* Do not UTF-8 decode a binary operations response

callOperationAs decoded every response body as UTF-8 before trying JSON.parse. The current callers
read binary downloads through `raw`, so nothing was corrupted, but the helper was a trap for the
next test that reached for `body` on a get_deployment_payload success. Octet-stream responses now
leave `body` undefined.

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

---------

Co-authored-by: Claude Opus <noreply@anthropic.com>
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.

delete_deployment_payload operation not implemented

2 participants