Skip to content

Decide whether a hosted patch is pinned through lockfile discovery alone - #1058

Merged
Mikola Lysenko (mikolalysenko) merged 33 commits into
mainfrom
arch-fix/pinned-check
Oct 8, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 33 commits into
mainfrom
arch-fix/pinned-check

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Audit §3.B, "is this pinned": four places decided whether a patch uuid is pinned, and they disagreed.

  • engine::confirm used the rewriters' reports plus substring needles.
  • rollout::mark_pinned counted any uuid-shaped token in any candidate text.
  • The in-memory engine's memory_recorded counted any mention of an offered uuid in any file.
  • vex::discover / HostedInventory (what vex, list, rollback, remove and vendor read) parses each format.

What that caused:

Change

Lockfile discovery is now the only answer to "is this pinned". Three commits:

  1. Read lockfile discovery through any project view. Behavior-preserving.
    • Discovery used to read the disk directly in 10 places. Content reads and listings now go through ProjectView.
    • Probes that need an installed tree sit behind DiscoverCtx::disk_root() and see nothing in memory.
    • New entry point: discover_patched_refs_view().
    • The hosted engine's private Python-lock lister is deleted in favor of ProjectView::python_lock_paths().
  2. Decide recorded hosted pins through lockfile discovery.
    • mentioned_uuids and the mention scan in memory_recorded are deleted.
    • Both engines' recorded view is now HostedPin::discover. Two behavior changes in the in-memory engine: a pin now counts even when the API no longer offers its patch (as on disk, so a re-scan re-confirms it as ALREADY instead of spending a NEW slot), and pins wired only by files under a nested root are still excluded, as the old mention scan did.
    • A pin on a patch server the run's references name but that isn't configured is recognized by re-running discovery with those origins (foreign_dep_origins). This replaces mark_pinned's reason to exist.
    • The disk scan makes that check before its lock decision. The late lock taken after an unlocked read is gone (B58).
    • Lockless pins now carry ContestedWiring.lockless. The refusal names the lockfile to create (dotnet restore --use-lock-file / cargo generate-lockfile) instead of a scan re-run.
    • A lockless pin's own grant token is no longer reported as a second contested patch (UnlockedPin.index_url).
  3. Never write hosted pins lockfile discovery would contest.

Kept as is, and documented in CLI_CONTRACT ("Attribution gate"):

  • A pin in a file discovery does not read keeps the rewriter's verdict. Example: a pre-2.6 bundler Gemfile, which the next bundle install locks.

  • Deliberate partial redirects the run already reports keep their behavior:

    • bundled or bun patch-ed copies (bundled_skipped_uuids)
    • bundled copies in vlt's store
    • deps withheld from the vlt rewrite while a sibling lock takes them

    Which unreachable copies should block a redirect is the copy-source policy (audit B16).

  • A vendored→hosted takeover is never vetoed. A wet run has already reverted its vendored wiring and saved the ledger before the rewrite, so dropping it would leave the package on the unpatched registry release (exit 1) while the dry run reported success. Its pin keeps the rewriters' verdict, as before this PR, and the dry run and wet run agree (RewriteOptions::takeover_uuids).

  • Lockless NuGet / Cargo pins are still written. They now carry a redirect_pin_lockless warning.

Duplicate copies deleted (before → after)

What Before After
"Is this uuid pinned" oracles 4: confirm needles, mark_pinned/mentioned_uuids, memory_recorded mention scan, discovery 1 deciding oracle (discovery, via HostedPin::discover / HostedInventory). confirm()'s needles stay as a "did the write land" pre-check that discovery can veto, and they still decide pins in files discovery does not read. The in-run --vex RedirectState carrier is deferred to E45. Counted strictly, 4 → 2.
Python-lock listers over a view 2: hosted::engine::python_lock_paths and the discovery disk call 1: ProjectView::python_lock_paths
Requirements include walkers Disk-only, with a separate disk path for discovery 1 view-based walk; the disk API delegates to it
Discovery origin builders 2 identical copies: commands::discover_options and rollback::patch_server_origins 1. The vlt heal's private list, which also counts --api-url, is renamed vlt_heal_origins and documented, so the two names no longer collide.

Testing

Failing-first: each new regression test was run against code without its fix and failed there.

Commands run (through heavy-job.sh, CARGO_INCREMENTAL=0, -j4), on head e25c17d over origin/main 431b818:

  • cargo test -p socket-patch-core --no-fail-fast: 6238 passed, 0 failed.
  • cargo test -p socket-patch-cli --no-fail-fast: 5129 passed, 3 failed. All 3 are unrelated and fail the same way before this PR:
    • mode_migration_npm::berry_vendored_then_hosted_takeover_leaves_pure_hosted fails on the base commit too.
    • e2e_vendor_cargo_build::cargo_vendored_{manifest_patch_builds_on_old_toolchains,two_versions_are_refused_by_cargo_below_1_45} fail locally because the old x86_64 rustup toolchains can't run on arm64 ("Bad CPU type").
  • cargo clippy -p socket-patch-core -p socket-patch-cli --all-features -- -D warnings: clean apart from the macOS-only unused_variables in python_crawler.rs, which already fails on main on this host (checked with -A unused-variables).
  • cargo clippy --all-targets: no findings in touched files.
  • rustfmt was applied only to the lines this branch changed.

Linux and docker suites are left to CI.

Deferred

Performance

The attribution gate first cost one to three extra lockfile discoveries per hosted scan, which made scan performance fail. Three changes bring it back under main's numbers.

1. A cheaper discovery (vex/discover/mod.rs, vendor/vlt_bundled.rs, gradle):

  • The resolved_elsewhere dedup is linear now (it was quadratic).
  • socket_identities is memoized process-wide. The key is the SHA-256 of the text plus the patch-server origins. Only files of 16 KiB or more are memoized, at most 64 entries. A repeated discovery of an unchanged lockfile, for example the gate's, skips the identity parse.
  • One Aho-Corasick sweep finds the anchors. A URL fast-reject skips text that cannot name a patch server, with a parity test over 230 spellings.
  • The vlt store walk runs as one blocking task. It uses lstat and probes shared path prefixes once, instead of a canonicalize per entry. Symlinked components still go through canonicalize, so the walk stays inside the project.
  • Gradle's PinnedRowChecks are computed once per discovery.

2. Reusing scan's discovery (hosted/engine.rs, scan/hosted.rs):

  • The gate reuses scan's own pre-redirect discovery (RewriteOptions::prior_discovery) instead of discovering again. That needs all of these:
    • (a) the pass writes nothing;
    • (b) the gate's origins equal scan's: no grant names a host outside Socket's server and the configured patch servers (foreign_dep_origins is empty);
    • (c) no vendored→hosted takeover reverted or migrated files earlier in this run;
    • (d) the read-set guard below passes.
  • The gate hands back what it used as Rewritten::final_discovery. The post-write step (discovery_after_writes) uses it in place of a third discovery, as follows:
    • Nothing written: scan's discovery, or the gate's.
    • --dry-run: scan's discovery.
    • Files written: the gate's overlaid discovery, but only when one of these holds: discovery read only through the overlaid view, which also shows created files; or no created file is visible to a read around the view (overlay_creation_is_invisible).
    • A vlt store heal additionally requires the discovery's vlt_bundled_copies to equal the healed store's.
    • Otherwise it discovers again, as before.

3. The read-set race guard (lock_inventory/view.rs ReadSet, scan/rollout.rs Prior::still_current):

  • Scan discovers before it takes the apply lock, so a concurrent writer could change the project in between.
  • Scan's discovery runs over a tracked DiskSnapshot. Before its first read of each path, the snapshot records that path's fingerprint: file metadata, and a directory's entry listing.
  • Under the apply lock, still_current() re-stats every recorded path and offers the prior discovery only when every fingerprint still holds.
  • The snapshot's root is private. A read the view does not mediate must go through root_reading(paths), which fingerprints exactly those paths, or through root(), which makes the read set unusable (None) and so opts that discovery out of reuse. The vlt store walk uses root(). NuGet's same-file probe declares its two paths through disk_root_reading.
  • A racily-current file is one modified within 2 s of its fingerprint. Timestamps move in ticks (about 16 ms on Windows, a second on HFS+), so a same-length rewrite in the same tick keeps every stat. Like git's "racily clean" files, such a file also holds only while its content is still the content the view read. A racy file that was only probed never holds.
  • Any change, or an unusable read set, sends the gate to a fresh discovery.

Bench: a local socket-patch-bench compare with the CI settings (perf profile, 15 pairs plus confirmations, all 45 scenarios). Base: origin/main 829d0af. Head: this branch at 9c49ebf. Exit 0, no regressions, peak RSS at most +7.4% (npm/hosted +2.8%). CI scan performance passed on every head pushed (56ca23e, 63bba81, d94dc9b, 0bf0c3f, 9c49ebf); later heads add only a main merge and a workflow pin comment.

scenario pkgs base wall head wall Δ wall [95% CI] Δ CPU Δ peak RSS requests verdict
npm/hosted 3000 122.2 ms 105.0 ms -14.1% [-15.8, -13.2] -8.5% +2.8% 127 ✅ faster
npm/rescan 3000 103.6 ms 76.9 ms -26.1% [-26.7, -25.0] -12.3% -2.6% 127 ✅ faster
pnpm/hosted 3000 243.8 ms 213.8 ms -7.1% [-21.6, -2.6] -10.5% -0.2% 127 ≈
pnpm/rescan 3000 134.8 ms 129.9 ms -9.5% [-12.8, -1.0] -2.6% -0.1% 127 ✅ faster
yarn-classic/hosted 3000 111.6 ms 93.6 ms -16.1% [-17.8, -15.5] -9.7% +3.5% 127 ✅ faster
yarn-classic/rescan 3000 97.9 ms 73.3 ms -25.1% [-26.1, -24.5] -11.5% -6.3% 127 ✅ faster
yarn-berry/hosted 3000 131.9 ms 116.6 ms -11.5% [-12.1, -11.0] -7.2% -0.1% 127 ✅ faster
yarn-berry/rescan 3000 109.7 ms 92.0 ms -16.5% [-16.9, -15.1] -8.8% -7.2% 127 ✅ faster
bun/hosted 3000 140.4 ms 130.5 ms -6.9% [-7.7, -5.9] -3.8% -0.5% 127 ≈
bun/rescan 3000 129.9 ms 115.7 ms -10.7% [-11.4, -10.1] -5.4% -1.9% 127 ✅ faster
bun-isolated/hosted 3000 172.0 ms 162.8 ms -5.4% [-6.1, -5.0] -2.4% -0.2% 127 ≈
bun-isolated/rescan 3000 172.6 ms 157.6 ms -9.0% [-9.4, -8.0] -4.2% -1.7% 127 ≈
vlt/hosted 1500 320.4 ms 186.1 ms -42.0% [-42.2, -41.3] -33.0% +1.8% 94 ✅ faster
vlt/rescan 1500 297.6 ms 162.7 ms -45.4% [-45.9, -44.6] -32.6% +2.3% 94 ✅ faster
pip/hosted 1000 72.7 ms 71.2 ms -1.9% [-3.7, -1.2] -2.8% +3.8% 53 ≈
pip/rescan 1000 60.5 ms 57.2 ms -5.4% [-7.3, -2.0] -5.4% +3.0% 53 ≈
uv/hosted 400 71.6 ms 69.6 ms -3.0% [-4.3, -1.0] -4.0% +4.2% 38 ≈
uv/rescan 400 52.1 ms 47.9 ms -8.2% [-9.8, -6.3] -8.2% +2.1% 38 ≈
pylock/hosted 400 58.8 ms 59.9 ms +0.9% [-3.2, +3.9] -3.1% +3.8% 26 ≈
pylock/rescan 400 47.5 ms 41.6 ms -12.5% [-13.6, -10.9] -12.0% +2.0% 26 ✅ faster
poetry/hosted 400 52.4 ms 53.1 ms -0.6% [-3.1, +4.4] -2.0% +1.8% 26 ≈
poetry/rescan 400 38.4 ms 34.8 ms -9.8% [-10.4, -8.0] -9.6% +1.7% 26 ✅ faster
pipenv/hosted 400 48.6 ms 48.3 ms -0.5% [-5.3, +0.6] -1.9% +2.2% 26 ≈
pipenv/rescan 400 37.7 ms 36.6 ms -3.6% [-4.4, -2.2] -3.6% +3.1% 26 ≈
pdm/hosted 400 53.6 ms 51.3 ms -1.9% [-6.8, +2.2] -2.7% +1.7% 26 ≈
pdm/rescan 400 38.6 ms 35.3 ms -8.3% [-9.0, -7.3] -7.9% +1.6% 26 ≈
hatch/hosted 1000 69.1 ms 69.4 ms +0.1% [-0.9, +2.0] +0.5% +1.7% 53 ≈
hatch/rescan 1000 49.1 ms 48.7 ms -0.1% [-2.1, +1.1] -0.7% +1.4% 53 ≈
bundler/hosted 800 62.7 ms 61.2 ms -1.0% [-1.8, -0.2] -1.2% +1.9% 43 ≈
bundler/rescan 800 40.4 ms 38.3 ms -4.1% [-5.9, -1.9] -4.5% +1.7% 43 ≈
composer/hosted 800 49.3 ms 45.6 ms -6.9% [-7.5, -6.1] -9.0% +7.4% 43 ≈
composer/rescan 800 36.8 ms 28.4 ms -22.9% [-23.6, -21.2] -21.9% -2.0% 43 ✅ faster
cargo/hosted 600 110.8 ms 109.0 ms -1.1% [-3.0, -0.5] -2.9% +2.2% 33 ≈
cargo/rescan 600 85.5 ms 81.3 ms -4.5% [-5.3, -3.9] -4.4% +2.1% 33 ≈
golang/hosted 1200 54.5 ms 53.4 ms -2.3% [-3.3, -0.4] -1.9% +4.9% 64 ≈
golang/rescan 1200 42.2 ms 39.5 ms -6.5% [-7.3, -5.8] -6.2% +1.4% 64 ≈
nuget/hosted 900 52.4 ms 52.0 ms -1.1% [-2.2, -0.2] -2.2% +2.1% 53 ≈
nuget/rescan 900 39.0 ms 37.9 ms -2.3% [-3.4, -1.9] -3.3% +2.5% 53 ≈
maven/hosted 1000 90.1 ms 91.1 ms +1.1% [-0.9, +1.9] +0.1% +1.9% 53 ≈
maven/rescan 1000 78.9 ms 78.0 ms -1.0% [-1.5, -0.2] -0.5% +1.2% 53 ≈
gradle/hosted 1000 154.7 ms 147.2 ms -5.4% [-6.5, -3.8] -6.7% +2.0% 53 ≈
gradle/rescan 1000 129.8 ms 114.1 ms -12.2% [-12.9, -11.9] -12.5% +1.1% 53 ✅ faster
npm/dry-run 3000 88.6 ms 72.7 ms -17.8% [-18.0, -17.7] -9.5% +3.7% 67 ✅ faster
npm/public-proxy 3000 118.5 ms 102.9 ms -13.2% [-13.9, -12.5] -7.7% +2.9% 151 ✅ faster
npm/latency 3000 689.9 ms 667.9 ms -3.3% [-4.5, -2.3] -10.0% -0.7% 127 ≈

Fixes #567
Fixes #260

🤖 Generated with Claude Code


Note

High Risk
Changes when hosted mode writes lockfiles and how rollout caps classify pins—core hosted scan path with broad ecosystem impact, mitigated by extensive regression tests and performance work.

Overview
Hosted redirect now uses lockfile discovery as the single “is this pinned?” oracle, replacing substring/mentioned_uuids checks in rollout caps and the in-memory engine. Pins on foreign patch-server origins are recognized via a pre-lock HostedPin::discover pass; lockless NuGet/Cargo pins count in the recorded view and emit redirect_pin_lockless instead of looping scan remedies.

