Skip to content

fix(install): only revalidate release policy when the age gate moved - #710

Open
candrewlee14 wants to merge 3 commits into
nubjs:mainfrom
candrewlee14:fix/release-policy-revalidation-scope
Open

fix(install): only revalidate release policy when the age gate moved#710
candrewlee14 wants to merge 3 commits into
nubjs:mainfrom
candrewlee14:fix/release-policy-revalidation-scope

Conversation

@candrewlee14

@candrewlee14 candrewlee14 commented Aug 9, 2026

Copy link
Copy Markdown

Root cause and fix for #709.

The bug

With minimumReleaseAge set, nub install throws away the lockfile and re-picks every dependency to the newest version in range.

Why: nub install asks the engine to re-check the age gate against the locked versions. The engine decides whether that check is needed by comparing settings_hash — which includes the raw pnpm-workspace.yaml bytes, where catalog lives. 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.rs turns the check off, so frozen never takes that branch.

One repo, minimumReleaseAge: 4320, three catalog edits:

changed lines in pnpm-lock.yaml
before 14,492
after 6

The 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_hash next to it, same reason, and reuses the existing hash_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_state records the hash on any install, including nub ci, which never calls the resolver. So CI can record a tightened gate without checking anything.

Pre-existing — hash_settings already 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:

  • catalog edit moves settings_hash but not the age hash; an age-gate change moves both.
  • the recorded hash round-trips through the real write_state, plus missing state and old state files with no hash.

Not covered

Only the nub install path. #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.

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>
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview Aug 15, 2026 6:06pm

Request Review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 by write_state alongside the broad settings_hash, with #[serde(default)] so pre-field state files parse.
  • New release_policy_changed_since_last_runtrue on missing state or an empty field, otherwise a comparison against a freshly computed hash_release_policy.
  • Third conjunct on the revalidation triggerinstall/mod.rs now requires real age-policy drift on top of settings drift and age > 0, so a catalog / overrides / packageExtensions edit 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_hash but 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_policy resolves the age gate from ctx alone, while the > 0 check on the adjacent line honors opts.minimum_release_age_override — so the two halves of the same condition disagree about what the effective gate is. Latent only: every InstallOptions construction site in the tree passes None for that field, and nub routes its own age flags through cli_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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread vendor/aube/crates/aube/src/state.rs
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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 real write_state against a temp project and an empty LockfileGraph, then pins every branch of release_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 reach write_state, WriteStateInput, WriteStateLayout, read_state, and state_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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 falserelease_policy_changed_since_last_run now returns false both on missing state and on a state file whose release_policy_hash is empty, where each previously returned true.
  • 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +927 to +929
if state.release_policy_hash.is_empty() {
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants