Skip to content
62 changes: 38 additions & 24 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1045,9 +1045,11 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem
| `vendoredFailed` | `[{purl, error}]` | Vendored reverts that errored — entry, artifact, and manifest record all survive for a retry; drives exit 1 |
| `hosted` | `{reverted: [purl], failed: [{purl, error}], unsupported: [purl], editedFiles: N}` | The hosted leg (v5.0: the upstream restore). `reverted` lists the pins restored (would-be on dry-run); `failed` the refused pins with the version-control remedy in `error` (the pseudo-purl `files` for a write failure); `unsupported` is kept for shape and is always empty (every ecosystem has a restore); `editedFiles` counts distinct files rewritten |
| `manifest` | `{removedEntries: [purl], preserved: bool}` | Entries removed from the manifest (would-be removals on dry-run); `preserved` mirrors `--preserve-state` |
| `gc` | `{skipped: true}` \| `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}` | Skipped under `--preserve-state`, after a blob-gate abort, and under a corrupt vendor ledger |
| `gc` | `{skipped: true}` \| `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}` | The shared GC shape (see "One GC shape"). Skipped under `--preserve-state`, after a blob-gate abort, and under a corrupt vendor ledger |
| `paths` | `[string]` | The path-glob targets verbatim (empty when none) |

**Counters (v5.0, #1066)**: `rolledBack` and `failed` span every leg. `rolledBack` counts agent results that restored files, plus `vendoredReverted`, `vendoredPreserved` and `hosted.reverted`; `failed` counts failed agent results, plus `vendoredKept`, `vendoredFailed`, `hosted.failed` and `hosted.unsupported`. A package wired through two legs counts once per leg. `alreadyOriginal` stays agent-only. When something failed and nothing was rolled back or already original, the run failed as a whole: `status: "error"` with `error: {code: "rollback_failed", message}` (exit 1) instead of `partial_failure`.

**Exit rules**: not-installed entries never flip the exit (the documented apply/rollback asymmetry — even an all-not-installed run exits 0 `success`). Everything that leaves the system still patched DOES flip it to `partial_failure` exit 1: agent-leg failures, vendored drift-keeps and revert failures, hosted refusals, a corrupt vendor ledger, and a failed manifest write. GC failures never affect the exit.

## Self-update contract (`socket-patch --update`)
Expand Down Expand Up @@ -1241,7 +1243,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified

```jsonc
{
"command": "scan" | "apply" | "vex" | "vendor" | "rollback" | "get" | "list" | "remove" | "repair",
"command": "scan" | "apply" | "vex" | "vendor" | "rollback" | "get" | "list" | "remove" | "repair" | "update",
"status": "success" | "partialFailure" | "error" | "noManifest" | "notFound",
"dryRun": false,
"events": [ <PatchEvent>, ... ],
Expand All @@ -1254,20 +1256,28 @@ Every `--json` invocation emits a single JSON object that follows the **unified
"failed": 0,
"removed": 0,
"verified": 0,
"bytesDownloaded": 0,
"bytesFreed": 0
"rebuilt": 0, // omitted while zero (repair / vendor only)
"bytesFreed": 0 // = gc.bytesFreed; 0 when no GC ran
},
"gc": { // only when the run swept .socket/ (repair, remove)
"removedBlobs": 0,
"removedDiffArchives": 0,
"removedPackageArchives": 0,
"bytesFreed": 0
},
"error": { "code": "...", "message": "..." } // only on status=error
}
```

`events` is the load-bearing payload. `summary` is pre-computed from `events` so consumers don't have to walk the array. `error` is set only on top-level failures (e.g. `manifest_not_found`); per-patch failures appear as `events[*]` with `action: "failed"`.
`events` is the load-bearing payload. `summary` is pre-computed from `events` so consumers don't have to walk the array; its action counters count patch-level events, so a GC carrier event (the artifact-level `removed`, or `verified` on a dry run, that `repair` and `remove` emit for a sweep) bumps none of them. The sweep itself is reported once, in `gc`. `error` is set only on top-level failures (e.g. `manifest_not_found`); per-patch failures appear as `events[*]` with `action: "failed"`.

**One GC shape (v5.0).** Every command that sweeps orphan artifacts from `.socket/blobs`, `.socket/diffs` and `.socket/packages` reports the pass as the same `gc` object, `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}`: the envelope's `gc` (`repair`, `remove`), rollback's `gc` and the `gc` of `scan --prune` / `--sync` (which adds its manifest and vendored keys beside them). On a dry run the counts are what the pass would remove, under the same keys (v5.0, MAJOR: scan's `--dry-run` preview no longer prints `prunableManifestEntries` / `orphanBlobs` / `orphanDiffArchives` / `orphanPackageArchives` / `revertableVendoredEntries` / `vendorOrphanDirs` / `bytesReclaimable`; it prints `prunedManifestEntries`, `removedBlobs`, `removedDiffArchives`, `removedPackageArchives`, `revertedVendoredEntries`, `removedVendorOrphanDirs` and `bytesFreed`, and leaves out only the keys a real pass alone can fill: `keptVendoredEntries`, `failedVendoredEntries`, `skipped`, `warnings`). `summary.bytesFreed` mirrors `gc.bytesFreed` on the envelope commands (0 when no GC ran: `repair --download-only`, `remove --preserve-state`, and every command without a GC pass). `gc` is absent when no sweep ran. v5.0 removes `summary.bytesDownloaded`, which no command ever emitted.

### `PatchEvent` shape

```jsonc
{
"action": "discovered" | "downloaded" | "applied" | "updated" | "skipped" | "failed" | "removed" | "verified",
"action": "discovered" | "downloaded" | "applied" | "updated" | "skipped" | "failed" | "removed" | "verified" | "rebuilt",
"purl": "pkg:npm/foo@1.2.3", // omitted on artifact-level events
"uuid": "<patch uuid>", // optional
"oldUuid": "<previous uuid>", // only when action=updated
Expand All @@ -1278,7 +1288,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
"appliedVia": "blob" // only on action=applied; v5.0 drops "package" and "diff"
}
],
"bytes": 1234, // optional (downloaded/removed)
"bytes": 1234, // only on the GC carrier event and --update's downloaded
"reason": "Files match afterHash", // human-readable explanation (skipped)
"errorCode": "already_patched", // stable snake_case routing tag
"error": "<message>", // only when action=failed
Expand All @@ -1294,15 +1304,17 @@ Every `--json` invocation emits a single JSON object that follows the **unified

| Action | Emitted by | Meaning |
|--------------|---------------------------------------|---------|
| `discovered` | `scan`, `list` | Patch exists upstream / in the manifest — no work taken. |
| `downloaded` | `get`, `repair`, `scan --mode agent` | Patch bytes were fetched from the registry. `bytes` set. |
| `applied` | `apply`, `scan --sync` | Patch was written to disk. `files` enumerates what changed. |
| `updated` | `apply`, `scan --sync`, `get` | A different UUID replaced an older one for this PURL. `oldUuid` set. |
| `skipped` | every command | No-op — already patched, not in scope, filtered, etc. `errorCode` carries the reason. |
| `failed` | every command | A specific patch attempt failed. `errorCode` + `error` set. |
| `removed` | `repair`, `remove`, `rollback` | Data was removed from `.socket/` (or files rolled back). `bytes` optional. |
| `verified` | `apply --dry-run`, `scan --dry-run` | The patch *would* apply cleanly. `files` lists previewed changes. |
| `rebuilt` | `repair` | A missing/corrupt vendored artifact was restored from its exact server download (v5.0: never a lost ledger entry — see `vendor_ledger_missing`). `summary.rebuilt` counts these (the field is omitted while zero). |
| `discovered` | `list` | Patch recorded in the manifest, the vendor ledger or a hosted lockfile pin — no work taken. |
| `downloaded` | `repair`, `--update` | `repair`: artifacts were fetched (one aggregate event, `details.count`). `--update`: the release archive was fetched (`bytes` = archive size). |
| `applied` | `apply`, `vendor` | Patch was written to disk (`vendor`: vendored). `files` enumerates what changed. |
| `updated` | `--update` | The binary was replaced (`details.from` / `details.to`). |
| `skipped` | every envelope command | No-op — already patched, not in scope, filtered, etc. `errorCode` carries the reason. |
| `failed` | `apply`, `repair`, `vendor` | A specific attempt failed. `errorCode` + `error` set. |
| `removed` | `remove`, `repair`, `vendor` | A manifest entry or vendored state was removed, or (artifact-level, no `purl`) `.socket/` artifacts were swept — that GC carrier sets `bytes` and is not counted in `summary.removed`; the sweep's totals are the envelope's `gc`. |
| `verified` | `apply`, `remove`, `repair`, `vendor` (dry run); `vex`; `--update --dry-run` | The action *would* succeed cleanly (`files` lists previewed changes); `vex`: the patch verified and was attested. |
| `rebuilt` | `repair`, `vendor` | A missing/corrupt vendored artifact was restored from its exact server download (v5.0: never a lost ledger entry — see `vendor_ledger_missing`). `summary.rebuilt` counts these (the field is omitted while zero). |

`scan`, `get` and `rollback` print their legacy shapes, not events (see [Migration status](#migration-status-v30)).

### Stable `errorCode` tags

Expand Down Expand Up @@ -1521,11 +1533,12 @@ Every `--json` invocation emits a single JSON object that follows the **unified

| Subcommand | Emits |
|--------------|---|
| `apply` | `Applied` · `Updated` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) |
| `vendor` | `Applied` (= vendored; `command` routes) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) |
| `apply` | `Applied` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) |
| `vendor` | `Applied` (= vendored; `command` routes) · `Rebuilt` (a reused artifact restored) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) |
| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted pins (v5.0: one per `(purl, uuid)` the lockfiles wire) additionally carry `details.mode: "hosted"` and `details.lockfiles: [<root-relative files wiring it>]` (no `details.ledger` — hosted mode keeps no ledger; the human listing labels them `Mode: hosted (wired in <files>)`), both additive and absent on manifest entries; v5.0: vendor-ledger records carry `details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` the same way, and the human listing labels them `Mode: vendored (recorded in .socket/vendor/state.json)`; a `state.json` that cannot be read or parsed degrades to nothing-to-consult with the stderr line `Warning: unreadable vendor ledger (<error>); its vendored patches are not listed` — muted by `--silent`, exit unchanged) |
| `repair` | `Downloaded` (or `Verified` on dry-run; `details: {count, mode: "file"}` — `mode` is always `"file"` since v5.0 removed the diff download path) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events |
| `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.rolledBack`) |
| `repair` | `Downloaded` (or `Verified` on dry-run; `details: {count, mode: "file"}` — `mode` is always `"file"` since v5.0 removed the diff download path) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch, cleanup_failed) · artifact-level `Removed` (or `Verified`) GC carrier (`details.count`, `details.checked`, `bytes`) · `Failed` events · top-level `gc` (absent under `--download-only`) |
| `vex` | `Verified` (one per attested subcomponent) · `Skipped` (omissions) — only under `--json --output` |
| `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.archivesRemoved`, `details.rolledBack`; `bytes` when the sweep removed something) · top-level `gc` (absent under `--preserve-state`) |
| `--update` | `Downloaded` → `Updated` (success) · `Skipped` (already_latest) · `Verified` (dry-run check, reason update_check) — see the Self-update contract section for details fields and top-level error codes |

### Migration status (v3.0)
Expand Down Expand Up @@ -1782,13 +1795,14 @@ socket-patch apply --json | jq '
'
```

GC summary (after `repair --json`):
GC summary (after `repair --json`; `remove --json` prints the same `gc`):

```bash
socket-patch repair --json | jq '{
removed: .summary.removed,
bytesFreed: .summary.bytesFreed,
failed: .summary.failed
removedBlobs: .gc.removedBlobs,
removedArchives: (.gc.removedDiffArchives + .gc.removedPackageArchives),
bytesFreed: .summary.bytesFreed,
failed: .summary.failed
}'
```

Expand Down
67 changes: 52 additions & 15 deletions crates/socket-patch-cli/src/commands/remove.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use clap::Args;
use socket_patch_core::api::blob_fetcher::{DIFF_ARCHIVE, PACKAGE_ARCHIVE};
use socket_patch_core::api::client::get_api_client_with_overrides;
use socket_patch_core::ledgers::hosted_pins_matching;
use socket_patch_core::manifest::cleanup_blobs::{format_bytes, ArtifactReferences};
use socket_patch_core::manifest::cleanup_blobs::{
format_bytes, format_cleanup_result_for, ArtifactReferences,
};
use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
use socket_patch_core::manifest::schema::PatchManifest;
use socket_patch_core::patch::redirect::upstream::HostedPin;
Expand All @@ -19,7 +22,9 @@ use crate::args::{apply_env_toggles, GlobalArgs};
use crate::commands::hosted_unwind::{run_hosted_leg, HostedLegOutcome};
use crate::commands::lock_cli::acquire_or_emit;
use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend};
use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status};
use crate::json_envelope::{
Command, Envelope, EnvelopeError, GcReport, PatchAction, PatchEvent, Status,
};
use crate::ui::short_uuid;
use crate::ui::{plural, sweep_failure};

Expand Down Expand Up @@ -961,8 +966,8 @@ pub async fn run(args: RemoveArgs) -> i32 {
&updated_manifest,
retained_not_installed.iter().copied(),
);
let mut blobs_removed = 0;
let mut archives_removed = 0;
// `None` under `--preserve-state` (no sweep ran): no `gc` in the JSON.
let mut gc: Option<GcReport> = None;
if !args.preserve_state {
let sweep = references.sweep(&socket_dir, args.common.dry_run).await;
// repair's posture: a failed pass (or a pass that could not unlink
Expand All @@ -973,9 +978,11 @@ pub async fn run(args: RemoveArgs) -> i32 {
eprintln!("Warning: {detail}");
}
}
if let Ok(r) = sweep.blobs {
blobs_removed = r.blobs_removed;
// The GC lines are one block, opened by a blank line.
let mut gc_printed = false;
if let Ok(r) = &sweep.blobs {
if loud && r.blobs_removed > 0 {
gc_printed = true;
println!(
"\n{}",
format_blob_sweep(
Expand All @@ -989,16 +996,35 @@ pub async fn run(args: RemoveArgs) -> i32 {
}
// Obsolete diff and package archives are swept whole (parity with
// repair and scan --prune).
for (dir, result) in [("diffs", sweep.diffs), ("packages", sweep.packages)] {
if let Some(detail) = sweep_failure(dir, &result) {
for (dir, noun, result) in [
("diffs", DIFF_ARCHIVE, &sweep.diffs),
("packages", PACKAGE_ARCHIVE, &sweep.packages),
] {
if let Some(detail) = sweep_failure(dir, result) {
if loud {
eprintln!("Warning: {detail}");
}
}
// The archives the sweep took are named like repair names them,
// so the human run accounts for everything `gc` reports.
if let Ok(r) = result {
archives_removed += r.blobs_removed;
if loud && r.blobs_removed > 0 {
if !gc_printed {
println!();
}
gc_printed = true;
println!(
"{}",
format_cleanup_result_for(r, args.common.dry_run, noun)
);
}
}
}
gc = Some(GcReport::from_passes(
sweep.blobs.as_ref().ok(),
sweep.diffs.as_ref().ok(),
sweep.packages.as_ref().ok(),
));
}

// The dry-run footer closes the whole preview, the blob-cleanup
Expand Down Expand Up @@ -1096,14 +1122,25 @@ pub async fn run(args: RemoveArgs) -> i32 {
// single-patch removal that happened to sweep an orphan blob.
// Consumers read the blob/rollback totals from `details`, never
// from `summary.removed`.
if blobs_removed > 0 || rollback_count > 0 || archives_removed > 0 {
env.events.push(
// The sweep's per-kind totals and byte count are also the
// envelope's `gc` (`summary.bytesFreed`), the shape every GC-running
// command prints.
let report = gc.unwrap_or_default();
if report.total_removed() > 0 || rollback_count > 0 {
let mut carrier =
PatchEvent::artifact(removal_action).with_details(serde_json::json!({
"blobsRemoved": blobs_removed,
"blobsRemoved": report.removed_blobs,
"rolledBack": rollback_count,
"archivesRemoved": archives_removed,
})),
);
"archivesRemoved": report.removed_diff_archives
+ report.removed_package_archives,
}));
if report.total_removed() > 0 {
carrier = carrier.with_bytes(report.bytes_freed);
}
env.events.push(carrier);
}
if let Some(gc) = gc {
env.set_gc(gc);
}
// Any drift-kept entry means part of the requested removal did
// NOT happen: the run is a partialFailure (exit 1) even when
Expand Down
Loading
Loading