Attribution gate (v5.0): before committing hosted rewrites, engine::rewrite simulates the post-write project (overlay or in-memory) and runs the same discovery vex/rollback/vendor use. Candidates whose pins would be contested wiring are dropped as redirect_unattributable in redirect.skipped[] with no file writes (#567 Pipenv + unreached -r include, #260 Maven profile deps). Staged vendored→hosted takeovers stay exempt so wet runs cannot strand packages unpatched.

Performance and correctness plumbing: scan records a fingerprinted prior discovery (ProjectContext + DiskSnapshot::tracked, Prior::still_current under the apply lock) and reuses it when origins and on-disk state match; post-write classification uses discovery_after_writes / FinalDiscovery to avoid redundant full discovers. Discovery reads route through ProjectView; CLI contract documents the gate and new warning/skip codes.

Reviewed by Cursor Bugbot for commit 02b8bd1. Configure here.


Generated by Claude Code

Discovery read the disk directly in ten places (the requirements -r
walk, the Python lock listing, cargo's config resolution, Rush subspace
listing, nuget's spelling and feed probes, maven's vendored jars, sbt's
resolution evidence and hashes, vlt's bundled-store walk), so it could
only run over a real checkout. Route the content reads and listings
through ProjectView, keep the probes that need an installed tree behind
DiscoverCtx::disk_root() (they see nothing in memory), and add
discover_patched_refs_view() so the in-memory hosted engine can ask the
same "is this pinned" question the disk commands ask.

The view-based Python lock listing replaces the hosted engine's private
copy, the requirements include walk and cargo's effective-config probe
take a view, and the discovery future is boxed as Send (the in-memory
engine's future must be Send; vlt's bundled-store pairs are collected
first for the same reason). Behavior on disk is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The rollout had three answers to "is this patch already pinned": the
disk scan's discovery pins, plus a second pass (mark_pinned) that
counted any uuid-shaped token in any candidate text, and the in-memory
engine's memory_recorded, which counted any mention of an offered uuid
in any file. A stale hosted URL in a package.json field, an inactive
pdm.lock or a comment therefore read as ALREADY, and the row rode past
the --max-new-patches cap. Delete the mention scan (mentioned_uuids)
and decide both engines' recorded view with HostedPin::discover, the
discovery the management commands read. A pin on a patch server that is
not configured is recognized once the run's references name it: both
engines re-run discovery with those origins (foreign_dep_origins).

The disk scan now makes that check before it decides whether to take
the apply lock, so a run that writes a row found pinned this way locks
before it reads what it writes. The late lock taken after an unlocked
read is gone (audit B58).

Lockless NuGet / Cargo pins (an exclusive Socket source without
packages.lock.json, a registry pin without Cargo.lock) are contested
wiring that nothing can attribute to a version. Their refusal no longer
tells the user to re-run the hosted scan, which only writes the same pin
again: it names the lockfile to create, and the pin's own grant token no
longer shows up as a second contested patch (audit B13; whether such
pins should be written at all stays with the E45 decision).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A hosted run confirmed a redirect when the rewriters' own report or a
substring probe said its URL landed, while vex, list, rollback, remove
and vendor read the same files through lockfile discovery. When the two
disagreed the run reported success and wrote wiring every later command
refused as contested, with a remedy (re-run the scan) that changed
nothing: a Pipfile.lock rewired beside a requirements -r include that
still pins the registry release (#567), a Maven pin inside a <profile>
(#260).

engine::rewrite now runs discovery over the project as the rewrite
would leave it (a DiskSnapshot overlaid with the pending writes, or the
in-memory project plus them) and reads it as the management commands do
(HostedInventory). A confirmed candidate whose pin would be contested
wiring is dropped and the rest rewritten without it; it is reported in
redirect.skipped[] as redirect_unattributable, nothing is written for
it, and the exit code is unchanged. Both the disk scan and the in-memory
engine go through this one gate.

Kept as they were, and documented: a pin in a file discovery does not
read (a pre-2.6 bundler Gemfile, locked by the next bundle install), a
deliberate partial redirect the run already reports (a bundled or
bun-patched copy left on the registry, a dep withheld from the vlt
rewrite while a sibling lock takes it; which unreachable copies should
block a redirect is the audit's copy-source policy, B16), and lockless
NuGet / Cargo pins, which are written with a new redirect_pin_lockless
warning naming the lockfile to create (E45 decides whether they should
be written at all).

The engine's unit fixtures now use real uuids in their hosted urls, so
discovery recognizes the pins they land.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko Mikola Lysenko (mikolalysenko) added the arch-refactor PR opened by the scheduled architecture refactor routine label Oct 7, 2026
A wet `scan --mode hosted` reverts a purl's vendored wiring and saves the
ledger before the rewrite runs. The attribution gate then dropped such a
purl when discovery contested its pin (the #567 shape: an unreached
requirements -r include beside Pipfile.lock), so the run left the package
on the unpatched registry release and exited 1, while the dry run, whose
takeover previews never reach the gate, reported success.

A takeover's uuid is now exempt from the veto (RewriteOptions::
takeover_uuids) and keeps the rewriters' verdict, as before the gate. The
dry run and the wet run agree again. CLI_CONTRACT no longer claims the
gate runs before anything is written.

Also:
- describe_skip_reason has human text for redirect_unattributable
  instead of blaming the server.
- New tests: the takeover is never stranded (failed before this fix),
  the --json skip contract (reason, detail, redirected 0, exit 0), the
  redirect_pin_lockless warning for a lockless NuGet pin, and exit-code
  assertions on the #567 / #260 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
memory_recorded now reads discovery's pins, which can include files under
a nested root that discovery reaches through a requirements include or a
rush subspace. Those pins are that root's own, as they were under the
mention scan this replaced, so they are filtered out again. Pins to
patches the API no longer offers now count (as on disk); the doc comment
says so.

Also document that DiskSnapshot::overlay does not change directory
listings, and rename the vlt heal's private origin list to
vlt_heal_origins so it is not confused with rollback's
patch_server_origins (it also counts --api-url).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 7, 2026 17:30

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Gate omits configured patch-server origins
    • Added patch_server_origins field to RewriteOptions and related types to pass the --patch-server-url allowlist through to the attribution gate's discovery, ensuring it uses the same origins as management commands.

Create PR

Or push these changes by commenting:

@cursor push 4947219719
Preview (4947219719)
diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs
--- a/crates/socket-patch-cli/src/commands/list.rs
+++ b/crates/socket-patch-cli/src/commands/list.rs
@@ -431,7 +431,10 @@
                 detail: detail.clone(),
             });
         } else if !args.common.silent {
-            eprintln!("Warning: {}", crate::commands::rollback::capitalize_first(detail));
+            eprintln!(
+                "Warning: {}",
+                crate::commands::rollback::capitalize_first(detail)
+            );
         }
     }
     let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent);
@@ -773,12 +776,18 @@
         let listings = HostedListing::from_pins(
             &[
                 pin("pkg:npm/minimist@1.2.2", &record.uuid),
-                pin("pkg:npm/other@1.0.0", "33333333-3333-4333-8333-333333333333"),
+                pin(
+                    "pkg:npm/other@1.0.0",
+                    "33333333-3333-4333-8333-333333333333",
+                ),
             ],
             Some(&legacy),
         );
         assert_eq!(listings[0].record, record);
-        assert_eq!(listings[1].record.uuid, "33333333-3333-4333-8333-333333333333");
+        assert_eq!(
+            listings[1].record.uuid,
+            "33333333-3333-4333-8333-333333333333"
+        );
         assert!(listings[1].record.vulnerabilities.is_empty());
         assert_eq!(listings[1].lockfiles, vec!["yarn.lock".to_string()]);
     }

diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs
--- a/crates/socket-patch-cli/src/commands/mod.rs
+++ b/crates/socket-patch-cli/src/commands/mod.rs
@@ -1,7 +1,7 @@
 pub mod apply;
 pub(crate) mod bun_preflight;
+pub(crate) mod composer_hints;
 pub(crate) mod context;
-pub(crate) mod composer_hints;
 pub(crate) mod fetch_stage;
 pub mod get;
 pub mod hosted_bundle;
@@ -9,11 +9,11 @@
 pub(crate) mod lock_cli;
 pub mod remove;
 pub mod repair;
-pub(crate) mod vendored_backend;
 pub mod rollback;
 pub mod scan;
 pub mod update;
 pub mod vendor;
+pub(crate) mod vendored_backend;
 pub mod vex;
 pub(crate) mod vex_consumed;
 pub(crate) mod vex_sources;
@@ -136,9 +136,11 @@
     common: &crate::args::GlobalArgs,
     root: &Path,
 ) -> socket_patch_core::patch::redirect::RedirectState {
-    hosted_state_from_pins(&socket_patch_core::patch::redirect::upstream::HostedPin::all(
-        &discover_wiring(common, root).await,
-    ))
+    hosted_state_from_pins(
+        &socket_patch_core::patch::redirect::upstream::HostedPin::all(
+            &discover_wiring(common, root).await,
+        ),
+    )
 }
 
 /// [`hosted_state_from_lockfiles`] over already-discovered pins. A purl
@@ -148,10 +150,8 @@
 ) -> socket_patch_core::patch::redirect::RedirectState {
     let mut state = socket_patch_core::patch::redirect::RedirectState::new();
     for pin in pins {
-        state
-            .records
-            .entry(pin.purl.clone())
-            .or_insert_with(|| socket_patch_core::manifest::schema::PatchRecord {
+        state.records.entry(pin.purl.clone()).or_insert_with(|| {
+            socket_patch_core::manifest::schema::PatchRecord {
                 uuid: pin.uuid.clone(),
                 exported_at: String::new(),
                 files: Default::default(),
@@ -159,7 +159,8 @@
                 description: String::new(),
                 license: String::new(),
                 tier: String::new(),
-            });
+            }
+        });
     }
     state
 }
@@ -186,4 +187,3 @@
         }
     }
 }
-

diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs
--- a/crates/socket-patch-cli/src/commands/scan/discovery.rs
+++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs
@@ -168,29 +168,32 @@
     }
     // `(ledger key, base purl, entry)`; the artifact fallback has no
     // entries to probe, so it never reports unwired keys.
-    let candidates: Vec<(String, String, Option<&socket_patch_core::vendor::VendorEntry>)> =
-        match state {
-            Ok(state) => state
-                .entries
-                .iter()
-                .map(|(key, entry)| {
-                    (
-                        key.clone(),
-                        strip_purl_qualifiers(&entry.base_purl).to_string(),
-                        Some(entry),
-                    )
-                })
-                .collect(),
-            // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
-            // recover the vendored set from the committed artifacts, or
-            // `scan --prune` (whose ledger exemption also degrades to empty)
-            // would delete still-vendored packages' manifest entries and blobs.
-            Err(_) => vendored_purls_from_artifacts(common)
-                .await
-                .into_iter()
-                .map(|base| (base.clone(), base, None))
-                .collect(),
-        };
+    let candidates: Vec<(
+        String,
+        String,
+        Option<&socket_patch_core::vendor::VendorEntry>,
+    )> = match state {
+        Ok(state) => state
+            .entries
+            .iter()
+            .map(|(key, entry)| {
+                (
+                    key.clone(),
+                    strip_purl_qualifiers(&entry.base_purl).to_string(),
+                    Some(entry),
+                )
+            })
+            .collect(),
+        // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
+        // recover the vendored set from the committed artifacts, or
+        // `scan --prune` (whose ledger exemption also degrades to empty)
+        // would delete still-vendored packages' manifest entries and blobs.
+        Err(_) => vendored_purls_from_artifacts(common)
+            .await
+            .into_iter()
+            .map(|base| (base.clone(), base, None))
+            .collect(),
+    };
     // Composer by release identity: a ledger `@3.0.2.0` is the crawled
     // `@3.0.2`, not a second package to supplement.
     let key = |p: &str| composer_purl_identity(p).unwrap_or_else(|| normalize_purl(p).into_owned());
@@ -1045,7 +1048,9 @@
             ..GlobalArgs::default()
         };
         let state = socket_patch_core::vendor::load_state(root).await;
-        vendored_ledger_supplement(&args, crawled, &state).await.packages
+        vendored_ledger_supplement(&args, crawled, &state)
+            .await
+            .packages
     }
 
     /// A ledger entry vendored as `@3.0.2.0` is the crawled composer
@@ -1080,7 +1085,9 @@
             out.iter().map(|p| &p.purl).collect::<Vec<_>>()
         );
 
-        let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await.packages;
+        let out = vendored_ledger_supplement(&args, &[], &Ok(state))
+            .await
+            .packages;
         assert_eq!(
             out.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
             vec!["pkg:composer/psr/log@3.0.2.0"]
@@ -1183,7 +1190,10 @@
             let state = npm_ledger_with_lock(tmp.path(), lock.as_deref()).await;
             let out = vendored_ledger_supplement(&args, &[], &state).await;
             assert_eq!(
-                out.packages.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
+                out.packages
+                    .iter()
+                    .map(|p| p.purl.as_str())
+                    .collect::<Vec<_>>(),
                 vec!["pkg:npm/left-pad@1.3.0"],
                 "lock={lock:?}"
             );

diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs
--- a/crates/socket-patch-cli/src/commands/scan/hosted.rs
+++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs
@@ -1023,7 +1023,11 @@
             .map(|c| c.dep.patch_uuid.clone())
             .collect()
     };
-    let rewrite_options = || RewriteOptions {
+    // The `--patch-server-url` allowlist: extra patch-server origins the
+    // attribution gate recognizes when discovering attributable pins.
+    let patch_server_origins = crate::commands::rollback::patch_server_origins(common);
+    let rewrite_options = || {
+        RewriteOptions {
         dry_run: common.dry_run,
         targets_pipenv_lock,
         pipenv_major,
@@ -1036,6 +1040,8 @@
         npm_outer: &npm_outer,
         blocking: true,
         takeover_uuids: takeover_uuids.clone(),
+        patch_server_origins: patch_server_origins.clone(),
+    }
     };
     // The rollout gate plans again without its deferred rows: keep what
     // the second pass needs.
@@ -4787,19 +4793,43 @@
         use super::npm_allow_remote_one_line;
         let hosts = ["patch.socket.dev"];
         let cases = [
-            (npm_allow_remote_configured_detail(&hosts, true, false), "Note: set"),
-            (npm_allow_remote_configured_detail(&hosts, false, false), "Note: set"),
-            (npm_allow_remote_configured_detail(&hosts, true, true), "Note: would set"),
-            (npm_allow_remote_already_detail(&hosts), "Note: .npmrc already"),
-            (npm_allow_remote_user_set_detail(&hosts, "none"), "Warning: npm >=12"),
-            (npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), "Warning: npm >=12"),
+            (
+                npm_allow_remote_configured_detail(&hosts, true, false),
+                "Note: set",
+            ),
+            (
+                npm_allow_remote_configured_detail(&hosts, false, false),
+                "Note: set",
+            ),
+            (
+                npm_allow_remote_configured_detail(&hosts, true, true),
+                "Note: would set",
+            ),
+            (
+                npm_allow_remote_already_detail(&hosts),
+                "Note: .npmrc already",
+            ),
+            (
+                npm_allow_remote_user_set_detail(&hosts, "none"),
+                "Warning: npm >=12",
+            ),
+            (
+                npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"),
+                "Warning: npm >=12",
+            ),
             (npm_allow_remote_manual_detail(&hosts), "Warning: npm >=12"),
-            (npm_allow_remote_unreadable_detail(&hosts, "is a symlink"), "Warning: npm >=12"),
+            (
+                npm_allow_remote_unreadable_detail(&hosts, "is a symlink"),
+                "Warning: npm >=12",
+            ),
         ];
         for (detail, start) in cases {
             let line = npm_allow_remote_one_line(&detail);
             assert!(line.starts_with(start), "{line}");
-            assert!(!line.contains('\n') && line.ends_with("(details: --verbose)."), "{line}");
+            assert!(
+                !line.contains('\n') && line.ends_with("(details: --verbose)."),
+                "{line}"
+            );
         }
     }
 }

diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs
--- a/crates/socket-patch-cli/src/commands/scan/policy.rs
+++ b/crates/socket-patch-cli/src/commands/scan/policy.rs
@@ -11,9 +11,9 @@
 use socket_patch_core::api::types::PatchSearchResult;
 use socket_patch_core::manifest::schema::PatchManifest;
 use socket_patch_core::policy::{
-    canon, find_repo_root_with_warnings, policy_block, FilteredEntry, RetainedEntry, patch_severity_order, repo_relative_checked, sanitize, severity_name,
-    DiskPolicyFs, FilterReason, Offers, PolicyError, PolicySource, PolicyWarning, Root, SelectionPolicy,
-    PATCHES_DISABLED,
+    canon, find_repo_root_with_warnings, patch_severity_order, policy_block, repo_relative_checked,
+    sanitize, severity_name, DiskPolicyFs, FilterReason, FilteredEntry, Offers, PolicyError,
+    PolicySource, PolicyWarning, RetainedEntry, Root, SelectionPolicy, PATCHES_DISABLED,
 };
 use socket_patch_core::utils::purl::normalize_purl;
 
