fix(install): only revalidate release policy when the age gate moved - #710
fix(install): only revalidate release policy when the age gate moved#710candrewlee14 wants to merge 3 commits into
Conversation
Release-policy revalidation discards the lockfile and re-resolves the whole graph, but it triggers off `settings_hash`, which also fingerprints the raw `pnpm-workspace.yaml` bytes. Catalogs, overrides, and packageExtensions all live there, so in a catalog-based monorepo an ordinary dependency edit reads as age-policy drift and silently upgrades the entire graph to newest-in-range. Measured on a monorepo with `minimumReleaseAge: 4320`, editing three catalog entries: 14,492 lockfile lines moved, mostly unrelated packages (AWS SDK 3.1046 -> 3.1104, etc.). The same edit on 0.6.0, which predates `revalidate_release_policy`, moved 6 lines. Gate revalidation on a dedicated `release_policy_hash` covering only the resolved age settings, stored alongside `dep_build_policy_hash` and for the same stated reason: the broad settings hash is too coarse to answer this one question. A pick already admitted under an unchanged gate only ever gets older, so it stays admissible and needs no revalidation; a tightened gate (or narrowed excludes, or strict/paranoid flipping) still re-resolves. Missing or pre-field state revalidates once and then records the hash. The gate still applies to every newly resolved pick — this only stops discarding a lockfile whose picks were already admitted under the same policy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
ℹ️ No critical issues — one coverage gap inline, one caveat worth recording.
Reviewed changes — the full diff plus the surrounding revalidation, resolver, and state-write paths.
- New
InstallState::release_policy_hash— a blake3 fingerprint of only the resolved age-policy settings, written bywrite_statealongside the broadsettings_hash, with#[serde(default)]so pre-field state files parse. - New
release_policy_changed_since_last_run—trueon missing state or an empty field, otherwise a comparison against a freshly computedhash_release_policy. - Third conjunct on the revalidation trigger —
install/mod.rsnow requires real age-policy drift on top of settings drift andage > 0, so a catalog / overrides /packageExtensionsedit keeps normal prefer-frozen reuse instead of discarding the lockfile. - Comment-only change on the nub side, plus one unit test asserting a catalog bump moves
settings_hashbut not the policy hash, with an age-gate edit as the positive control.
I checked the supply-chain half independently rather than taking the PR body's word for it, and it holds: resolve_minimum_release_age feeds .with_minimum_release_age(...) unconditionally, so every newly resolved pick is still gated regardless of this flag; aube_resolver::MinimumReleaseAge has exactly the three fields hash_release_age_settings covers (minutes, exclude, strict || paranoid); and write_state and the new checker are reached with the same cwd and the same &opts.cli_flags, so the recorded hash genuinely round-trips. Worth noting for the record that aube-parity.yml fires on vendor/aube/** for pull requests, so the new test does run in CI on ubuntu and windows.
ℹ️ The "cases that genuinely need a re-resolve still re-resolve" argument has an ordering caveat
The PR body argues that a raised minimumReleaseAge always moves the hash and therefore still re-resolves. That is true of the hash, but not of the sequence: write_state is reached by FrozenMode::Frozen installs, which never call the resolver at all, so the run that first records a tightened gate can be a run that verified nothing. This is not introduced here — hash_settings already folds in the same age-policy bytes, so the identical hazard predates the PR — but it is worth recording so the safety argument is not later read as a guarantee.
Technical details
# Recorded policy hash can be written by an install that never checked the picks
## Affected sites
- `vendor/aube/crates/aube/src/state.rs:730` — `write_state` records
`release_policy_hash` unconditionally on every completed non-dry-run,
non-`--lockfile-only` install.
- `vendor/aube/crates/aube/src/commands/install/finalize.rs:564` — the call
site; reached under `FrozenMode::Frozen`, which short-circuits to the
lockfile-as-truth branch (`commands/install/resolve.rs:502-566`) and never
invokes the resolver.
- `crates/nub-cli/src/pm_engine/install_family.rs:1445` — `nub ci` builds
`FrozenMode::Frozen` with `revalidate_release_policy` left at its `false`
default.
## The sequence
Raise `minimumReleaseAge`; the next command to run is `nub ci` (CI usually
beats a developer to it). It records the new policy hash without checking a
single pick. Every later `nub install` then sees no policy drift and never
revalidates the tightened gate.
## Required outcome
Nothing in this PR. Pre-existing: `hash_settings` already contains
`hash_release_age_settings` (`state.rs:1498`), so the same blessing happened
via `settings_hash` before this change. Recorded so the invariant is stated
accurately — "an age-policy change always forces a re-resolve" is true only
when the first install after the change is one that resolves.
## Open questions for the human
- Should a policy-hash write be gated on the install having actually run the
resolver (i.e. skip recording it on the frozen / lockfile-fresh paths), so
the recorded value always means "these picks were checked under this gate"?
That is a separate change from this PR's narrowing, and arguably the more
correct shape for both hashes.ℹ️ Nitpicks
hash_release_policyresolves the age gate fromctxalone, while the> 0check on the adjacent line honorsopts.minimum_release_age_override— so the two halves of the same condition disagree about what the effective gate is. Latent only: everyInstallOptionsconstruction site in the tree passesNonefor that field, and nub routes its own age flags throughcli_flags(pm_engine/mod.rs:1114-1122), which the hash does see. Worth a line in the doc comment noting the override is deliberately out of scope, if it is.
Claude Opus | 𝕏
The hash-comparison test did not cover the link that makes the narrowing work: `write_state` records the hash and `release_policy_changed_since_last_run` compares against it. If those two stop agreeing, the checker returns `true` forever and revalidation silently reverts to firing on every settings change, while the existing test still passes. Drives the real `write_state` (an empty `LockfileGraph` is enough) and pins all three documented branches: missing state, a state file predating the field, and a recorded hash matching the current policy. Also asserts the catalog-edit and tightened-gate cases end to end rather than at the hash level. Verified the test bites: blanking the write in `write_state` fails it on the "an unchanged age gate must not revalidate" assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the delta since the prior review at ec9af74, which is the single test-only commit 4db0ccf, plus a re-check of the write_state / checker pair it exercises.
- Added
recorded_release_policy_hash_round_trips_through_write_state— drives the realwrite_stateagainst a temp project and an emptyLockfileGraph, then pins every branch ofrelease_policy_changed_since_last_run: missing state →true, recorded-and-unchanged →false, catalog edit →false, raised gate →true, and a state file with the field blanked →true. - Widened the test-module
use super::{…}list to reachwrite_state,WriteStateInput,WriteStateLayout,read_state, andstate_dir; no production code moved in this commit.
I built and ran it rather than taking the mutation claim on trust. cargo test -p aube --lib from vendor/aube compiles and passes the new test, the whole state::tests module stays green, and it fails for the right reason under both mutations: blanking release_policy_hash in write_state panics at state.rs:2773 on an unchanged age gate must not revalidate, and pointing the checker at settings_hash panics at state.rs:2785 on a catalog edit must not revalidate. Driving the real write_state instead of a hand-written InstallState is strictly stronger than what the prior comment suggested — a literal would still pass if write_state ever stopped calling hash_release_policy. That closes the round-trip gap, and the ordering caveat is now stated accurately in the PR body; whether to gate the hash write on the run having actually resolved remains a scope call for the maintainer, unchanged by this commit.
Claude Opus | 𝕏
An empty or missing `release_policy_hash` meant "revalidate", which discards the lockfile and re-picks every range at newest. Every nub before this field wrote state without it, so the first install after upgrading that also carried any settings drift still re-resolved the whole graph — the exact symptom this branch removes, handed to every upgrading user once. The two directions are not symmetric. Guessing "changed" costs a whole-graph re-resolve; guessing "unchanged" only defers a gate re-check, and that exposure is bounded: a locked pick was admitted under some earlier gate and has only aged since, so the sole miss is a pick younger than a gate raised while there was no record, which the next real policy change re-checks. Fail toward the lockfile. Tests pin both halves — an unknown policy does not revalidate, and a raised gate still does once a hash is on record.
There was a problem hiding this comment.
Important
The newest commit flips the unknown-previous-policy default, which silently skips the age-gate re-check on a project's first post-upgrade install. Detail inline.
Reviewed changes — the delta since the prior review at 4db0ccf, which is the single commit 6bce930, plus a re-check of the revalidation path it feeds.
- Flipped the unknown-previous-policy answer to
false—release_policy_changed_since_last_runnow returnsfalseboth on missing state and on a state file whoserelease_policy_hashis empty, where each previously returnedtrue. - Rewrote the rationale on both doc comments — the field doc and the function doc now argue the asymmetry between guessing "changed" (one whole-graph re-resolve per upgrading user) and guessing "unchanged" (a deferred gate re-check).
- Inverted the matching test assertions and added a positive control — the missing-state and blanked-field cases now assert no revalidation, and a new block re-records the hash and raises the gate to confirm the new default doesn't leave the checker permanently silent.
I checked the two claims the flip rests on rather than reading them off the doc comment. install_settings_changed_since_last_run returns false when state is absent (state.rs:317-319), so the first conjunct short-circuits and the missing-state arm is unreachable from the only call site — that half of the flip is behavior-neutral. The empty-field arm is not, and resolve.rs:567-573 is the only place the age gate is re-applied to already-locked picks: the sibling drift checks that run in its place look at catalogs, workspace manifests, patches, and packageExtensions, never at publish time.
Claude Opus | 𝕏
| if state.release_policy_hash.is_empty() { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
An empty release_policy_hash means the state predates this field, which is every existing project on its first install after upgrading — and the doc above rests the safety case on the pick having been "admitted under some earlier gate", which is the one thing an empty hash cannot establish. On main and at ec9af74, raising minimumReleaseAge on such a project re-resolves and drops picks that miss the new cutoff; here the lockfile is reused untouched and the same install records the hash, so the raise is never applied to those picks.
Technical details
# An unknown previous policy grandfathers picks the newly raised gate would reject
## Affected sites
- `vendor/aube/crates/aube/src/state.rs:927-929` — the empty-field branch answers `false`.
- `vendor/aube/crates/aube/src/state.rs:916-919` — the doc's safety argument assumes the pick was admitted under a known earlier gate; an empty hash means no gate is on record, which is a different claim.
- `vendor/aube/crates/aube/src/commands/install/resolve.rs:567-573` — the only place the age gate is re-applied to locked picks. The drift checks that run instead (`check_catalogs_drift`, `check_drift_workspace_for_kind`, `check_patched_dependencies_drift`, `package_extensions_drift`) never read publish time, so nothing downstream catches the skip.
- `vendor/aube/crates/aube/src/state.rs:733` — the same install records the hash, so the skipped check is not retried on the next run.
## The sequence
`try_install_fast_path` (`commands/install/mod.rs:785-791`) returns before `write_state`, so a warm project records no `release_policy_hash` until an install does real work — and raising `minimumReleaseAge` is exactly such an install. On that install: settings drift `true`, gate `> 0` `true`, new conjunct `false` → lockfile reused as-is, new hash recorded. Picks sitting between the old and new cutoff are kept, and stop violating the gate only by aging past it, not by any check.
## Required outcome
Either the upgrade hop re-checks the gate, or the doc comment and PR body say plainly that a gate raised on a project's first working install after upgrade is not applied to existing picks. If the trade-off stands as written, the argument should rest on the exposure being wall-clock bounded rather than on the pick having cleared a known earlier gate.
## Suggested approach
The two unknowns are separable. `read_state` returning `None` is already unreachable from the call site (`install_settings_changed_since_last_run` returns `false` on missing state, `state.rs:317-319`), so leaving that arm at `false` costs nothing. Answering `true` on the empty-FIELD arm alone restores the pre-PR check for exactly one install per project while keeping every steady-state catalog edit on the fast path — the churn #709 reports is the repeated case, not the one-time one.
## Open questions for the human
- Is one whole-graph re-resolve per project on the upgrade hop an acceptable price for not silently skipping a raised gate? That is the entire difference between `ec9af74` and `6bce930`, and it is a posture call rather than a code question.
Root cause and fix for #709.
The bug
With
minimumReleaseAgeset,nub installthrows away the lockfile and re-picks every dependency to the newest version in range.Why:
nub installasks the engine to re-check the age gate against the locked versions. The engine decides whether that check is needed by comparingsettings_hash— which includes the rawpnpm-workspace.yamlbytes, wherecataloglives. So editing a catalog entry looks like an age-gate change. And the "check" is done by resolving as if there were no lockfile.That also explains the frozen/prefer-frozen split in #709:
ci.rsturns the check off, so frozen never takes that branch.One repo,
minimumReleaseAge: 4320, three catalog edits:pnpm-lock.yamlThe flag doesn't exist in v0.6.0 and does in v0.7.0, matching #709.
The fix
Compare a hash of just the age settings, not the whole settings hash. Follows the existing
dep_build_policy_hashnext to it, same reason, and reuses the existinghash_release_age_settings.Safe because a locked version only gets older — if it passed this gate before, it still does. Newly resolved versions are still gated as usual; this only stops discarding a lockfile that was already checked under the same settings.
One limit
A tightened gate moves the hash, so the next install that resolves will re-resolve. But it doesn't guarantee that install is the next one to run:
write_staterecords the hash on any install, includingnub ci, which never calls the resolver. So CI can record a tightened gate without checking anything.Pre-existing —
hash_settingsalready included the same bytes — but my first draft claimed more than that, so I'm stating it straight. Can close it separately if you want (only record the hash when the run actually resolved).Checks
903 tests pass, clippy clean,
cargo fmt.Two tests, both mutation-checked so I know they bite:
settings_hashbut not the age hash; an age-gate change moves both.write_state, plus missing state and old state files with no hash.Not covered
Only the
nub installpath. #709 also saw standalone aube 1.38.0 do this, and standalone aube leaves this off by default — so there may be a second cause I haven't touched.Not a regular contributor here, so say the word if a stored hash is the wrong shape. The narrowing is the point, not the mechanism.