env_version reads the project's uv settings correctly, but uv also merges user-level and system-level configuration underneath them, and lc cannot see that. So an install setting on the machine can change which artifacts a sync materializes while env_version — and therefore every manifest — reports the environment as unchanged.
This is a known, deliberate residue rather than a regression: hashing machine state would make one commit answer differently on two hosts, so a colleague's clone would report every output as behind. It is recorded as a limitation in CLAUDE.md; this issue is to decide how to close it.
What is already handled
identity._uv_config reads settings the way uv itself does, including uv's replacement precedence: a uv.toml beside pyproject.toml makes [tool.uv] ignored wholesale, so hashing both would report two environments where uv installs one. Measured on uv 0.12.5:
pyproject [tool.uv] no-binary = true → no_binary: All
+ uv.toml (no-build = true) → no_binary: None, no_build: All
uv warns about the pair itself, and project.tool_warnings() already lifts that into the report.
What is not
User-level (~/.config/uv/uv.toml, %APPDATA%\uv\uv.toml) and system-level (/etc/uv/uv.toml, %PROGRAMDATA%\uv\uv.toml) configuration is merged underneath the project's. Measured:
pyproject [tool.uv] no-binary = true + ~/.config/uv/uv.toml no-build = true
→ no_binary: All, no_build: All # both applied; env_version sees only the first
Two properties make this narrower than it first looks, and both come from uv's docs:
- Those levels can only be
uv.toml, never pyproject.toml. So the surface is two known paths, and a check for it would be complete rather than a heuristic.
- Arrays concatenate across levels, project entries first. A user-level
no-binary-package = ["h5py"] therefore adds to the project's list rather than replacing it — so detecting the file's presence is not enough; the check has to look at which keys it sets.
Why there is no flag for this
The obvious fix — tell uv to consider only pyproject.toml — does not exist. Every lever treats [tool.uv] as one of the discovered configuration files. Measured, uv 0.12.5:
| attempt |
result |
--config-file pyproject.toml |
refused: "expects to receive a uv.toml file, not a pyproject.toml", then a parse error |
--config-file <empty uv.toml> |
kills the user-level file and the project's [tool.uv] — identical to --no-config |
--no-config / UV_NO_CONFIG=1 |
disables all persistent configuration, the project's own included |
--no-config is finer-grained than the docs suggest, but not in a way that helps. It still reads pyproject.toml as the project definition — a lock built with it carries the declared dependencies, requires-python, [dependency-groups], [tool.uv] default-groups and [tool.uv.sources]. What it drops is the settings slice:
kept under --no-config |
dropped |
[project] dependencies, requires-python |
no-binary, no-binary-package |
[dependency-groups] |
no-build, no-build-package |
[tool.uv] default-groups |
no-build-isolation, no-build-isolation-package |
[tool.uv.sources] |
config-settings |
|
[tool.uv.index] |
So seven of the eight settings in _INSTALL_SETTINGS would vanish, which would shrink env_version to sha256(uv.lock ‖ .python-version ‖ default-groups) with no machine-level hole at all. Two things block adopting it, and neither is about identity:
[tool.uv.index] is dropped — the canonical GPU mechanism (pin download.pytorch.org). Worse, the failure is asymmetric: [tool.uv.sources] survives while the index does not, so lc init's uv lock resolves against PyPI and the project half-works rather than failing loudly.
no-build-isolation-package is dropped — some packages do not install without it, so lc would fail to sync an environment the researcher can sync by hand.
Both are lc breaking working projects, not lc disagreeing with them.
Options
- Refuse what we cannot honour. Refuse a project-level
uv.toml (blocked in convergence, refused at run start), and refuse a user- or system-level uv.toml that sets one of the audited install settings — only those keys, so a cache-dir, link-mode or index-credentials config is untouched. The invariant "only pyproject.toml decides the environment" then holds by enforcement, env_version reads [tool.uv] alone, and _uv_config's precedence handling is deleted. Cost: the user-level half reaches outside the project and could block someone over a long-standing global setting.
- Report it. Add the user/system-level finding to
scan_lock's advisory tier, beside sdist_built and non_default_groups. Cheap and honest; the hole stays open, annotated.
--no-config on the sync/run hops only, never on uv lock. Both blockers above are resolution-time, so this could close the machine-level hole for everything except default-groups while leaving index pinning intact. Unmeasured: whether the settings a lock already resolved make the sync-side ones moot. That measurement is the prerequisite for taking this seriously.
- Leave it, as today: recorded in
CLAUDE.md, unguarded.
Layer 3 is the natural home for anything stronger than (2) — scrubbing the ambient UV_* namespace is already the launcher's job, and this is the same class of fact: machine state steering an environment that is supposed to be a function of the repository.
Measurements in this issue are uv 0.12.5 on Linux, via uv sync --show-settings and by building locks and comparing installed packages.
env_versionreads the project's uv settings correctly, but uv also merges user-level and system-level configuration underneath them, and lc cannot see that. So an install setting on the machine can change which artifacts a sync materializes whileenv_version— and therefore every manifest — reports the environment as unchanged.This is a known, deliberate residue rather than a regression: hashing machine state would make one commit answer differently on two hosts, so a colleague's clone would report every output as
behind. It is recorded as a limitation inCLAUDE.md; this issue is to decide how to close it.What is already handled
identity._uv_configreads settings the way uv itself does, including uv's replacement precedence: auv.tomlbesidepyproject.tomlmakes[tool.uv]ignored wholesale, so hashing both would report two environments where uv installs one. Measured on uv 0.12.5:uv warns about the pair itself, and
project.tool_warnings()already lifts that into the report.What is not
User-level (
~/.config/uv/uv.toml,%APPDATA%\uv\uv.toml) and system-level (/etc/uv/uv.toml,%PROGRAMDATA%\uv\uv.toml) configuration is merged underneath the project's. Measured:Two properties make this narrower than it first looks, and both come from uv's docs:
uv.toml, neverpyproject.toml. So the surface is two known paths, and a check for it would be complete rather than a heuristic.no-binary-package = ["h5py"]therefore adds to the project's list rather than replacing it — so detecting the file's presence is not enough; the check has to look at which keys it sets.Why there is no flag for this
The obvious fix — tell uv to consider only
pyproject.toml— does not exist. Every lever treats[tool.uv]as one of the discovered configuration files. Measured, uv 0.12.5:--config-file pyproject.tomluv.tomlfile, not apyproject.toml", then a parse error--config-file <empty uv.toml>[tool.uv]— identical to--no-config--no-config/UV_NO_CONFIG=1--no-configis finer-grained than the docs suggest, but not in a way that helps. It still readspyproject.tomlas the project definition — a lock built with it carries the declared dependencies,requires-python,[dependency-groups],[tool.uv] default-groupsand[tool.uv.sources]. What it drops is the settings slice:--no-config[project] dependencies,requires-pythonno-binary,no-binary-package[dependency-groups]no-build,no-build-package[tool.uv] default-groupsno-build-isolation,no-build-isolation-package[tool.uv.sources]config-settings[tool.uv.index]So seven of the eight settings in
_INSTALL_SETTINGSwould vanish, which would shrinkenv_versiontosha256(uv.lock ‖ .python-version ‖ default-groups)with no machine-level hole at all. Two things block adopting it, and neither is about identity:[tool.uv.index]is dropped — the canonical GPU mechanism (pindownload.pytorch.org). Worse, the failure is asymmetric:[tool.uv.sources]survives while the index does not, solc init'suv lockresolves against PyPI and the project half-works rather than failing loudly.no-build-isolation-packageis dropped — some packages do not install without it, so lc would fail to sync an environment the researcher can sync by hand.Both are lc breaking working projects, not lc disagreeing with them.
Options
uv.toml(blocked in convergence, refused at run start), and refuse a user- or system-leveluv.tomlthat sets one of the audited install settings — only those keys, so acache-dir,link-modeor index-credentials config is untouched. The invariant "onlypyproject.tomldecides the environment" then holds by enforcement,env_versionreads[tool.uv]alone, and_uv_config's precedence handling is deleted. Cost: the user-level half reaches outside the project and could block someone over a long-standing global setting.scan_lock's advisory tier, besidesdist_builtandnon_default_groups. Cheap and honest; the hole stays open, annotated.--no-configon the sync/run hops only, never onuv lock. Both blockers above are resolution-time, so this could close the machine-level hole for everything exceptdefault-groupswhile leaving index pinning intact. Unmeasured: whether the settings a lock already resolved make the sync-side ones moot. That measurement is the prerequisite for taking this seriously.CLAUDE.md, unguarded.Layer 3 is the natural home for anything stronger than (2) — scrubbing the ambient
UV_*namespace is already the launcher's job, and this is the same class of fact: machine state steering an environment that is supposed to be a function of the repository.Measurements in this issue are uv 0.12.5 on Linux, via
uv sync --show-settingsand by building locks and comparing installed packages.