@@ -42,12 +42,18 @@
 /// Load the policy for `args` (4.5): `--global` scans have no repo and read
 /// no file; everything else reads the repo root's socket.yml.
 pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result<InvocationPolicy, PolicyLoadError> {
-    let overrides = args.socket_yml.overrides().map_err(PolicyLoadError::Usage)?;
+    let overrides = args
+        .socket_yml
+        .overrides()
+        .map_err(PolicyLoadError::Usage)?;
     let cwd = std::fs::canonicalize(&args.common.cwd).unwrap_or_else(|_| args.common.cwd.clone());
     if args.common.is_global() {
-        let policy = SelectionPolicy::load(&socket_patch_core::policy::MemoryPolicyFs::default(), &overrides)
-            .map_err(PolicyLoadError::Policy)?
-            .0;
+        let policy = SelectionPolicy::load(
+            &socket_patch_core::policy::MemoryPolicyFs::default(),
+            &overrides,
+        )
+        .map_err(PolicyLoadError::Policy)?
+        .0;
         return Ok(InvocationPolicy {
             policy,
             repo_root: cwd,
@@ -56,8 +62,8 @@
         });
     }
     let (repo_root, mut warnings) = find_repo_root_with_warnings(&cwd);
-    let (policy, load_warnings) =
-        SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides).map_err(PolicyLoadError::Policy)?;
+    let (policy, load_warnings) = SelectionPolicy::load(&DiskPolicyFs::new(&repo_root), &overrides)
+        .map_err(PolicyLoadError::Policy)?;
     warnings.extend(load_warnings);
     Ok(InvocationPolicy {
         policy,
@@ -141,7 +147,12 @@
 
 impl ScanPolicy {
     /// The policy for the project rooted at `root_dir`.
-    pub(crate) fn for_root(invocation: &InvocationPolicy, root_dir: &Path, explicit: bool, global: bool) -> Self {
+    pub(crate) fn for_root(
+        invocation: &InvocationPolicy,
+        root_dir: &Path,
+        explicit: bool,
+        global: bool,
+    ) -> Self {
         let root_dir = std::fs::canonicalize(root_dir).unwrap_or_else(|_| root_dir.to_path_buf());
         let project = repo_relative_checked(&invocation.repo_root, &root_dir).unwrap_or_default();
         let root_verdict = if global {
@@ -174,7 +185,9 @@
                 severity: None,
             });
         }
-        let announce_warnings = !invocation.warned.swap(true, std::sync::atomic::Ordering::Relaxed);
+        let announce_warnings = !invocation
+            .warned
+            .swap(true, std::sync::atomic::Ordering::Relaxed);
         Self {
             policy: invocation.policy.clone(),
             warnings,
@@ -227,7 +240,10 @@
     /// exclude stays in the query (so `upgradeAvailable` can be reported)
     /// but joins the retained set, which never reaches a writer.
     pub(crate) fn admit_crawled(&self, purl: &str) -> bool {
-        let verdict = self.root_verdict.clone().and_then(|()| self.policy.admits_purl(purl));
+        let verdict = self
+            .root_verdict
+            .clone()
+            .and_then(|()| self.policy.admits_purl(purl));
         let reason = match verdict {
             Ok(()) => return true,
             Err(reason) => reason,
@@ -337,7 +353,8 @@
             // (not when a lower-ranked admitted patch simply wins).
             let top_withheld = self.policy.admits_severity(patch_severity_order(&group[0]));
             if let Err(reason) = top_withheld {
-                let upgrade_withheld = chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
+                let upgrade_withheld =
+                    chosen.is_some() && chosen == recorded_at && recorded_at != Some(0);
                 if chosen.is_none() || upgrade_withheld {
                     report.filtered.push(FilteredEntry {
                         purl: Some(canon(&purl)),
@@ -525,17 +542,20 @@
         let verdict = if !policy.enabled() {
             Err(FilterReason::Disabled)
         } else {
-            root_verdict.clone().and_then(|()| policy.admits_purl(purl)).and_then(|()| {
-                // The floor only hides a package when none of its patches pass.
-                match group
-                    .iter()
-                    .map(|p| policy.admits_severity(patch_severity_order(p)))
-                    .find(Result::is_ok)
-                {
-                    Some(ok) => ok,
-                    None => policy.admits_severity(patch_severity_order(group[0])),
-                }
-            })
+            root_verdict
+                .clone()
+                .and_then(|()| policy.admits_purl(purl))
+                .and_then(|()| {
+                    // The floor only hides a package when none of its patches pass.
+                    match group
+                        .iter()
+                        .map(|p| policy.admits_severity(patch_severity_order(p)))
+                        .find(Result::is_ok)
+                    {
+                        Some(ok) => ok,
+                        None => policy.admits_severity(patch_severity_order(group[0])),
+                    }
+                })
         };
         if let Err(reason) = verdict {
             out.push((

diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs
@@ -4,8 +4,10 @@
 
 use std::collections::{BTreeMap, BTreeSet, HashSet};
 
-use socket_patch_core::rollout::{canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan};
 pub(crate) use socket_patch_core::rollout::stage::*;
+use socket_patch_core::rollout::{
+    canonical_base_purl, severity_label, MaxNew, MaxNewSource, Recorded, RolloutPlan,
+};
 
 use super::discovery::UpdateInfo;
 
@@ -208,11 +210,11 @@
 mod tests {
     use super::*;
     use socket_patch_core::api::types::PatchSearchResult;
+    use socket_patch_core::api::types::VulnerabilityResponse;
     use socket_patch_core::manifest::schema::PatchManifest;
-    use std::path::Path;
-    use socket_patch_core::api::types::VulnerabilityResponse;
     use socket_patch_core::manifest::schema::PatchRecord;
     use std::collections::HashMap;
+    use std::path::Path;
 
     fn offer(purl: &str, uuid: &str, published: &str, severities: &[&str]) -> PatchSearchResult {
         PatchSearchResult {
@@ -357,13 +359,21 @@
         let stored = manifest(&[("pkg:composer/psr/log@3.0.2.0", "old")]);
         let recorded = RecordedIndex::new(Some(&stored), &[]);
         let offers = offers_from_results(
-            &[offer("pkg:composer/psr/log@v3.0.2", "new", "2026-02-01T00:00:00Z", &["high"])],
+            &[offer(
+                "pkg:composer/psr/log@v3.0.2",
+                "new",
+                "2026-02-01T00:00:00Z",
+                &["high"],
+            )],
             false,
         );
         let rows = classify(&offers, &recorded, "");
         let plan = socket_patch_core::rollout::plan_rollout(
             rows.into_iter().map(|row| row.candidate).collect(),
-            &MaxNew { value: Some(0), source: MaxNewSource::Flag },
+            &MaxNew {
+                value: Some(0),
+                source: MaxNewSource::Flag,
+            },
             false,
             &BTreeSet::new(),
         );

diff --git a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
--- a/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
+++ b/crates/socket-patch-cli/src/commands/scan/rollout_args.rs
@@ -1,7 +1,6 @@
 //! `scan --max-new-patches` (see the rollout guide,
 //! `docs/configuration.md#gradual-rollout`).
 
-
 use clap::Args;
 pub(crate) use socket_patch_core::rollout::stage::RolloutCarry;
 use socket_patch_core::rollout::{resolve_max_new, MaxNew};
@@ -77,7 +76,6 @@
     }
 }
 
-
 #[cfg(test)]
 mod tests {
     use super::*;

diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs
--- a/crates/socket-patch-cli/src/commands/vendor.rs
+++ b/crates/socket-patch-cli/src/commands/vendor.rs
@@ -442,10 +442,7 @@
 /// entry (fail-safe): ecosystems other than npm, cargo and pypi (whose
 /// probe covers the requirements flavor only) have no in-use probe yet,
 /// and a missing/unreadable lockfile proves nothing.
-pub(crate) async fn dispatch_in_use_one(
-    entry: &VendorEntry,
-    project_root: &Path,
-) -> Option<bool> {
+pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option<bool> {
     match entry.ecosystem.as_str() {
         "npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await,
         // Cargo probes the lock entry's shape: detached + `[patch]` pointing
@@ -1237,8 +1234,7 @@
     // know (the ledger was ignored or dropped from the commit along with the
     // manifest) leaves every fresh install failing; the manifest keys above
     // cannot see it, so the references are read from the wiring itself.
-    let references =
-        crate::commands::vendored_backend::repair::scan_vendor_references(root).await;
+    let references = crate::commands::vendored_backend::repair::scan_vendor_references(root).await;
     for (eco, uuid, rel) in references {
         let ledgered = state
             .entries

diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs
--- a/crates/socket-patch-cli/tests/apply/apply_network.rs
+++ b/crates/socket-patch-cli/tests/apply/apply_network.rs
@@ -940,7 +940,10 @@
         "a legacy package archive must not cover the patch; stdout={stdout}\nstderr={stderr}"
     );
     let content = std::fs::read(tmp.path().join("node_modules/pkgcache/index.js")).unwrap();
-    assert_eq!(content, before, "the file must not be patched from the legacy archive");
+    assert_eq!(
+        content, before,
+        "the file must not be patched from the legacy archive"
+    );
 
     let requests = mock.received_requests().await.unwrap_or_default();
     let blob_path = format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}");
@@ -1043,10 +1046,7 @@
         v["summary"]["applied"], 1,
         "the drifted nested copy must be warn-overwritten.\nstdout={v:#}"
     );
-    assert_eq!(
-        v["summary"]["failed"], 0,
-        "no copy may fail.\nstdout={v:#}"
-    );
+    assert_eq!(v["summary"]["failed"], 0, "no copy may fail.\nstdout={v:#}");
 
     // The nested copy's blob was fetched on demand…
     let requests = mock.received_requests().await.unwrap();

diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
--- a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
+++ b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs
@@ -201,7 +201,9 @@
         "non-silent stderr must carry the {CODE} warning; got:\n{stderr}"
     );
     assert_eq!(
-        stderr.matches("Warning: bundler app config BUNDLE_PATH").count(),
+        stderr
+            .matches("Warning: bundler app config BUNDLE_PATH")
+            .count(),
         1,
         "exactly ONE warning line (not one per discovery call); got:\n{stderr}"
     );

diff --git a/crates/socket-patch-cli/tests/cli/covgap_output.rs b/crates/socket-patch-cli/tests/cli/covgap_output.rs
--- a/crates/socket-patch-cli/tests/cli/covgap_output.rs
+++ b/crates/socket-patch-cli/tests/cli/covgap_output.rs
@@ -168,9 +168,8 @@
         .expect("spawn socket-patch in PTY");
     drop(pair.slave);
 
-    let reader_handle = crate::pty_io::PtyOutput::spawn(
-        pair.master.try_clone_reader().expect("clone reader"),
-    );
+    let reader_handle =
+        crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
 
     // Watchdog: detached kill after `timeout`; a no-op if the child exits
     // naturally first.
@@ -261,7 +260,10 @@
         "\n",
         Duration::from_secs(15),
     );
-    assert_eq!(code, 0, "remove with bare Enter must succeed; got: {output}");
+    assert_eq!(
+        code, 0,
+        "remove with bare Enter must succeed; got: {output}"
+    );
     // The interactive confirm MUST have run — otherwise this test passes
     // vacuously against a regression that drops the TTY gate and
     // auto-proceeds. Match the distinctive prompt verbatim (the loose

diff --git a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
--- a/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
+++ b/crates/socket-patch-cli/tests/cli/interactive_prompts_e2e.rs
@@ -112,9 +112,8 @@
     // closed. The previous design used a chunked read+mpsc loop
     // because it interleaved with a try_wait poll; the simplified
     // design serializes wait → drop master → read_to_end joins.
-    let reader_handle = crate::pty_io::PtyOutput::spawn(
-        pair.master.try_clone_reader().expect("clone reader"),
-    );
+    let reader_handle =
+        crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader"));
 
     // Watchdog: detach a thread that kills the child after `timeout`.
     // The cloned ChildKiller is independent of the main `child`

diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs
--- a/crates/socket-patch-cli/tests/cli_config_fallback.rs
+++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs
@@ -59,8 +59,7 @@
     let mut cmd = Command::new(BINARY);
     // Human mode: core's proxy advisory (the oracle below) is muted under
     // `--json`/`--silent`.
-    cmd.args(["scan", "-e", "npm", "--cwd"])
-        .arg(project);
+    cmd.args(["scan", "-e", "npm", "--cwd"]).arg(project);
     for (key, _) in std::env::vars_os() {
         let name = key.to_string_lossy();
         if name.starts_with("SOCKET_") {
@@ -298,7 +297,9 @@
     json_cmd.arg("--json");
     let json_out = run(json_cmd);
     assert!(
-        json_out.stderr.contains("could not parse socket-cli config"),
+        json_out
+            .stderr
+            .contains("could not parse socket-cli config"),
         "the parse warning must reach stderr under --json too; got:\n{}",
         json_out.stderr
     );

diff --git a/crates/socket-patch-cli/tests/cli_get_silent.rs b/crates/socket-patch-cli/tests/cli_get_silent.rs
--- a/crates/socket-patch-cli/tests/cli_get_silent.rs
+++ b/crates/socket-patch-cli/tests/cli_get_silent.rs
@@ -25,10 +25,7 @@
     for var in GLOBAL_ARG_ENV_VARS {
         cmd.env_remove(var);
     }
-    for var in [
-        "SOCKET_SAVE_ONLY",
-        "SOCKET_ALL_RELEASES",
-    ] {
+    for var in ["SOCKET_SAVE_ONLY", "SOCKET_ALL_RELEASES"] {
         cmd.env_remove(var);
     }
     cmd.env("SOCKET_TELEMETRY_DISABLED", "1");

diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs
--- a/crates/socket-patch-cli/tests/cli_parse_list.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_list.rs
@@ -370,7 +370,11 @@
     let out = run_list_binary(tmp.path(), &["--json"]);
     let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim())
         .expect("stdout must be valid JSON envelope");
-    assert_eq!(out.status.code(), Some(0), "missing manifest is an empty list");
+    assert_eq!(
+        out.status.code(),
+        Some(0),
+        "missing manifest is an empty list"
+    );
     assert_eq!(v["status"], "success", "envelope: {v}");
     assert_eq!(v["summary"]["discovered"], 0, "envelope: {v}");
 }
@@ -1313,7 +1317,10 @@
     assert_eq!(v["status"], "success", "envelope={v}");
     let warnings = v["warnings"].as_array().expect("warnings[] present");
     assert_eq!(warnings.len(), 1, "envelope={v}");
-    assert_eq!(warnings[0]["code"], "redirect_ledger_corrupt", "envelope={v}");
+    assert_eq!(
+        warnings[0]["code"], "redirect_ledger_corrupt",
+        "envelope={v}"
+    );
     assert!(
         out.stderr.is_empty(),
         "--json must keep stderr clean: {}",

diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs
--- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs
@@ -366,7 +366,11 @@
 /// relied on the rejection get a test-visible flip instead of a silent one.
 #[test]
 fn multiple_targets_parse_in_order() {
-    let args = parse_rollback(&["pkg:npm/foo@1", "packages/api/**", "b0630680-4da6-45f9-bba8-b888e0ffd58c"]);
+    let args = parse_rollback(&[
+        "pkg:npm/foo@1",
+        "packages/api/**",
+        "b0630680-4da6-45f9-bba8-b888e0ffd58c",
+    ]);
     assert_eq!(
         args.targets,
         vec![

diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs
--- a/crates/socket-patch-cli/tests/cli_parse_scan.rs
+++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs
@@ -898,7 +898,11 @@
         ("NONE", None),
     ] {
         let args = parse_scan(&["--max-new-patches", raw]);
-        assert_eq!(args.rollout.max_new_patches, Some(MaxNewPatches(want)), "{raw}");
+        assert_eq!(
+            args.rollout.max_new_patches,
+            Some(MaxNewPatches(want)),
+            "{raw}"
+        );
     }
 }
 
@@ -989,20 +993,33 @@
     assert_eq!(parse_scan(&[]).socket_yml.min_severity, None);
     assert_eq!(overrides(&[], &[]).unwrap().min_severity, None);
     assert_eq!(
-        overrides(&["--min-severity", "High"], &[]).unwrap().min_severity,
+        overrides(&["--min-severity", "High"], &[])
+            .unwrap()
+            .min_severity,
         Some((Some(1), OverrideSource::Flag))
     );
     assert_eq!(
-        overrides(&["--min-severity", "none"], &[("SOCKET_MIN_SEVERITY", "critical")]).unwrap().min_severity,
+        overrides(
+            &["--min-severity", "none"],
+            &[("SOCKET_MIN_SEVERITY", "critical")]
+        )
+        .unwrap()
+        .min_severity,
         Some((None, OverrideSource::Flag))
     );
     assert_eq!(
-        overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")]).unwrap().min_severity,
+        overrides(&[], &[("SOCKET_MIN_SEVERITY", "moderate")])
+            .unwrap()
+            .min_severity,
         Some((Some(2), OverrideSource::Env))
     );
-    assert_eq!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "")]).unwrap().min_severity, None);
+    assert_eq!(
+        overrides(&[], &[("SOCKET_MIN_SEVERITY", "")])
+            .unwrap()
+            .min_severity,
+        None
+    );
     assert!(overrides(&[], &[("SOCKET_MIN_SEVERITY", "severe")]).is_err());
     assert!(try_parse_scan(&["--min-severity", "severe"]).is_err());
     assert!(overrides(&["--no-socket-yml"], &[]).unwrap().bypass);
 }
-

diff --git a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs
--- a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs
+++ b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs
@@ -149,7 +149,9 @@
     );
     let chatter = stderr_chatter(&stderr);
     assert!(
-        chatter.iter().any(|l| l.contains("could not be downloaded")),
+        chatter
+            .iter()
+            .any(|l| l.contains("could not be downloaded")),
         "--silent must keep the download-failure error (errors only, \
          never nothing); stderr was: {stderr:?}"
     );

diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
--- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
+++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
@@ -566,8 +566,7 @@
         "the human skipped line must name purl + reason; stderr=\n{stderr}"
     );
     assert!(
-        stderr.contains("Warning: ")
-            && stderr.contains("could not be reverted"),
+        stderr.contains("Warning: ") && stderr.contains("could not be reverted"),
         "the takeover pre-warning must reach human stderr; stderr=\n{stderr}"
     );
 }
@@ -814,7 +813,10 @@
     let lock_before = std::fs::read(root.join("package-lock.json")).unwrap();
 
     let assert_ignored = |code: i32, doc: &Value, label: &str| {
-        assert_eq!(code, 0, "{label}: a pre-v5 ledger is never an error: {doc:#}");
+        assert_eq!(
+            code, 0,
+            "{label}: a pre-v5 ledger is never an error: {doc:#}"
+        );
         assert_eq!(doc["status"], "success", "{label}: {doc:#}");
         assert!(
             !doc.to_string().contains("redirect-state.json"),
@@ -1017,7 +1019,10 @@
 
     for extra in [&[][..], &["--silent"][..]] {
         let (code, stdout, stderr) = scan_hosted(root, &server.uri(), extra, &[]);
-        assert_eq!(code, 0, "{extra:?}: an empty discovery exits 0; stderr=\n{stderr}");
+        assert_eq!(
+            code, 0,
+            "{extra:?}: an empty discovery exits 0; stderr=\n{stderr}"
+        );
         if extra.is_empty() {
             assert!(
                 stdout.contains("No patches available for installed packages."),
@@ -1404,16 +1409,22 @@
         ],
         &env,
     );
-    assert_eq!(code, 1, "a binary bun.lockb pin is refused: {stdout}\n{stderr}");
+    assert_eq!(
+        code, 1,
+        "a binary bun.lockb pin is refused: {stdout}\n{stderr}"
+    );
     let doc: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{e}: {stdout}"));
... diff truncated: showing 800 of 6730 lines

You can send follow-ups to the cloud agent here.

Comment thread crates/socket-patch-core/src/hosted/engine.rs
Comment thread crates/socket-patch-core/src/hosted/memory/mod.rs Outdated
same_file returned false on every non-Unix platform, so on Windows'
case-insensitive filesystem the one nuget.config was also recognized as
NuGet.config and NuGet.Config. A contested-wiring refusal then named the
file three times, and its `git checkout --` remedy listed three paths for
one file, which lockless_nuget_pin_refusal_names_the_lockfile_remedy
caught on windows-latest.

Compare volume serial + file index on Windows through the same-file crate
the core already depends on. Unix keeps its stat-only dev + inode check so
a FIFO under a config name is never opened.

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

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread crates/socket-patch-core/src/vendor/nuget_config.rs
…grades

The attribution gate discovered with only the hosts this run's grants
name, not the operator's --patch-server-url allowlist that vex, list,
rollback, remove and vendor read. An existing pin on a configured server
was invisible to the gate whenever this run's grants lived on another
host, so a write those commands would refuse as contested could land.
RewriteOptions now carries the configured origins and the gate discovers
over them plus the grants' hosts.

The second rollout pass (mark_pinned) only flipped a NEW row to ALREADY
when the selected uuid was pinned on a server only this run's references
name. An older patch pinned there stayed NEW, so an upgrade spent a NEW
cap slot (in both engines; in memory always, since it has no configured
origins). It now marks such a row Superseded.

Addresses Bugbot findings on #1058.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Blocked: CI still pending on 8da8a1a, so this is not marked ready yet.

  • Head already contained current origin/main (05ecc6e). No merge was needed.
  • Fixed the two open Bugbot findings from e25c17d in 8da8a1a and replied on both threads:
    • The attribution gate now also discovers over the operator's --patch-server-url origins (RewriteOptions::patch_server_origins), the same list the management commands use.
    • mark_pinned now marks a NEW row as Superseded when an older patch for the same package is pinned on a server only this run's references name. Before, that upgrade spent a NEW cap slot.
  • Local checks on 8da8a1a: clippy -D warnings is clean (lib/bin targets). core --lib 5634/5634 and hosted_inventory pass. The cli lib and hosted_memory_*, in_process_redirect_pipenv, in_process_get_hosted_ecosystems, mode_migration_pypi and scan_rollout_e2e tests all pass.
  • Bugbot: its auto-run on 8da8a1a is pending. The two threads are replied to but not resolved, because resolving needs GraphQL and the GraphQL budget was 0.
  • "Ready for review" label: not present.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] scan performance failure on 8da8a1a is a real regression. Reproduced locally, no push yet.

Local perf-profile bench (socket-patch-bench compare --runs 10) against origin/main:

scenario main this PR change
npm/hosted 282 ms 359 ms +25.9%
npm/rescan 257 ms 320 ms +25.1%
pnpm/rescan 211 ms 231 ms +12.8%

CPU is up about 20% and peak memory about 10%.

Cause: engine::rewrite (hosted/engine.rs) now loops rewrite_once + unattributed_pins. Each pass that confirms pins runs a full discover_patched_refs_view, even on a rescan that writes 0 files. That is a third full discovery per rewrite, on top of scan's ctx.discovery() (scan/mod.rs) and hosted_state_from_lockfiles. The CLI view is ProjectView::Disk, so the cost is parsing, not file reads, and overlaying a cached snapshot alone will not recover it.

Planned fix (next pass):

  1. Thread scan's pre-rewrite Discovery and the origins it was computed with into RewriteOptions as an Option. get and the in-memory engine pass None.
  2. In unattributed_pins, reuse it only when all of these hold:
    • nothing was written (ignoring synthetic sbt keys)
    • no binary files
    • the merged origin list matches the one the prior discovery used
    • no takeover or migration wrote first
  3. Have rewrite_once borrow CandidateFiles and python_metadata instead of cloning them.

redirect_unattributable and redirect_pin_lockless keep working, because the same discovery result feeds HostedInventory::of.


Generated by Claude Code

…g was written

Rescan benches recover (npm/rescan +25% -> +5%, pnpm/rescan +13% -> +2.5%)
but npm/hosted is still ~26% slower than main because a writing rewrite
still runs a full fresh discovery over the overlay. Not validated: clippy
and tests have not been re-run after the last edit. Parked for #1058.

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

Copy link
Copy Markdown
Collaborator Author

[agent] Needs a human decision: is the scan performance regression on this PR acceptable?

Two fix attempts this run. The second reuses scan's pre-rewrite discovery in the unattributed_pins gate when a pass writes nothing and the origins match. It is parked, not pushed here, on branch agent/wip-1058-discovery-reuse (f27cf57). Clippy and tests were not re-run after its last edit.

scenario before the fix with the fix
npm/rescan +25% +5%
pnpm/rescan +13% +2.5% (noise)
npm/hosted +26% +26%

npm/hosted still regresses because a rewrite that writes files needs a full fresh discovery over the overlay, and the cost is lockfile parsing. Closing that gap means also dropping the post-write hosted_state_from_lockfiles discovery in favor of the gate's result. That is safe only when origins match and nothing writes after the rewrite (vlt heal, .npmrc). It is a larger behavioral refactor.

Question: should we

  • (a) do that refactor on this PR,
  • (b) take the parked rescan fix and accept the npm/hosted cost with performance-regression-accepted, or
  • (c) rethink the unattributed_pins gate?

Generated by Claude Code

The hosted flow discovered the project's lockfiles three times per scan:
scan's own discovery, the attribution gate's discovery inside
`engine::rewrite`, and `hosted_state_from_lockfiles` after the writes
(plus a fourth inside `classify_overlap_takeover_with` when a vendored
ledger overlaps). This finishes the WIP reuse of scan's discovery in the
gate and adds the post-write reuse.

Gate (engine):
- The gate counts the same pins as the caller's discovery when no grant
  names an origin outside Socket's server and the configured ones
  (`foreign_dep_origins` is empty), instead of comparing origin lists as
  strings.
- `Rewritten::final_discovery` hands back the final pass's discovery when
  it was made with exactly the configured origins: `Prior` (the pass wrote
  nothing and reused the caller's) or `Overlaid` (the overlay of the
  pass's writes). `None` when nothing was confirmed or a foreign origin
  was counted.

CLI (`run_redirect_selected`, `discovery_after_writes`):
- The vlt heal touched the installed tree: discover again.
- The rewrite planned nothing: scan's discovery (None when a takeover
  wrote first), else the gate's of the unwritten project.
- Dry run: scan's discovery only (the overlay describes the preview).
- Files landed: the gate's overlaid discovery, when every written path
  was a regular file before the write or is a root `.npmrc` /
  `pnpm-workspace.yaml` (no discovery lists a directory for them; the
  overlay hides a created file only from listings).
- Otherwise a fresh `discover_wiring`.
The overlap classifier now takes that discovery instead of making its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The vlt heal can invalidate store entries, and lockfile discovery reads
the installed store for one thing only: the bundled copies of the lock's
nodes (#471). Falling back to a fresh discovery after every heal kept
vlt/hosted ~17% slower than main, and comparing the copies before and
after the heal cost a full store walk, as much as the discovery saved.

Discovery now records the copies its vlt extractor read
(`Discovery::vlt_bundled_copies`, `None` when it did not look), and the
heal hands back the copies after it (`healed_store`, which it already
walked for its bundled-copy warning). `discovery_after_writes` reuses a
discovery after a heal only when the two maps are equal; a discovery
that never looked at the store (`None`) is never reused after a heal.
The discovery golden renderer skips the field: every copy it lists is
already a contest and a diagnostic there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A hosted scan now runs three full discoveries (scan's own, the
attribution gate's over the pending rewrite, and the post-write hosted
state). Profiling npm/hosted showed the time going to per-discovery CPU,
not parsing: the rule-11 identity sweep over every read file (~30%),
a quadratic dedup in Discovery::resolved_elsewhere (~20%), and a full
url parse of every lock entry's registry url in DiscoverCtx::hosted_uuid.

- resolved_elsewhere no longer scans the list on every push; finalize
  already sorts and dedups it, and the first match contest_across_locks
  finds is the same either way.
- socket_identities is memoized process-wide by (SHA-256 of the swept
  text, sorted origin set), never by path, so an overlaid or rewritten
  lock is swept afresh. Texts under 16 KiB skip the memo; 64 entries max.
- The sweep finds all of its anchors in one Aho-Corasick pass (none of
  them overlaps itself, so each pattern's hits equal its match_indices),
  matches hosts case-insensitively instead of lowercasing a copy of the
  file, and skips the backslash folds when the text has no backslash.
- hosted_uuid answers a plain http(s) url on a host no accepted origin
  names without parsing it; anything not plainly a DNS name (IPs, %, \,
  userinfo, xn--, ...) still takes the full parse. A test checks the
  shortcut against hosted_patch_uuid over tricky spellings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
vlt discovery walks node_modules/.vlt for bundled copies on every
discovery, and the attribution gate's extra discovery doubled that cost
(vlt/hosted +14% in the bench). Each lock node paid a canonicalize
(realpath: one getattrlist per path component, root included) plus
several tokio::fs round-trips to the blocking pool.

The walk now runs as one spawn_blocking with std::fs, and checks that a
package directory resolves inside the project by lstat-ing only the
components under the root (shared prefixes once): all real directories
means the canonical path is the canonical root joined with it; a
missing or non-directory component is rejected as before; a symlinked
component still goes through canonicalize. vlt/hosted is now ~36%
faster than main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Gradle discovery re-ran the planner's project refusal (wrapper version,
Android/KMP and custom-lockFile lexes of every script) and the lock-path
listing for every pinned row, though neither depends on the row, and
compiled the wrapper-version regex on each call. PinnedRowChecks works
the build-level half out once, on the first row that asks, and the
regex is compiled once per process.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Scan's discovery is taken before the apply lock, so reusing it inside
the attribution gate partly undid #1058's B58 fix: a concurrent run that
held the lock and rewrote a lockfile between scan's read and ours left
the gate deciding on stale wiring.

The context's DiskSnapshot is now tracked: before it first touches a
path it fingerprints it (lstat, plus stat of a symlink's target: kind,
length, mtime, and dev/ino/ctime on Unix or the creation time elsewhere;
a directory keeps only kind and identity), and a directory it lists also
records its sorted entry names. `ProjectContext::recorded_discovery`
records the paths discovery touched (`DiskSnapshot::begin_recording` /
`end_recording`, a `ReadSet`). Under the apply lock, the hosted flow
reuses the prior discovery only when `ReadSet::unchanged()` re-stats
every path the same (`rollout::Prior::still_current`); otherwise the
gate discovers afresh under the lock. Stats and one readdir per listed
directory only, no file reads: ~37 paths, ~50 us on the bench fixtures.

Soundness is enforced at compile time: `DiskSnapshot::root` is private,
and every read that bypasses the view now goes through `root()`, which
marks an open recording unusable (`end_recording` returns `None`), or
`root_reading(paths)`, which declares exactly what it reads. The bundler
manifest probe (every project) declares its app and global config files,
and NuGet's same-file probe declares the two config spellings. The vlt
store walk and the sbt / Maven / NuGet feed probes still use `root()`, so
those projects never reuse the prior discovery and keep main's
discovery count.

The listing leaves out `.socket/` at the project root: taking the apply
lock creates it, no listing consumer selects it, and any read inside it
is fingerprinted on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…across them

gradle/hosted creates three files under `.socket/gradle/`, so the
post-write reuse fell back to a fresh discovery and the scenario sat at
the gate's edge (+8-9% wall, +11% CPU vs main).

The overlay snapshot now shows a file it would CREATE to every read the
view mediates: `list_dir` merges the overlaid children (also of a
directory the disk lacks), `python_lock_paths` adds overlaid root Python
locks, and `exists` / `exists_no_follow` answer for the directories an
overlaid file implies. This also lets the attribution gate see a created
file that discovery finds by listing, which it previously could not.

The gate's overlay snapshot is tracked, and `FinalDiscovery::Overlaid`
carries `view_only`: discovery made no read around the view (no
`DiskSnapshot::root()`). Such a discovery equals a discovery of the
written disk whatever the write created, so `discovery_after_writes`
reuses it for any created file. A discovery that did read around the
view keeps the `.npmrc` / `pnpm-workspace.yaml` allowlist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	crates/socket-patch-cli/src/commands/scan/hosted.rs
#	crates/socket-patch-core/src/hosted/memory/mod.rs
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: not labeling yet. Bugbot's "Lockless pins recast as new" thread (stage.rs, comment 4223060409) was resolved with no reply and no fix, and it still holds on 02b8bd1. In scan/hosted.rs (~L855–866), the foreign-origin re-classification passes mark_pinned only HostedPin::discover(view, &origins). That is HostedPin::all over refs, so lockless cargo/nuget pins in discovery.unlocked_pins are dropped. The main recorded view already counts them since 77e4bb0 (hosted_unlocked_pins in scan/mod.rs). So a lockless pin on a server that only this run's grants name still reads as NEW and uses up a --max-new-patches slot on every run. Suggested fix: in that pass, run discover_patched_refs_view once and feed mark_pinned both its refs and its unlocked_pins, the same way the recorded view does. The next burn-down run will pick this up.


Generated by Claude Code

@mikolalysenko
Mikola Lysenko (mikolalysenko) added this pull request to the merge queue Oct 8, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 02b8bd1. Configure here.

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 8, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: labeled Ready for review at 02b8bd1.

  • CI: 465/465 check runs green on the head (451 success, 14 skipped); no main-wide failures.
  • Bugbot: re-reviewed 02b8bd1 (Cursor Bugbot check success, 22:26 UTC); no open review threads. The commits since its last review (77e4bb0) are only merges from main.
  • No merge conflict with current main, no CHANGELOG.md change.
  • Slack announcement not sent this run (no Slack send tool in this session); the next run will retry.

Generated by Claude Code

Merged via the queue into main with commit 4d06019 Oct 8, 2026
466 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the arch-fix/pinned-check branch October 8, 2026 23:17
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Oct 8, 2026
#1058's attribution gate refuses a hosted pin that lockfile discovery
would contest. A yarn npm: alias entry left on the registry made it
refuse the whole package, so the direct entry was no longer pinned and
the scan exited 1. That broke the documented alias behavior: pin the
direct copy and warn that the alias copy stays unpatched.

The alias skip is a deliberate partial redirect that the run reports
and keeps out of the in-run VEX, just like the bundled copies the gate
already exempts. Treat it the same way.

Assisted-by: Claude Code:claude-opus-5-5
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Oct 8, 2026
Resolve conflicts with #1058 (hosted pin attribution through discovery):

- hosted/engine.rs: keep this branch's removal of bun_lock_present in
  favor of lock_inventory::bun_text_lock_drives; give the branch's
  default-trust test the new RewriteOptions fields.
- lock_inventory/bun.rs: DiskSnapshot's root is private on main, so
  bun_text_lock_drives reaches it through disk_root_reading([BUN_LOCK]),
  which also records the probe in the snapshot's read set (without it
  the gate's overlaid discovery was no longer view-only).
- CLI_CONTRACT.md: keep main's new attribution-gate paragraph and this
  branch's redirect_bun_non_registry_entry_skipped note.

Co-Authored-By: Claude <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Oct 9, 2026
main (#1058) dropped DiscoverCtx::root, which broke the merge-queue
build of this branch. The check now gets the root from
disk_root_reading and declares the project files it reads
(member_stray_lock_own_files). A read recording therefore stays usable,
and an npm-only discovery still reads only through the view. The
ancestors it walks are above the project, which no overlay of the
project's files changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118z8gCQpYXnzeeinQdsTEp
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Oct 9, 2026
Main (#1058) made DiskSnapshot::root private behind ProjectView::disk_root
and added three RewriteOptions fields; read the root through disk_root()
and fill the new fields in the test initializer.

Co-Authored-By: Claude <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Oct 9, 2026
A capped scan (--max-new-patches 0, SOCKET_MAX_NEW_PATCHES=0 or
socket.yml maxNewPatches: 0) deferred an already-applied hosted patch
as NEW once the project gained a second, unpinned copy of the same
version: an npm: alias, an unpinned npm-shrinkwrap.json twin, a
bundled copy. Lockfile discovery rightly stops attesting such a pin,
but the rollout read "not attestable" as "nothing recorded", so the
re-scan that would rewire the copy never ran and the remedy vex
prints looped forever (#1195, regression from #1058).

Discovery now keeps every ref it drops over an unpatched copy beside
it in Discovery::shadowed (npm's pair, same-lock, bundled and legacy
mirror rules, Bun and vlt copies, and the shared within-lock and
cross-lock contests). HostedPin::recorded adds those to the
attributable pins, and the disk and in-memory rollouts build their
recorded view from it. Attestation and management commands still use
HostedPin::all and are unchanged.

Fixes #1195

Assisted-by: Claude Code:claude-opus-5-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-refactor PR opened by the scheduled architecture refactor routine Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

3 participants