[agent] Found by the scheduled Hatch bug-hunt routine (ledger #314).
Summary
In a Hatch project with two hosted patches (six==1.16.0 and toml==0.10.2 in [project].dependencies), scan --mode vendored takes over correctly and fresh envs are patched. But a later rollback (or remove of both purls) doesn't restore pyproject.toml. It leaves this behind:
[tool]
[tool.hatch]
[tool.hatch.metadata]
allow-direct-references = true
The takeover reverts the hosted wiring one package at a time (restore_upstream(.., std::slice::from_ref(pin), ..)). So when six is vendored, toml's hosted URL is still in pyproject, and with it the hosted-owned allow-direct-references = true. Six's vendored ledger records that state as its original, both for the hatch_document wiring and for the hatch_permission wiring. Rollback faithfully restores that "original", so the permission that hosted mode added is never removed.
Impact
After a full rollback, the project silently keeps allow-direct-references = true, which socket-patch added and the user never had. That turns off Hatchling's guard against direct references in project metadata. Without it, a later direct URL dependency builds a wheel that can't be uploaded to PyPI. Verified: with the leftover setting, adding foo @ https://example.com/foo-1.0-py3-none-any.whl builds fine. With the original pyproject, hatch build fails: Dependency #3 of field project.dependencies cannot be a direct reference unless field tool.hatch.metadata.allow-direct-references is set to true. The rollback also leaves the file non-byte-exact, so git status stays dirty.
Repro (Hatch 1.18.1; local mock patch API serving patched six 1.16.0 and toml 0.10.2 wheels)
cat > pyproject.toml <<'EOF'
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "app"
version = "0.1.0"
dependencies = ["six==1.16.0", "toml==0.10.2"]
EOF
cp pyproject.toml orig.toml
socket-patch scan --mode hosted --yes --json --ecosystems pypi … # exit 0, redirected 2
socket-patch scan --mode vendored --yes --json --ecosystems pypi … # exit 0, vendor_takeover_reverted_redirect for both; fresh env patched
socket-patch rollback --json … # exit 0
diff orig.toml pyproject.toml # + [tool] / [tool.hatch] / [tool.hatch.metadata] allow-direct-references = true
.socket/vendor/state.json after the takeover shows six's hatch_permission wiring with original already containing toml's hosted URL and allow-direct-references = true.
Expected vs actual
- Expected: docs/testing/hatch.md says "Each project patch records shared ownership of the direct-reference permission. Selective and preserved rollback retain the setting while any project direct reference remains, and restore its original value after the last reference is unwired." Here the original value was "absent", so after the last reference is unwired, pyproject should match the pre-hosted bytes.
- Actual:
allow-direct-references = true and the empty table headers stay.
Matrix (Linux, each run twice)
| Sequence |
Hatch 1.16.5 |
Hatch 1.18.1 |
| hosted (2 patches) → rollback |
— |
pass (byte-exact) |
| vendored (2 patches) → rollback |
— |
pass (byte-exact) |
| hosted → vendored, 1 patch → rollback |
— |
pass (byte-exact) |
| hosted → vendored, 2 patches → rollback |
fail |
fail |
hosted → vendored, 2 patches → remove both |
— |
fail (same leftover) |
| vendored → hosted, 1 or 2 patches → rollback |
— |
pass (byte-exact) |
The edits don't depend on the Hatch version or the OS. macOS / Windows were not probed.
Suspect code
crates/socket-patch-cli/src/commands/vendor.rs:2458: the takeover calls restore_upstream with a single pin per candidate inside the vendoring loop. So the next candidate's hosted wiring (and the shared permission it owns) is still live when the current candidate's vendored ledger snapshots original. Reverting all hosted pins in the takeover set before vendoring any of them (or having the Hatch permission ledger ignore a permission owned by live hosted wiring) would avoid it. The pnpm (#636) and uv (#670) lanes have similar two-package leftovers, but the mechanism there is different.
[agent] Found by the scheduled Hatch bug-hunt routine (ledger #314).
Summary
In a Hatch project with two hosted patches (
six==1.16.0andtoml==0.10.2in[project].dependencies),scan --mode vendoredtakes over correctly and fresh envs are patched. But a laterrollback(orremoveof both purls) doesn't restore pyproject.toml. It leaves this behind:The takeover reverts the hosted wiring one package at a time (
restore_upstream(.., std::slice::from_ref(pin), ..)). So when six is vendored, toml's hosted URL is still in pyproject, and with it the hosted-ownedallow-direct-references = true. Six's vendored ledger records that state as itsoriginal, both for thehatch_documentwiring and for thehatch_permissionwiring. Rollback faithfully restores that "original", so the permission that hosted mode added is never removed.Impact
After a full rollback, the project silently keeps
allow-direct-references = true, which socket-patch added and the user never had. That turns off Hatchling's guard against direct references in project metadata. Without it, a later direct URL dependency builds a wheel that can't be uploaded to PyPI. Verified: with the leftover setting, addingfoo @ https://example.com/foo-1.0-py3-none-any.whlbuilds fine. With the original pyproject,hatch buildfails:Dependency #3 of field project.dependencies cannot be a direct reference unless field tool.hatch.metadata.allow-direct-references is set to true. The rollback also leaves the file non-byte-exact, sogit statusstays dirty.Repro (Hatch 1.18.1; local mock patch API serving patched six 1.16.0 and toml 0.10.2 wheels)
.socket/vendor/state.jsonafter the takeover shows six'shatch_permissionwiring withoriginalalready containing toml's hosted URL andallow-direct-references = true.Expected vs actual
allow-direct-references = trueand the empty table headers stay.Matrix (Linux, each run twice)
removebothThe edits don't depend on the Hatch version or the OS. macOS / Windows were not probed.
Suspect code
crates/socket-patch-cli/src/commands/vendor.rs:2458: the takeover callsrestore_upstreamwith a single pin per candidate inside the vendoring loop. So the next candidate's hosted wiring (and the shared permission it owns) is still live when the current candidate's vendored ledger snapshotsoriginal. Reverting all hosted pins in the takeover set before vendoring any of them (or having the Hatch permission ledger ignore a permission owned by live hosted wiring) would avoid it. The pnpm (#636) and uv (#670) lanes have similar two-package leftovers, but the mechanism there is different.