Skip to content

Yarn berry hosted pin can't cover a descriptor added after pinning: re-scan refuses with an impossible "dedupe" remedy, and rollback reports success but leaves a lock every yarn install --immutable rejects (YN0028) #1082

Description

[agent] Found by the scheduled Yarn Berry (2+) bug-hunt routine (ledger #305).

Summary

A hosted berry pin is a root resolutions selector for each descriptor that existed when you ran the scan (for example "lpx@npm:^1.0.0": "<socket url>"). If someone later adds the same package under a different range, the selector doesn't cover it. That happens when a workspace member adds "lpx": "1.0.0", or when a new dependency depends on lpx@~1.0.0. Yarn then locks a second, separate registry entry "lpx@npm:1.0.0" beside the Socket URL entry, and installs that copy unpatched. Three things go wrong after that:

  1. A re-scan can't fix it. scan --mode hosted exits 0 and pins nothing (redirect_yarn_berry_ambiguous_entry): "yarn.lock holds 2 separate entries resolving lpx@1.0.0; run yarn install once to dedupe the lock, then re-run". Neither yarn install nor yarn dedupe merges the two entries, because the resolutions pin is what keeps them apart. The suggested fix can't work, and nothing else is offered.
  2. Rollback breaks the lock. rollback (and remove) exit 0 with "Restored pkg:npm/lpx@1.0.0", but they write back two blocks with the same resolution ("lpx@npm:1.0.0" and "lpx@npm:^1.0.0", both resolution: "lpx@npm:1.0.0"). Yarn merges those into "lpx@npm:1.0.0, lpx@npm:^1.0.0", so every fresh yarn install --immutable fails with YN0028.
  3. Lock-only vex on main attests not_affected while packages/b/node_modules/lpx is unpatched. Open PR Stop VEX attesting over yarn PnP, pnpm bundled and deno.lock copies #1033 already fixes this part (its berry same-lock rule; I checked its head 314038b: lock-only vex now drops the ref with the "stays UNPATCHED" warning). But Stop VEX attesting over yarn PnP, pnpm bundled and deno.lock copies #1033 says a re-run of scan "rewires every copy", and for berry hosted that isn't true (point 1). With Stop VEX attesting over yarn PnP, pnpm bundled and deno.lock copies #1033 merged, the project loses VEX and still has no way to re-pin.

Impact

This is a normal workflow: someone adds a dependency after the hosted pin. Afterwards one copy of the patched package installs unpatched, the scan reports nothing it can do (exit 0), and the documented undo (rollback) leaves a lockfile that CI's --immutable install rejects.

Repro (local registry + mock patch API; synthetic lpx@1.0.0)

mkdir -p proj/packages/b && cd proj
echo '{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*"],"dependencies":{"lpx":"^1.0.0"}}' > package.json
printf 'nodeLinker: node-modules\n' > .yarnrc.yml
yarn install
socket-patch scan --mode hosted --yes                # pins lpx@npm:^1.0.0 → socket url (resolutions + lock)
echo '{"name":"b","version":"1.0.0","dependencies":{"lpx":"1.0.0"}}' > packages/b/package.json
yarn install                                          # adds a separate "lpx@npm:1.0.0" registry entry
head -1 packages/b/node_modules/lpx/index.js          # unpatched
socket-patch scan --mode hosted --yes                # exit 0: "holds 2 separate entries … run `yarn install` once to dedupe"
yarn install && yarn dedupe && socket-patch scan --mode hosted --yes   # same refusal, still 2 entries
socket-patch rollback                                 # exit 0 "Restored …"
# fresh checkout of package.json/yarn.lock/.yarnrc.yml:
yarn install --immutable                              # YN0028 (lock would merge the two entries)

Expected vs actual

  • Expected: a re-scan pins the new descriptor too. The rewriter already pins merged multi-descriptor entries by adding one resolutions selector per range, so it can add lpx@npm:1.0.0 and fold the registry entry into the URL entry. If it can't, it should refuse with a remedy that works (exit non-zero, or at least name the descriptor to remove). CLI_CONTRACT.md / docs/ecosystems.md describe a re-run of scan as the way to re-wire a project whose lock drifted.
  • Expected: rollback leaves a lock that yarn install --immutable accepts (the bar every other berry rollback cell meets). If it can't produce one, it should fail loudly rather than report success.
  • Actual: as in the summary.

OS × version

OS yarn re-scan dead end rollback → YN0028 lock-only vex attests (main)
Linux 4.0.2 reproduces (2/2) reproduces (2/2) reproduces (2/2); dropped on PR #1033
Linux 4.18.1 reproduces (2/2) reproduces (2/2) reproduces (2/2); dropped on PR #1033
macOS / Windows — untested (the probe branches are blocked)

Tested on main 05ecc6e, and on PR #1033 head 314038b for the re-scan and VEX columns. Not bisected. Releases ≤4.0.0 pinned through an ::__archiveUrl= locator instead of resolutions.

Suspect code

  • crates/socket-patch-core/src/patch/redirect/mod.rs:4141-4152: targets.len() > 1 gives redirect_yarn_berry_ambiguous_entry with the dedupe remedy, even when one of the two targets is the existing Socket URL entry and the other is a registry entry whose descriptor just needs another selector.
  • crates/socket-patch-core/src/patch/redirect/upstream/npm.rs:546 (restore_berry): it restores the URL entry's keys as their own block next to an existing block with the same resolution:, and doesn't merge them the way yarn would.
  • crates/socket-patch-core/src/vex/discover/yarn.rs:405-409: a plain registry entry is only resolved_elsewhere, not an unpatched_copy (PR Stop VEX attesting over yarn PnP, pnpm bundled and deno.lock copies #1033 changes this).

Backlog review — 2026-10-08

Priority: P1 → P2. Adding a Yarn descriptor after pinning creates a refusal/broken immutable install. Retain the recovery fix; the dangerous VEX portion was addressed separately.

Activity

  1. mikolalysenko commented on Oct 7, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p1 (Yarn berry, npm-family). Not a duplicate. Point 3 (lock-only VEX) is addressed by open PR #1033, as the report notes. Points 1 (the ambiguous-entry refusal at redirect/mod.rs:4141) and 2 (restore_berry not merging blocks that share a resolution) have no open PR. #962 also ends in YN0028 after rollback, but its cause is a user yarn patch, so I'm not clustering them.


    Generated by Claude Code

  2. mikolalysenko commented on Oct 8, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Re-triage on main ea09714 (after #1033 and #1035). Linux, yarn 4.0.2 and 4.18.1, same repro as the report (workspace member adds lpx: 1.0.0 after a hosted pin of lpx@npm:^1.0.0).

    Point 4.0.2 4.18.1 Status
    3. Installed-tree vex attests the unpatched member copy not attested; warns patched_ref_unattributable ("that copy stays UNPATCHED") same fixed by #1033
    2. rollback exit 0, then fresh --immutable YN0028 now exit 1, status: error, nothing written; fresh --immutable passes, because the lock still holds the pin same now fail-closed
    1. Re-scan dead end scan --mode hosted exit 0, redirect_yarn_berry_ambiguous_entry ("run yarn install once to dedupe the lock") same still reproduces

    Point 1 is now a loop. The rollback refusal says: "reconcile the lockfiles (re-run socket-patch scan --mode hosted) or restore them from version control (git checkout -- package.json yarn.lock)". The re-scan refuses with the dedupe remedy, and yarn install/yarn dedupe don't merge the two entries. So the only way out is git checkout, which also throws away the member's new dependency. The member copy stays unpatched, and nothing in socket-patch can re-pin it or unwind it.


    Generated by Claude Code

  3. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Re-triage on main 9ab72d4 (5.0.0): point 2 has regressed. rollback and remove exit 0 with "Restored" again, and every fresh yarn install --immutable then fails YN0028. On ea09714 (run 31) the same repro refused with exit 1 and wrote nothing. I bisected it to f3c6313a (#1008, "Fix open npm issues"); its parent b17a114a still refuses.

    The same thing happens when the descriptor added after pinning is an npm: alias (lp: npm:left-pad@1.3.0), either at the root or in a member. ea09714 refused that shape too.

    Linux, yarn 4.0.2 and 4.18.1 (from @yarnpkg/cli-dist), node-modules linker, local mock patch API (--api-url/--proxy-url/--patch-server-url):

    mkdir -p ws/packages/a && cd ws
    echo '{"name":"a","version":"1.0.0"}' > packages/a/package.json
    echo '{"name":"app","version":"1.0.0","private":true,"workspaces":["packages/*"],"dependencies":{"left-pad":"^1.3.0"}}' > package.json
    printf 'nodeLinker: node-modules\n' > .yarnrc.yml; touch yarn.lock; yarn install
    socket-patch scan --mode hosted; yarn install
    (cd packages/a && yarn add left-pad@1.3.0)        # or: yarn add lp@npm:left-pad@1.3.0
    socket-patch rollback                              # exit 0, "Restored pkg:npm/left-pad@1.3.0 …"
    # fresh checkout of package.json + yarn.lock + .yarnrc.yml, cold cache:
    yarn install --immutable                           # YN0028

    After the rollback, the lock holds two blocks with the same resolution. Yarn would merge them into "left-pad@npm:1.3.0, left-pad@npm:^1.3.0":, which is why the immutable install rejects the lock:

    "left-pad@npm:1.3.0":
      version: 1.3.0
      resolution: "left-pad@npm:1.3.0"
    "left-pad@npm:^1.3.0":
      version: 1.3.0
      resolution: "left-pad@npm:1.3.0"
    
    Binary yarn member left-pad@1.3.0: rollback / remove left-pad lp: npm:left-pad@1.3.0: rollback / remove left-pad
    ea09714 4.18.1 exit 1, nothing written (fail-closed) exit 1, nothing written
    4d06019b (#1058) 4.18.1 exit 1 exit 1
    b17a114a (parent of #1008) 4.18.1 exit 1 exit 1
    f3c6313a (#1008) 4.18.1 exit 0, then YN0028 exit 0, then YN0028
    a80b89e, 21536b29 4.18.1 exit 0, then YN0028 exit 0, then YN0028
    9ab72d4 (main) 4.0.2, 4.18.1 exit 0, then YN0028 exit 0, then YN0028

    Likely cause: the #828 part of #1008 (the Discovery::shadowed list). contest_within_locks (crates/socket-patch-core/src/vex/discover/mod.rs:827-862) used to drop a ref whose own lock also installs an unpatched copy of the same name@version. It now shadows that ref, so HostedPin::all hands it to rollback/remove. That is right for an npm bundled copy, which sits in its own location. In a berry lock, though, the unpatched sibling is a block that yarn merges with the restored one. restore_berry (crates/socket-patch-core/src/patch/redirect/upstream/npm.rs:735-748) rewrites the pinned block to left-pad@npm:1.3.0 in place and never merges it into the existing block with that resolution (point 2 of the triage above). Before #1008, the contest refusal hid this. Either a berry same-lock sibling should keep refusing, or restore_berry needs to merge blocks that share a resolution.

    Vendored counterpart, same unmerged-block shape: vendor left-pad, then add lp: npm:left-pad@1.3.0 (the alias keeps a registry block, alias_skipped). vendor --revert, rollback and remove left-pad all exit 0, and fresh --immutable fails YN0028 on 4.0.2 and 4.18.1. The pre-vendor entry is restored next to the alias block that resolves to the same locator. The member-descriptor version of this was noted on #759 in run 29.

    Point 1 (the re-scan dead end) is unchanged.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions