[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
When vendored mode wires six through [tool.uv.sources], it rewrites six's entry in uv.lock's [package.metadata] requires-dist (or requires-dev) from { name = "six", specifier = "==1.16.0" } to { name = "six", path = ".socket/vendor/…" }. This matches uv, which drops the specifier for a path source (path_source_entry, crates/socket-patch-core/src/vendor/pypi_uv.rs:1450). It saves the old element as the record's original.
Because the specifier isn't in the lock any more, a later user edit to six's own requirement (uv add "six>=1.16,<1.17", or a hand edit plus uv lock) leaves uv.lock byte-identical. uv correctly sees nothing to relock, and uv sync --locked passes. The vendored record therefore still matches exactly, and vendor --revert / remove / rollback replace it with the saved original (pypi_uv.rs:830, through toml_surgery::replace_fragment, toml_surgery.rs:232). That writes back the stale specifier = "==1.16.0", while pyproject.toml now says six>=1.16,<1.17.
Result: both files lose every .socket/vendor reference and the command reports success (exit 0, no warning). But uv.lock's metadata no longer matches pyproject.toml, so uv sync --locked fails with "The lockfile at uv.lock needs to be updated".
Hosted mode does the right thing on the same shape. The vendored → hosted takeover (scan --mode hosted) and a hosted rollback after a single-clause edit both re-derive the current specifier from pyproject.toml, and --locked passes. Only the vendored revert path restores a recorded specifier blindly.
This is not #806 / #821. Those are about a relock that re-serializes an array, so the lock record drifts and only pyproject.toml is reverted. Here nothing in uv.lock drifts, both files are reverted, and the restored content itself is wrong. PR #822 (77686c4) fixes #806 / #821 but not this: verified, same failure.
Impact
- Any project that tightens or relaxes the vendored package's requirement while the patch is vendored (a routine
uv add pkg>=…) gets a broken frozen/locked install the moment the patch is reverted (vendor --revert, remove, rollback, and the GC pass behind scan --prune). CI on uv sync --locked goes red, while socket-patch reports success.
- A plain
uv sync silently relocks, which hides the corruption locally and makes it look like a lock churn caused by the user.
Repro
The mock serves POST /patch/package and the patched six 1.16.0 wheel with SRI sha512. It's the same harness as #806 / #821. uv resolves everything else from PyPI.
mkdir app && cd app
cat > pyproject.toml <<'EOF'
[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "attrs>=20"]
EOF
uv lock && uv sync
SP="socket-patch --api-url $MOCK --api-token fake --org acme --patch-server-url $MOCK --vendor-url $MOCK"
$SP scan --mode vendored --yes # uv.lock requires-dist: { name = "six", path = ".socket/vendor/…" }
uv add "six>=1.16,<1.17" # pyproject changes; uv.lock is byte-identical (cmp says so)
uv sync --locked # ok
$SP vendor --revert --yes --json # exit 0, status "success", no warning
grep 'name = "six", specifier' uv.lock # { name = "six", specifier = "==1.16.0" } <- stale
grep six pyproject.toml # "six>=1.16,<1.17"
uv sync --locked # error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
The same happens with six in a PEP 735 dev group (requires-dev), with a hand edit plus uv lock instead of uv add, and with remove pkg:pypi/six@1.16.0 / rollback pkg:pypi/six@1.16.0 in place of vendor --revert. Other specifiers fail too (>=1.15, ==1.16.*).
Expected vs actual
- Expected: the revert leaves a lock that matches the current pyproject.toml. Either it re-derives the requires-dist / requires-dev element from the current declaration, as hosted unwind already does (
redirect/upstream/uv.rs), or, if it can't, it treats the mismatch as drift and keeps both files (docs/testing/uv-compatibility.md: "Script and lock edits are treated as a pair… rather than restoring only one side"; CLI_CONTRACT.md: user-changed fragments are left alone with vendor_lock_entry_drifted). A revert should never report success on a project whose uv sync --locked it just broke.
- Actual: the stale
original element is written back verbatim, exit 0, status: "success", no events, and uv sync --locked fails.
Matrix
On main 045d7ec, "fail" means 0 vendor refs in both files, uv.lock specifier ==1.16.0, and uv sync --locked exits non-zero. Each cell is {hand edit + uv lock, uv add} × {dependencies, dev group} × {vendor --revert, remove}. "H takeover" is scan --mode hosted on the same edited project.
| OS |
uv 0.5.31 |
uv 0.8.17 |
uv 0.12.23 |
H takeover (all three) |
| Linux (sandbox + ubuntu-latest) |
fail (8/8) |
fail (8/8) |
fail (8/8) |
pass |
| macOS (macos-latest) |
fail (8/8) |
fail (8/8) |
fail (8/8) |
pass |
| Windows (windows-latest) |
fail (8/8) |
fail (8/8) |
fail (8/8) |
pass |
Linux, PR #822 77686c4, vendor --revert (deps, dev) |
– |
– |
fail |
– |
Not affected: a marker edit (six==1.16.0; python_version >= "3.9") on uv 0.8.17 / 0.12.23, because uv records nothing new and the restored ==1.16.0 still matches. On 0.5.31 the marker lands in the lock, which is the drift / half-revert case covered by #822.
Not bisected. The behaviour is inherent to the recorded-original design of the uv vendored backend and is present in every release on the matrix.
Suspect code
crates/socket-patch-core/src/vendor/pypi_uv.rs:826-845: the uv_lock_requires_dist / uv_lock_requires_dev revert arm restores rec.original whenever rec.new is still present, with no check that the original's specifier still matches pyproject.toml's declaration.
crates/socket-patch-core/src/vendor/pypi_uv.rs:1450 (path_source_entry): the specifier is dropped (correctly, matching uv), which is why no lock drift can ever signal the user's edit.
Probe run: https://github.com/SocketDev/socket-patch/actions/runs/37284791550 (bughunt/uv/20261005-spec-edit-revert, 3 OS × 0.5.31 / 0.8.17 / 0.12.23).
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
When vendored mode wires six through
[tool.uv.sources], it rewrites six's entry in uv.lock's[package.metadata] requires-dist(orrequires-dev) from{ name = "six", specifier = "==1.16.0" }to{ name = "six", path = ".socket/vendor/…" }. This matches uv, which drops the specifier for a path source (path_source_entry,crates/socket-patch-core/src/vendor/pypi_uv.rs:1450). It saves the old element as the record'soriginal.Because the specifier isn't in the lock any more, a later user edit to six's own requirement (
uv add "six>=1.16,<1.17", or a hand edit plusuv lock) leaves uv.lock byte-identical. uv correctly sees nothing to relock, anduv sync --lockedpasses. The vendored record therefore still matches exactly, andvendor --revert/remove/rollbackreplace it with the savedoriginal(pypi_uv.rs:830, throughtoml_surgery::replace_fragment,toml_surgery.rs:232). That writes back the stalespecifier = "==1.16.0", while pyproject.toml now sayssix>=1.16,<1.17.Result: both files lose every
.socket/vendorreference and the command reports success (exit 0, no warning). But uv.lock's metadata no longer matches pyproject.toml, souv sync --lockedfails with "The lockfile atuv.lockneeds to be updated".Hosted mode does the right thing on the same shape. The vendored → hosted takeover (
scan --mode hosted) and a hosted rollback after a single-clause edit both re-derive the current specifier from pyproject.toml, and--lockedpasses. Only the vendored revert path restores a recorded specifier blindly.This is not #806 / #821. Those are about a relock that re-serializes an array, so the lock record drifts and only pyproject.toml is reverted. Here nothing in uv.lock drifts, both files are reverted, and the restored content itself is wrong. PR #822 (
77686c4) fixes #806 / #821 but not this: verified, same failure.Impact
uv add pkg>=…) gets a broken frozen/locked install the moment the patch is reverted (vendor --revert,remove,rollback, and the GC pass behindscan --prune). CI onuv sync --lockedgoes red, while socket-patch reports success.uv syncsilently relocks, which hides the corruption locally and makes it look like a lock churn caused by the user.Repro
The mock serves
POST /patch/packageand the patched six 1.16.0 wheel with SRI sha512. It's the same harness as #806 / #821. uv resolves everything else from PyPI.The same happens with six in a PEP 735
devgroup (requires-dev), with a hand edit plusuv lockinstead ofuv add, and withremove pkg:pypi/six@1.16.0/rollback pkg:pypi/six@1.16.0in place ofvendor --revert. Other specifiers fail too (>=1.15,==1.16.*).Expected vs actual
redirect/upstream/uv.rs), or, if it can't, it treats the mismatch as drift and keeps both files (docs/testing/uv-compatibility.md: "Script and lock edits are treated as a pair… rather than restoring only one side"; CLI_CONTRACT.md: user-changed fragments are left alone withvendor_lock_entry_drifted). A revert should never report success on a project whoseuv sync --lockedit just broke.originalelement is written back verbatim, exit 0,status: "success", no events, anduv sync --lockedfails.Matrix
On main
045d7ec, "fail" means 0 vendor refs in both files, uv.lock specifier==1.16.0, anduv sync --lockedexits non-zero. Each cell is {hand edit +uv lock,uv add} × {dependencies,devgroup} × {vendor --revert,remove}. "H takeover" isscan --mode hostedon the same edited project.77686c4,vendor --revert(deps, dev)Not affected: a marker edit (
six==1.16.0; python_version >= "3.9") on uv 0.8.17 / 0.12.23, because uv records nothing new and the restored==1.16.0still matches. On 0.5.31 the marker lands in the lock, which is the drift / half-revert case covered by #822.Not bisected. The behaviour is inherent to the recorded-original design of the uv vendored backend and is present in every release on the matrix.
Suspect code
crates/socket-patch-core/src/vendor/pypi_uv.rs:826-845: theuv_lock_requires_dist/uv_lock_requires_devrevert arm restoresrec.originalwheneverrec.newis still present, with no check that the original'sspecifierstill matches pyproject.toml's declaration.crates/socket-patch-core/src/vendor/pypi_uv.rs:1450(path_source_entry): the specifier is dropped (correctly, matching uv), which is why no lock drift can ever signal the user's edit.Probe run: https://github.com/SocketDev/socket-patch/actions/runs/37284791550 (
bughunt/uv/20261005-spec-edit-revert, 3 OS × 0.5.31 / 0.8.17 / 0.12.23).