[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
scan --mode hosted redirects a uv project where the patched package shows up in [project] with more than one specifier. Examples: dependencies = ["six==1.16.0"] plus [project.optional-dependencies] extra = ["six>=1.15"], two extras with different floors, or one dependencies list with marker-split specifiers. The scan succeeds (exit 0, redirected: 1, uv sync --locked installs the patch). After that, rollback, remove and the hosted → vendored takeover all refuse:
cannot restore pkg:pypi/six@1.16.0 to its upstream registry entry: uv.lock: pyproject.toml declares six with different specifiers; which one each lock entry mirrors is not derivable; restore it from version control instead (`git checkout -- uv.lock`)
The mapping is derivable, though. The hosted rewrite keeps each requires-dist entry's marker (extra == 'extra', python_version < '3.10') and only swaps specifier for url. Each lock entry can therefore be matched to the declaration with the same marker (plus extra == '<name>' for an optional group), which is how uv itself produced it.
Impact
A floor in dependencies with a tighter or looser one in an extra (or an all extra that re-lists packages) is a common pyproject layout. On such a project a hosted patch can be applied but not unwound by any socket-patch command: rollback and remove exit 1, and vendor / scan --mode vendored fail redirect_revert_failed, so the user can't switch modes either. The only way out is git checkout. Vendored mode on the same project vendors and reverts byte-identically.
Repro (main 045d7ec, Linux, uv 0.8.17)
This uses the same local mock patch API as the earlier uv issues (batch / by-package / package / view routes plus a patched six-1.16.0 wheel), --patch-server-url for the mock origin, and SOCKET_PYPI_JSON_API pointing at a pypi.org pass-through.
SP="socket-patch --api-url $MOCK --api-token t --org test-org --patch-server-url $MOCK"
cat > pyproject.toml <<'TOML'
[project]
name = "uvp"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "idna==3.7"]
[project.optional-dependencies]
extra = ["six>=1.15"]
TOML
uv lock && git init -q && git add -A && git commit -qm init
$SP scan --mode hosted --json --yes # success, redirected 1
rm -rf .venv && uv sync --locked # installs the patched wheel (SOCKET_PATCHED == 1)
$SP rollback --json --yes # exit 1, partial_failure, hosted.failed[] = the error above
$SP remove pkg:pypi/six@1.16.0 --json --yes # exit 1, hosted_revert_failed
$SP vendor --json # exit 1, failed redirect_revert_failed
The hosted lock still carries the markers:
requires-dist = [
{ name = "idna", specifier = "==3.7" },
{ name = "six", url = "http://127.0.0.1:8765/patch/pypi/six/1.16.0/…/six-1.16.0-py2.py3-none-any.whl" },
{ name = "six", marker = "extra == 'extra'", url = "http://127.0.0.1:8765/patch/pypi/six/1.16.0/…/six-1.16.0-py2.py3-none-any.whl" },
]
Other pyprojects that reproduce:
dependencies = ["idna==3.7"] with [project.optional-dependencies] a = ["six==1.16.0"], b = ["six>=1.10"]
dependencies = ["idna==3.7", "six>=1.10; python_version < \"3.10\"", "six==1.16.0; python_version >= \"3.10\""]
Controls that roll back byte-identically:
- the same specifier in
dependencies and in an extra with a marker (six==1.16.0 and win = ["six==1.16.0; sys_platform == \"win32\""])
dependencies = ["six==1.16.0"] plus [dependency-groups] dev = ["six"] (groups are matched per group)
- vendored
vendor → vendor --revert on the failing pyproject
Expected vs actual
- Expected: CLI_CONTRACT.md, "Hosted unwind coverage" (pypi), restores
uv.lock "+ the paired pyproject.toml" with re-derived hashes. Its refusal list for uv covers non-pure wheels, exclude-newer / no-binary / no-build, registry ambiguity and [[distribution]] locks. It doesn't cover several specifiers for one name, and neither does docs/testing/uv-compatibility.md. If this case can't be unwound, the scan should refuse it up front, the way it already refuses marker forks (redirect_uv_project_unsupported), instead of writing a pin that can't be undone.
- Actual: the scan succeeds. Then rollback, remove and the takeover all exit 1 with "not derivable", and
uv.lock / pyproject.toml keep the hosted URL.
OS × uv matrix (Linux sandbox; this is pure TOML logic, so no probe run)
| uv |
deps + extra (a) |
two extras (c) |
marker-split deps (e) |
same specifier + marker (control) |
vendored revert |
| 0.4.30 |
❌ |
– |
n/a (scan refuses: uv locks two six versions) |
– |
– |
| 0.5.31 |
❌ |
– |
❌ |
– |
– |
| 0.8.17 |
❌ (2 runs; rollback, remove, takeover) |
❌ |
❌ |
✅ |
✅ |
| 0.12.22 |
❌ |
– |
❌ |
– |
– |
First bad
The hosted upstream restore arrived in v5 (#277). Release 4.0.0 reverted from a ledger fragment, so there's nothing earlier to bisect.
Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:801-812: declarations(Declared::Dist) flattens dependencies and every optional-dependency group into one list, losing which extra (and which marker) each specifier belongs to.
crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:951-972: declared_clauses then requires every declaration of the name to share one clause list and errors otherwise. Matching on the lock entry's own marker (the declaration marker, and-ed with extra == '<group>' for optional dependencies) would pick the right declaration.
This is related to #473 (include-group), but the root cause is separate: that one is the Declared::Dev arm skipping table members.
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
scan --mode hostedredirects a uv project where the patched package shows up in[project]with more than one specifier. Examples:dependencies = ["six==1.16.0"]plus[project.optional-dependencies] extra = ["six>=1.15"], two extras with different floors, or onedependencieslist with marker-split specifiers. The scan succeeds (exit 0,redirected: 1,uv sync --lockedinstalls the patch). After that,rollback,removeand the hosted → vendored takeover all refuse:The mapping is derivable, though. The hosted rewrite keeps each
requires-distentry'smarker(extra == 'extra',python_version < '3.10') and only swapsspecifierforurl. Each lock entry can therefore be matched to the declaration with the same marker (plusextra == '<name>'for an optional group), which is how uv itself produced it.Impact
A floor in
dependencieswith a tighter or looser one in an extra (or anallextra that re-lists packages) is a common pyproject layout. On such a project a hosted patch can be applied but not unwound by any socket-patch command:rollbackandremoveexit 1, andvendor/scan --mode vendoredfailredirect_revert_failed, so the user can't switch modes either. The only way out isgit checkout. Vendored mode on the same project vendors and reverts byte-identically.Repro (main
045d7ec, Linux, uv 0.8.17)This uses the same local mock patch API as the earlier uv issues (batch / by-package / package / view routes plus a patched
six-1.16.0wheel),--patch-server-urlfor the mock origin, andSOCKET_PYPI_JSON_APIpointing at a pypi.org pass-through.The hosted lock still carries the markers:
Other pyprojects that reproduce:
dependencies = ["idna==3.7"]with[project.optional-dependencies] a = ["six==1.16.0"],b = ["six>=1.10"]dependencies = ["idna==3.7", "six>=1.10; python_version < \"3.10\"", "six==1.16.0; python_version >= \"3.10\""]Controls that roll back byte-identically:
dependenciesand in an extra with a marker (six==1.16.0andwin = ["six==1.16.0; sys_platform == \"win32\""])dependencies = ["six==1.16.0"]plus[dependency-groups] dev = ["six"](groups are matched per group)vendor→vendor --reverton the failing pyprojectExpected vs actual
uv.lock"+ the pairedpyproject.toml" with re-derived hashes. Its refusal list for uv covers non-pure wheels,exclude-newer/no-binary/no-build, registry ambiguity and[[distribution]]locks. It doesn't cover several specifiers for one name, and neither does docs/testing/uv-compatibility.md. If this case can't be unwound, the scan should refuse it up front, the way it already refuses marker forks (redirect_uv_project_unsupported), instead of writing a pin that can't be undone.uv.lock/pyproject.tomlkeep the hosted URL.OS × uv matrix (Linux sandbox; this is pure TOML logic, so no probe run)
First bad
The hosted upstream restore arrived in v5 (#277). Release 4.0.0 reverted from a ledger fragment, so there's nothing earlier to bisect.
Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:801-812:declarations(Declared::Dist)flattensdependenciesand every optional-dependency group into one list, losing which extra (and which marker) each specifier belongs to.crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:951-972:declared_clausesthen requires every declaration of the name to share one clause list and errors otherwise. Matching on the lock entry's ownmarker(the declaration marker,and-ed withextra == '<group>'for optional dependencies) would pick the right declaration.This is related to #473 (
include-group), but the root cause is separate: that one is theDeclared::Devarm skipping table members.