fix(resolver): fail closed when publish time can't prove a version's age - #602
Conversation
…ublish age
`minimumReleaseAge` treated a version with no `time:` entry as eligible, so
any packument lacking publish times silently disabled the gate — including
under `minimumReleaseAgeStrict=true`, whose whole point is to fail closed.
Nub pins the gate on by default (1440 minutes, strict), so the advertised
24-hour trust floor could be bypassed with no warning and no error.
`passes_effective_cutoff` now distinguishes three cases instead of one:
- time known -> compare it, as before
- times present, this one not -> block. A hole in an otherwise-populated
map is anomalous, never a reason to admit.
- no per-version times at all -> fall back to the packument's `modified`,
an upper bound on every version's publish
time. `modified <= cutoff` proves the whole
document predates the wall; otherwise the
version is blocked.
The `modified` fallback is what keeps abbreviated (corgi) metadata usable
without a full-packument fetch for the dormant majority of a dependency
tree, so failing closed does not cost a request per package.
Matches pnpm 11, which excludes an undated version in a dated map
(`filterPkgMetadataByPublishDate`) and derives maturity from `modified`
otherwise (`pickPackageFromMeta`), rather than the fail-open this replaces.
Also gives `registrySupportsTimeField` a `workspaceYaml` source. pnpm reads
it only from `pnpm-workspace.yaml`, while aube read it only from `.npmrc` —
exactly inverted, so a pnpm project setting it in the file pnpm actually
reads was silently ignored.
BEHAVIOR CHANGE: a registry that serves full packuments carrying no `time`
map at all now blocks any package whose `modified` is newer than the cutoff,
where it previously installed. `minimumReleaseAgeExclude` or
`minimumReleaseAge=0` are the escapes.
Closes #581
…e-pick `resolve::vulnerable` carried its own copy of the cutoff comparison, with a comment claiming it mirrors `pick_version`'s. It did — until the previous commit made `pick_version` fail closed on an undeterminable publish age, leaving this path still admitting any version with no `time:` entry. That path re-picks a version away from a known vulnerability, so failing open there lands the install on an unvetted fresh publish precisely when the resolver is already reacting to a security signal. Hoist the comparison into `semver_util::version_clears_cutoff` and call it from both sites, so the wall has one definition and the two cannot drift again. Refs #581
There was a problem hiding this comment.
Copilot wasn't able to review any files in this pull request.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The previous commit added a `workspaceYaml` source for the setting but no matching field on `WorkspaceConfig`, so the key deserialized into `extra` and was silently unread. `aube-settings`' own metadata self-test caught it (`workspace_yaml_keys_deserialize_onto_workspace_config`). Add a resolution test alongside it: the self-test only proves the metadata names a real field, and would still pass on a field nothing ever reads. Refs #581
There was a problem hiding this comment.
Important
The fail-closed direction is right, but it is implemented unconditionally, which changes standalone aube's default behavior (fork-discipline) and skips the minimumReleaseAgeIgnoreMissingTime knob the plan on #581 specified. pnpm 11 defaults that knob to true (skip the gate, warn once), so "Matches pnpm 11" does not hold for this branch. Two secondary effects worth a look: loose mode now silently resolves to the lowest satisfying version with no diagnostic, and the vulnerability re-pick can keep a known-vulnerable version.
Reviewed changes — closes the minimumReleaseAge fail-open hole where a packument with no usable publish time let a version through the age gate, including under minimumReleaseAgeStrict=true.
- Add
version_clears_cutoffas the single publish-age wall — one helper insemver_util.rsdecides all three cases: time known, hole in a populatedtimemap, and no per-version times at all. - Admit timeless packuments via
modifiedas an upper bound —modified <= cutoffproves every version in the document is mature, keeping abbreviated (corgi) metadata usable; absent or newermodifiedblocks. - Treat a
created/modified-onlytimemap as timeless — avoids a rawis_empty()misreading a bookkeeping-only mirror response as "populated, with holes". - Route the vulnerability re-pick through the same helper —
prefer_non_vulnerable_pickhad its own copy of the comparison that kept the fail-open afterpick_versiondropped it. - Replace the old fail-open test with four-case coverage — plus a bookkeeping-only-map test; both drive
pick_versiondirectly withstrict=true. - Extend
registrySupportsTimeField— new docs paragraph on the interaction, plus aworkspaceYamlsource andprecedence = ["workspaceYaml", "npmrc"]matching theminimumReleaseAge*family.
⚠️ A missing publish time reports as "blocked by age gate"
When the new wall blocks a version because its age is unknown, the resolver still raises Error::AgeGate, so the user sees "blocked by age gate" plus a list of versions that were never actually shown to be too new. The help text's first suggestion is minimumReleaseAgeStrict=false, which in exactly this case drops the user into the silent lowest-version fallback rather than a mature pick.
The plan on #581 called for a distinct missing-time diagnostic mirroring the existing ERR_AUBE_TRUST_MISSING_TIME shape, naming the package and pointing at the real escape hatches.
Technical details
# A missing publish time reports as "blocked by age gate"
## Affected sites
- `vendor/aube/crates/aube-resolver/src/resolve/driver.rs:996-1003` — `PickResult::AgeGated` maps unconditionally to `Error::AgeGate` / `build_age_gate`, with no way to distinguish "cutoff excluded it" from "age undeterminable".
- `vendor/aube/crates/aube-resolver/src/error.rs:238-262` — `build_age_gate` lists every range-satisfying version as `gated`, including versions with no time entry.
- `vendor/aube/crates/aube-resolver/src/error.rs:318` — help text: "to bypass: loosen `minimumReleaseAge` ... set `minimumReleaseAgeStrict=false` to fall back to the lowest satisfying version ...".
- `vendor/aube/crates/aube-codes/src/errors.rs` — `ERR_AUBE_TRUST_MISSING_TIME` is the established precedent for a missing-time failure code.
## Required outcome
- A block caused by undeterminable publish age is distinguishable, in both the error code and the message, from a block caused by a version being too new.
- The message names the package and the escape hatches that actually apply to this failure mode.
- The suggestion to set `minimumReleaseAgeStrict=false` is not offered for a missing-time block unless loose mode produces a defensible pick (see the inline comment on `semver_util.rs:211`).
## Open questions for the human
- Should a missing-time block get its own `ERR_AUBE_*` code (mirroring the trust-policy sibling) or reuse `ERR_AUBE_NO_MATURE_MATCHING_VERSION` with amended help text?ℹ️ Two invariant comments elsewhere still describe the old fail-open
The flip invalidates a documented assumption on the offline add path, and leaves a sibling check keyed on the exact heuristic this PR's new comment argues against.
vendor/aube/crates/aube/src/commands/add/manifest.rs:326-328states that "corgi's missingtimemap keeps its versions cutoff-eligible inpick_version, so either format picks correctly" — the justification for serving an offlineaddfrom whichever cache format is on disk. That is no longer true, and no test covers offline + corgi +minimumReleaseAge.vendor/aube/crates/aube-resolver/src/trust.rs:166skips the trust check onpackument.time.is_empty(). For a mirror serving onlycreated/modified, that check reads the map as populated and the next line raisesTrustCheckError::MissingTime— the same misread the new helper's comment calls out atsemver_util.rs:109-112.
Claude Opus | 𝕏
| /// | ||
| /// Shared with the vulnerability re-pick (`resolve::vulnerable`), which applies | ||
| /// the same wall and must not drift from it. | ||
| pub(crate) fn version_clears_cutoff( |
There was a problem hiding this comment.
AGENTS.md requires vendor/aube changes to be default-preserving with the embedder opting in. It also skips the mechanism the plan on #581 specified: pnpm 11's minimumReleaseAgeIgnoreMissingTime, whose default is true (skip the gate for a timeless package, warn once), with false opting into failure. That makes "Matches pnpm 11" inaccurate for the block-when-unprovable branch, and leaves users of a registry that omits time with no proportionate escape.
Technical details
# Fail-closed is hard-coded; pnpm's opt-in knob is missing
## Affected sites
- `vendor/aube/crates/aube-resolver/src/semver_util.rs:101-121` — `version_clears_cutoff` blocks whenever nothing proves maturity, with no setting to consult.
- `vendor/aube/crates/aube-resolver/src/semver_util.rs:86-87` — doc comment claims parity with pnpm's `pickPackageFromMeta` + `filterPkgMetadataByPublishDate`. Both functions are real and the two admit-paths match, but pnpm's third branch (no time data, `modified` absent or too new) throws and is then caught by `ignoreMissingTimeField`, which is on by default.
- `crates/nub-cli/src/pm_engine/use_nub.rs:286` — `minimumReleaseAgeIgnoreMissingTime` sits in `WARN_TAIL`, so a user setting pnpm's documented escape hatch gets it warned-and-dropped.
- `crates/nub-cli/src/pm_engine/mod.rs:2256-2257` — nub's embedder defaults are `minimumReleaseAge=1440` + `minimumReleaseAgeStrict=true`, so under nub the new wall is a hard install failure by default.
- pnpm docs: `minimumReleaseAgeIgnoreMissingTime`, added v11.0.0, default `true` — "skips the minimumReleaseAge check for a package whose registry metadata does not include the time field (some private registries and mirrors omit it). Set to false to fail resolution in that case instead of installing the package."
## Required outcome
- `MinimumReleaseAge` carries an `ignore_missing_time` flag that `version_clears_cutoff` honors, declared in `aube-settings/settings.toml` as `minimumReleaseAgeIgnoreMissingTime`.
- Standalone aube's default path is unchanged (flag defaults to `true` there); nub opts in via its embedder defaults if that is the posture chosen.
- `minimumReleaseAgeIgnoreMissingTime` moves out of nub's `WARN_TAIL` into the honored path.
- The doc comment's parity claim is narrowed to the two branches that actually match pnpm.
## Open questions for the human
- nub's default for `ignore_missing_time`: `false` (genuinely closed 24h floor, breaks installs against time-less mirrors) or `true` (pnpm parity)? This is the same call left open on [#581](https://github.com/nubjs/nub/issues/581) and it was never answered.
- Whether a per-package warning ships regardless of the default (option 2 on that issue).There was a problem hiding this comment.
Partly addressed in 4a627b7: standalone aube's default path is preserved and the pnpm-parity claim is now narrowed to the modified bound, so the fork-discipline half is resolved. Still open — there is no minimumReleaseAgeIgnoreMissingTime, so the missing-time posture rides on minimumReleaseAgeStrict (see the new review), and nub's WARN_TAIL still drops that key.
Claude Opus | 𝕏
| and blocking the rest. Getting this flag wrong costs over-blocking, | ||
| never a silent bypass. |
There was a problem hiding this comment.
ℹ️ This holds only in strict mode. With minimumReleaseAgeStrict=false (this crate's default) the cost is neither over-blocking nor a bypass: the resolver silently falls back to the lowest satisfying version of every affected package. Worth stating both outcomes so the flag's risk is legible to someone reading only this doc.
There was a problem hiding this comment.
Still inaccurate after 4a627b7, now in the other direction: under the lenient default an undated version stays eligible, so enabling this flag against a registry that omits time does effectively disable minimumReleaseAge — a silent bypass, not over-blocking. The over-blocking wording holds only under minimumReleaseAgeStrict.
Claude Opus | 𝕏
The previous commit applied the publish-age wall inside `prefer_non_vulnerable_pick`, which is a preference, not a gate: when no candidate cleared the wall it returned `fallback` — the version already known to be vulnerable. So an undated non-vulnerable version was rejected in favour of a confirmed advisory hit, which is the wrong trade. Rank in two tiers instead. A non-vulnerable version that clears the wall wins; failing that, a non-vulnerable version whose age is merely UNDETERMINABLE still beats returning the vulnerable fallback. A version with a KNOWN publish time that is too new belongs to neither tier — that is the freshly-published-compromise case, and it stays excluded even against a vulnerable fallback. This is a deliberate asymmetry with `pick_version`, which has no second-worst option to fall to and so blocks outright. Also corrects an invariant comment in `add/manifest.rs` that the fail-closed flip invalidated: corgi's missing `time` map no longer keeps versions cutoff-eligible, so an offline add falling back to a corgi-cached packument can now fail where it previously succeeded. Refs #581
…s default Failing closed unconditionally changed standalone aube's out-of-box behavior, which fork-discipline does not allow: lenient mode already means "fall back rather than fail", so an undeterminable publish age blocking there is the embedder's posture leaking into upstream's default. Thread `strict` into `version_clears_cutoff`. Strict blocks an undeterminable age; lenient keeps it eligible, exactly as before. nub pins `minimumReleaseAgeStrict` on, so nub's ruled fail-closed posture is unchanged. The `modified` upper bound applies in BOTH modes — it is proof of maturity, not a policy — so the corgi-without-a-fetch property survives for lenient callers too. Converges with the same fix carried in #587, which gates on `strict` but has no `modified` bound; keeping the bound is what stops strict mode over-blocking a time-less registry it could have proven mature. The vulnerability re-pick passes `strict = true`, where it means "provably mature" for the first-choice tier rather than a veto — its second tier still prefers an undated safe version over a vulnerable fallback. Refs #581
There was a problem hiding this comment.
ℹ️ The vulnerability question is answered well — two minor findings inline, both about invariants stated in the new code not quite matching what it does.
Reviewed changes — the two new commits answer the "which protection wins" question from the first review and repair a settings-parity gap the earlier commits opened.
- Rank the vulnerability re-pick in two tiers instead of gating it —
prefer_non_vulnerable_picknow prefers a safe version that clears the age wall, falls back to a safe version whose age is merely undeterminable, and excludes a dated-but-too-new version outright. A confirmed advisory hit is never traded for an unprovable publish date. - Add the first unit test for the re-pick — covers the undated-beats-vulnerable case and the dated-too-new exclusion on the same fixture shape;
make_version/make_packumentbecomepub(crate)so sibling modules can build packuments. - Give
registrySupportsTimeFielditsWorkspaceConfigfield — the earlier commit declared aworkspaceYamlsource without the matching struct field, which the settings-meta parity self-test rejects. The newvalues.rstest asserts the value actually resolves rather than merely that the metadata names a real field. - Rewrite the stale invariant comment on the offline
addfallback —add/manifest.rsno longer claims corgi's missingtimemap keeps versions cutoff-eligible.
The four other threads from the first review are unchanged by these commits and remain open.
Claude Opus | 𝕏
| } else if packument.time.contains_key(ver_str) { | ||
| // Dated and too new — excluded outright. | ||
| continue; | ||
| } else { | ||
| &mut best_undated | ||
| }; |
There was a problem hiding this comment.
ℹ️ Tier-2 eligibility is !time.contains_key(ver) alone, so it never re-checks exempt_cutoff — the time-based wall this file's own comment at lines 40-44 calls never-droppable, and which pick_version's lenient fallback re-tests explicitly (semver_util.rs:275). An undated candidate can therefore be returned in resolutionMode=time-based even though every other pick path treats that cutoff as absolute.
Adding the wall to the exclusion condition is a no-op whenever time-based mode is off (exempt_cutoff is None, so the helper returns true).
Technical details
# Tier 2 drops the time-based hard wall
## Affected sites
- `vendor/aube/crates/aube-resolver/src/resolve/vulnerable.rs:81-86` — tier-2 admission keys only on the absence of a `time` entry.
- `vendor/aube/crates/aube-resolver/src/resolve/vulnerable.rs:40-44` — the function's own comment states an age-gate-exempt version is "still subject to `exempt_cutoff`, the time-based wall".
- `vendor/aube/crates/aube-resolver/src/semver_util.rs:271-279` — the sibling lenient fallback re-runs `passes_effective_cutoff(ver_str, exempt_cutoff)` before a candidate becomes eligible. Tier 2 is the analogous bucket and does not.
- `vendor/aube/crates/aube-resolver/src/resolve/driver.rs:212-217,781-782,1020-1030` — `exempt_cutoff` is `time_cutoff`, documented as a hard floor that even `minimumReleaseAgeExclude` never relaxes; both cutoffs are forwarded unchanged into the re-pick.
## Required outcome
- A tier-2 candidate clears the time-based wall, so the re-pick cannot select a version that `pick_version` would treat as hard-blocked.
- Behavior outside time-based mode is unchanged.
## Suggested approach
Fold the wall into the exclusion branch so the two paths share one rule:
```rust
} else if packument.time.contains_key(ver_str)
|| !crate::semver_util::version_clears_cutoff(packument, ver_str, exempt_cutoff)
{
continue;
}
```
## Open questions for the human
- Tier 2 also admits a version that is a HOLE in an otherwise-populated `time` map, which `version_clears_cutoff`'s doc calls "anomalous, never a reason to admit a version" and which `pick_version` excludes under `strict`. Is an anomalous hole meant to rank with a genuinely timeless corgi document, or should tier 2 be restricted to documents carrying no per-version times at all?| } else if packument.time.contains_key(ver_str) { | |
| // Dated and too new — excluded outright. | |
| continue; | |
| } else { | |
| &mut best_undated | |
| }; | |
| } else if packument.time.contains_key(ver_str) | |
| || !crate::semver_util::version_clears_cutoff(packument, ver_str, exempt_cutoff) | |
| { | |
| // Dated and too new, or short of the time-based hard wall that no | |
| // age-gate concession ever drops — excluded outright. | |
| continue; | |
| } else { | |
| &mut best_undated | |
| }; |
There was a problem hiding this comment.
Correction for 4a627b7: version_clears_cutoff now takes a fourth strict argument, so the suggested guard is !crate::semver_util::version_clears_cutoff(packument, ver_str, exempt_cutoff, true) — the concern itself is unchanged, since tier-2 eligibility is still !time.contains_key(ver_str) alone.
Claude Opus | 𝕏
| // resolves only what the document's `modified` timestamp | ||
| // proves mature, and otherwise reports the age gate instead | ||
| // of silently installing. Intended: an offline fallback must | ||
| // not be a way around the gate. But it does mean this | ||
| // fallback can now fail where it previously succeeded. |
There was a problem hiding this comment.
ℹ️ "Reports the age gate instead of silently installing" holds only under minimumReleaseAgeStrict=true. strict is the sole route to AgeGated (semver_util.rs:300), it defaults to false in this crate, and pick_version_for_add passes exempt_cutoff = None while normalizing a gated latest to * — so on the lenient default this path silently returns the lowest version in the whole packument.
The error arm two functions down already concedes this (AgeGated is "only reachable in strict mode today"), so the new comment contradicts a neighbour.
Technical details
# Rewritten invariant comment describes strict mode only
## Affected sites
- `vendor/aube/crates/aube/src/commands/add/manifest.rs:333-337` — claims the corgi fallback "reports the age gate instead of silently installing".
- `vendor/aube/crates/aube-resolver/src/semver_util.rs:300-313` — `if strict || cutoff.is_none()` is the only path returning `PickResult::AgeGated`; otherwise `fallback_lowest` is returned as `Found`.
- `vendor/aube/crates/aube-resolver/src/semver_util.rs:53-57,69-78` — `pick_version_for_add` rewrites a gated `latest` to `*` and passes `exempt_cutoff = None`, so `fallback_lowest` is unfiltered by time and resolves to the lowest version in the packument.
- `vendor/aube/crates/aube-resolver/src/types.rs:44-56` and `vendor/aube/crates/aube-settings/settings.toml:277-292` — `strict` defaults to `false` in standalone aube. `crates/nub-cli/src/pm_engine/mod.rs:2255-2257` overrides it to `true` as an embedder default only, so explicit user config still reaches the lenient path.
- `vendor/aube/crates/aube/src/commands/add/manifest.rs:476-479` — the existing `AgeGated` arm already notes it is "[o]nly reachable in strict mode today (the lenient pick falls back instead)".
## Required outcome
- The comment states both outcomes, so a reader does not conclude the offline fallback is fail-safe under the default configuration.
## Suggested approach
See the inline suggestion. The underlying loose-mode behavior is tracked by the open thread on `semver_util.rs:211`; this is only about the comment agreeing with the code.| // resolves only what the document's `modified` timestamp | |
| // proves mature, and otherwise reports the age gate instead | |
| // of silently installing. Intended: an offline fallback must | |
| // not be a way around the gate. But it does mean this | |
| // fallback can now fail where it previously succeeded. | |
| // resolves only what the document's `modified` timestamp | |
| // proves mature. Under `minimumReleaseAgeStrict` the rest | |
| // reports the age gate; under the lenient default the pick | |
| // drops to the lowest satisfying version instead, silently. | |
| // Intended: an offline fallback must not be a way around the | |
| // gate. But it does mean this fallback can now fail, or pick | |
| // an older version, where it previously succeeded. |
There was a problem hiding this comment.
Correction for 4a627b7: lenient mode now keeps undated versions eligible, so there is no silent lowest-version downgrade here — disregard that half of the suggestion. What remains is that the whole "WEAKER than it once was" paragraph describes strict mode only; under the lenient default this fallback behaves exactly as it did before.
Claude Opus | 𝕏
There was a problem hiding this comment.
Important
Gating on strict is the right shape and resolves the fork-discipline and lenient-downgrade concerns cleanly. One consequence needs a decision before merge: minimumReleaseAgeStrict now decides two different things, and the age-gate error text still tells users to turn it off. Separately, PR #587 already carries this same gate on the same lines.
Reviewed changes — the third commit in this run reworks the wall's terminal case so it is opt-in rather than unconditional.
- Gate the missing-time wall on
strict—version_clears_cutofftakes a fourthstrictargument and returnsmodified_proves_maturity || !strictfor an undeterminable age. Strict blocks, lenient keeps the version eligible, so standalone aube's out-of-box behavior is unchanged and the age wall becomes the embedder's posture. - Pin lenient mode with its own test —
pick_version_lenient_mode_keeps_undated_versions_eligibleasserts1.1.0, which distinguishes "stayed eligible" from "blocked, then fell back to the lowest" rather than passing either way. - Hard-code
strict = truein the vulnerability re-pick — the re-pick uses the flag to mean "provably mature" for its first-choice tier only, so its two-tier ranking is unaffected by the project's strictness while still never vetoing down to a vulnerable fallback. - Narrow the pnpm claim — the doc comment and PR body now attribute only the
modifiedupper bound to pnpm 11 and state plainly that the terminal default differs, replacing the earlier three-case parity claim.
ℹ️ PR #587 lands the same gate on the same lines
Commit 5a01418d on the nub-jsonc branch already changes the missing-time arm of pick_version to honor strict, reached from a different direction (an embedder project-config settings tier). Its version has neither the modified upper bound nor the shared helper that routes the vulnerability re-pick through the same wall, so the two branches implement overlapping subsets of one behavior and will conflict on the same closure.
The PR body notes the overlap but not the resolution, and whichever lands second has to reconcile more than a textual conflict — #587's arm blocks every undated version under strict, including ones this branch's modified bound would admit.
Technical details
# Overlapping missing-time gate across two open PRs
## Affected sites
- `vendor/aube/crates/aube-resolver/src/semver_util.rs:218-220` (this branch) — `passes_effective_cutoff` delegates to `version_clears_cutoff(packument, ver, effective, strict)`.
- `vendor/aube/crates/aube-resolver/src/semver_util.rs:164-176` on branch `nub-jsonc`, commit `5a01418d` — the same closure, with `None => !strict` inline and no `modified` fallback.
- `vendor/aube/crates/aube-resolver/src/resolve/vulnerable.rs:51` (this branch only) — the re-pick shares the helper. #587 leaves that path on its own comparator.
## Required outcome
- One branch owns the missing-time gate; the other drops its copy rather than resolving a conflict line-by-line.
- Whichever lands second preserves the `modified` upper bound and the shared-helper wiring, so the two pick paths do not drift again.
## Open questions for the human
- Which PR merges first, and does the other get rebased onto it or have its gate removed?
- Does #587's `revalidate_release_policy` freshness-hash work need the `modified` bound to be present to behave correctly, or is it independent?Claude Opus | 𝕏
| .any(|k| k != "created" && k != "modified"); | ||
| let modified_proves_maturity = | ||
| !has_version_times && packument.modified.as_deref().is_some_and(|m| m <= c); | ||
| modified_proves_maturity || !strict |
There was a problem hiding this comment.
minimumReleaseAgeStrict decide two unrelated things: whether an age-gated range falls back to the lowest satisfying version, and whether an unprovable publish age blocks at all. The age-gate help text (error.rs:317) advertises only the first — "set minimumReleaseAgeStrict=false to fall back to the lowest satisfying version" — so a nub user hitting a time-less mirror follows that advice and silently stops age-checking every undated package, which on such a registry can be the entire tree.
There is also no way left to express "enforce the floor, but tolerate a mirror that omits time", which is what pnpm's dedicated knob provides.
Technical details
# `minimumReleaseAgeStrict` now doubles as the missing-time switch
## Affected sites
- `vendor/aube/crates/aube-resolver/src/semver_util.rs:126` — `modified_proves_maturity || !strict` couples the missing-time posture to the fallback flag.
- `vendor/aube/crates/aube-resolver/src/error.rs:317` — `"to bypass: loosen `minimumReleaseAge` in .npmrc, set `minimumReleaseAgeStrict=false` to fall back to the lowest satisfying version, or add ... to `minimumReleaseAgeExclude`"`. The middle clause now understates what it does.
- `vendor/aube/crates/aube-settings/settings.toml:277-292` — the setting's own documentation describes only the fallback behavior.
- `vendor/aube/crates/aube-manifest/src/workspace/config.rs:541-545` — same, on the typed field.
- `crates/nub-cli/src/pm_engine/mod.rs:2255-2257` — nub pins `minimumReleaseAgeStrict=true`, so nub users are the population that meets the strict block and reads that help text.
- `crates/nub-cli/src/pm_engine/use_nub.rs:286` — `minimumReleaseAgeIgnoreMissingTime` is still warned-and-dropped, so pnpm's own escape hatch for this exact situation is unavailable.
## Required outcome
- Following the age-gate error's advice does not silently widen the change beyond what the advice describes.
- A user can keep the age floor enforced for dated packages while tolerating a registry that omits `time`, or is told plainly that the two are inseparable.
- Wherever `minimumReleaseAgeStrict` is documented, both effects are stated.
## Suggested approach
Either of two directions, not both:
1. Land `minimumReleaseAgeIgnoreMissingTime` as originally planned on [#581](https://github.com/nubjs/nub/issues/581), have `version_clears_cutoff` consult it instead of `strict`, and move the key out of nub's `WARN_TAIL`. This restores the separation and matches pnpm's surface.
2. Keep the coupling, but amend `format_age_gate_help` so a block caused by an undeterminable age says so and names `minimumReleaseAgeExclude` first — the escape that does not weaken the gate for everything else — and update the setting's docs on both the toml and the typed field.
Direction 2 leaves the missing diagnostic distinction from the first review's body section unresolved; direction 1 subsumes it.
## Open questions for the human
- Is coupling to `strict` the intended long-term surface, or a stopgap until the dedicated knob lands?Follow-through on the conflict note. The `semver_util.rs` hunks were already resolved in favour of main — its shared `version_clears_cutoff` also accepts a packument `modified` timestamp as proof of maturity, so a time-less abbreviated document still resolves and the `vulnerable.rs` fail-open stays closed. `tests.rs` auto-merged, though, so this branch's `test_pick_version_strict_cutoff_rejects_missing_time_entries` survived on its own. Main covers the same ground and more with `pick_version_missing_time_fails_closed_unless_modified_proves_maturity`, which asserts both the fail-closed half and the `modified` bound this branch's version predates. The aube-resolver crate now matches main byte-for-byte; the branch carries no divergence there at all. Left the three pre-existing rustfmt drifts in tests.rs alone — they are on main too, and reformatting them would thicken the fork delta for nothing.
Since #602 a strict-mode `minimumReleaseAge` blocks a version whose publish age the registry gives no way to establish. That refusal was reported as an ordinary age gate: it listed versions as "blocked by age gate" that were never shown to be too new, and led with `minimumReleaseAgeStrict=false` as the fix — which drops the reader into the lenient fallback rather than a mature pick. `pick_version` now returns the cause alongside `AgeGated`, and the driver and `add` raise `ERR_AUBE_RELEASE_AGE_MISSING_TIME` (exit 28) when no candidate could be dated, mirroring the trust policy's `ERR_AUBE_TRUST_MISSING_TIME`. Its help names the package and the remedies that apply — a registry that serves publish times, `minimumReleaseAgeExclude`, or `minimumReleaseAge=0` — and omits `minimumReleaseAgeStrict=false`, which here installs the newest match with no age checked at all. A provably-too-new candidate still outranks an undated one, so a mixed range keeps reporting as an age gate. `AgeGateDetails.gated` now carries only versions the registry dated; listing an undated one claimed evidence we never had. Default-preserving for standalone aube: `minimumReleaseAgeStrict` defaults to false there, so neither error is reachable on the default path. Only the strict posture nub pins on changes, and only in which of the two errors it raises. The regenerated `docs/error-codes.data.json` also picks up `WARN_AUBE_SKIPPED_OPTIONAL_NO_MATCHING_VERSION`, which was added to the registry earlier without a regeneration.
* fix(resolver): give an undeterminable publish age its own error Since #602 a strict-mode `minimumReleaseAge` blocks a version whose publish age the registry gives no way to establish. That refusal was reported as an ordinary age gate: it listed versions as "blocked by age gate" that were never shown to be too new, and led with `minimumReleaseAgeStrict=false` as the fix — which drops the reader into the lenient fallback rather than a mature pick. `pick_version` now returns the cause alongside `AgeGated`, and the driver and `add` raise `ERR_AUBE_RELEASE_AGE_MISSING_TIME` (exit 28) when no candidate could be dated, mirroring the trust policy's `ERR_AUBE_TRUST_MISSING_TIME`. Its help names the package and the remedies that apply — a registry that serves publish times, `minimumReleaseAgeExclude`, or `minimumReleaseAge=0` — and omits `minimumReleaseAgeStrict=false`, which here installs the newest match with no age checked at all. A provably-too-new candidate still outranks an undated one, so a mixed range keeps reporting as an age gate. `AgeGateDetails.gated` now carries only versions the registry dated; listing an undated one claimed evidence we never had. Default-preserving for standalone aube: `minimumReleaseAgeStrict` defaults to false there, so neither error is reachable on the default path. Only the strict posture nub pins on changes, and only in which of the two errors it raises. The regenerated `docs/error-codes.data.json` also picks up `WARN_AUBE_SKIPPED_OPTIONAL_NO_MATCHING_VERSION`, which was added to the registry earlier without a regeneration. * fix(resolver): stop the missing-time help asserting the registry is dateless Self-review reproduced the overclaim. The error fires when every version SATISFYING THE RANGE is undated, not when the packument carries no dates — a packument that dates most versions but has a hole over the matching set hits it too. The help told that operator their registry serves no publish times and sent them to switch registries, which is false and misdirecting. npmjs.org grows such holes after an unpublish/republish. Reword to say the metadata omits these versions rather than that the document lacks it, matching how the trust-policy sibling already words the same situation. Also lead the remedies with unsetting `registry-supports-time-field`. That setting suppresses the full-packument fetch that carries `time`, so it is the likeliest operator cause of this error against npmjs.org — and its own documentation says getting it wrong costs over-blocking, which is exactly this error. It was absent from the list. Docs: the second table row (dates for other versions, none for this one) also raises this code; the prose attributed it only to the last row.
…on (#587) * pm(aube): add an embedder project-config settings tier Give the vendored engine a settings source an embedding host can supply directly, plus the two engine capabilities nub's project config needs to lower onto it. - `ResolveCtx.project_config` is a new precedence tier that sits below CLI/environment and above every file and global source. It is empty for standalone aube, so resolution there is unchanged. `EngineContext` grows the matching `project_config_settings` carrier; unlike the synthetic npmrc entries it feeds only the typed settings resolver, never the registry client. - `diskMaterializePackages` / GVS trigger entries are matched as package-name globs (`package_name_matches`) instead of exact strings. Invalid glob syntax falls back to a literal compare, so no previously matchable name stops matching. - `InstallOptions.revalidate_release_policy` re-resolves lockfile picks after a prefer-frozen install has already missed the warm shortcut, and the release-age settings join the freshness hash — a project can tighten its release-age policy without touching its manifest or lockfile, and the warm path must not serve picks the new policy would reject. Strict mode now treats a packument with no publish time as gated rather than eligible. Defaults are `false`, preserving standalone behavior. - `dlx::run_with_child_env` and the `exec_bin*_with_env` variants let an embedder add environment values to the executed tool; the ordinary entry points pass an empty map. * feat: project nub.jsonc — typed runtime, install, and dlx configuration `nub.jsonc` at the project root is Nub's canonical typed configuration. The parser is strict: an unknown key or an invalid value stops the command. The global `~/.config/nub/nub.jsonc` shares the schema but stays best-effort — unreadable, malformed, or invalid input means no typed layer. One snapshot is resolved per process, from the command's final working directory, and every route consumes it: the file runner, `nub run`, `nub watch`, `nub exec`/`nubx`, the `node` argv0 hijack, the PM shims, and the install family. Install-family verbs own a verb-local `-C/--dir`, so their engine session initializes the snapshot after applying it; every other route has reached its final cwd earlier and initializes there. Runtime keys (`preload`, `env`, `nodeOptions`, `v8Flags`, `conditions`, `define`, `loader`, `tsconfig`, `nodeCompat`) travel to the child through `__NUB_RUNTIME_CONFIG` and validated NODE_OPTIONS tokens. Option tokens are checked against the resolved Node's accepted-flag set before it starts, so an unsupported flag is a Nub error rather than a Node startup abort. `node_options_token` now also quotes empty and quote-bearing values, so each JSON array element survives Node's tokenizer as exactly one option. Install keys lower onto the engine's new project-config tier, and only for a Nub-identity or truly-fresh project — an incumbent npm/pnpm/yarn/bun project keeps resolving its own PM's configuration untouched. Reserved and contradictory combinations (`nodeLinker: "pnp"`, `hoist` without `isolated`, `symlinkDisablePattern` without `symlink`) fail loudly. Nub-native projects also make the release-age gate strict by default: resolution fails closed when registry metadata cannot establish a publish time. Lenient sessions collapse identity contradictions to "no incumbent", so that default independently re-checks strict identity resolution before treating an undetected project as new. `nodeCompat` is the project-wide form of `--node`: augmentation off, version provisioning on. A nested compat re-entry arriving through an augmented parent's PATH shim now restores the environment captured before that parent installed Nub's values and drops the shim's own PATH entry, so it is genuinely vanilla rather than merely skipping a second pass. Sandbox values are parsed and retained but stay inert — nothing in the runtime or install lowering reacts to them. * test: cover project nub.jsonc across the runtime, install, and dlx routes `project_runtime_config` drives every runtime consumer end to end — file run, `nub run`, the `node` argv0 hijack, `nub exec`, `nubx`, and the watch child — and asserts the nested-compat restore leaves the child with the user's own NODE_OPTIONS/NODE_PATH/NODE and no shim on PATH. `project_config_snapshot_cwd` pins that the snapshot is resolved from each command's final working directory, including the install family's verb-local `-C/--dir`. `project_config_dlx` covers the fetched-tool environment. `project_config_sandbox_inert` — with the shape table shared by the install-lowering unit test — pins that every sandbox shape parses and changes nothing, which is what keeps the runtime and install assertions non-vacuous rather than passing on an unparsed key. The additions to `install_engine`, `integration`, and `pm_shim` carry a restrictive sandbox block through the engine, argv0, and PM-shim routes for the same reason. Two `project_runtime_config` cases fail on this host — the `define` assertion in `runtime_snapshot_reaches_file_script_node_argv0_exec_and_nubx` and the `shimInPath` assertion in `inherited_runtime_snapshot_yields_to_nested_node_compat_with_zero_augmentation`. Both reproduce identically against the branch this work was extracted from, with the same test binary and a clean cache home, so they are carried forward rather than introduced here. * docs: document project nub.jsonc and publish its JSON schema Add the configuration reference page and serve the schema at /schema/nub.json so `$schema` gives editors completion and validation. AGENTS.md's project-config bullet said discovery was gated pending an unfinished field; discovery is live, so state that, and that sandbox values are parsed and retained while staying inert. * fix(config): guard JSONC depth, drop the inert release-age gate, correct the docs Three merge blockers found reviewing the branch, plus the docs they touch. A `nub.jsonc` from a cloned repo could abort the process. jsonc_parser descends without a depth bound and the serde_json::Value it builds drops recursively, so a deeply-nested document exhausts the stack before any validation can reject it — an uncatchable abort under panic = "abort". Windows hits it first at ~380 levels on its 1 MiB stack. The guard module and its call-site sweep are cherry-picked from the sandbox integration branch, which already solved this; sandbox_bridge.rs is not part of this change, so its call site is omitted. The two remaining direct parse_to_serde_value calls are in nub-native, predate this branch, and are left alone. The native-only release-age gate never ran. nub_setting_defaults already pushed minimumReleaseAgeStrict unconditionally, and get() returns the first match, so the later conditional insert could not change any lookup. Its two tests asserted None and received Some("true"), failing on every platform. Removing the gate is behaviour-neutral: strict stays on for every project, which is what the file already did and what the missing-timestamp policy calls for. Scoping it to Nub-native projects is a relaxation for incumbent projects and belongs in its own change. __NUB_RUNTIME_CONFIG joins ENV_FILE_DENYLIST. It carries define, preload, and loader, and the watch path applies injected .env values after stamping it, so a repo-supplied .env could otherwise substitute arbitrary source into every transpiled file. The install-lowering snapshot test now holds the process-wide env lock and points XDG_CONFIG_HOME at its own directory. It read the variable while sibling tests call unsafe set_var in the same binary — a data race under edition 2024 — and without isolation it merged the developer's real global config into the snapshot it asserts on. Docs: every hoist example omitted nodeLinker and so hit the isolated-only error; verifyDepsBeforeRun documented "install" and "prompt" as installing and prompting when both behave as warn; the v8Flags example used a Node option and did not say the accepted set is NODE_OPTIONS'. Adds the ${VAR} expansion in env paths and the Nub-native scope of the install block. * docs(config): unwrap paragraphs, fix a code-led sentence, show both refusals PROSE.md bans a sentence opening with inline code and bans hard-wrapping paragraphs; three spots did both. The reserved-linker note now shows the error it produces, matching the hoist and v8Flags sections. * test(config): take the settled truncated-input assertions The depth-guard cherry-pick landed an intermediate revision of this test. An unterminated block comment is a parse error, not an empty document; only an unterminated line comment yields Ok(None). Matches the revision the sandbox integration branch settled on. * fix(watch): keep the runtime snapshot out of the watch env guard Adding __NUB_RUNTIME_CONFIG to ENV_FILE_DENYLIST also enrolled it in the watch guard, which plants an empty placeholder for every guarded key. The two share a list but not a purpose: the denylist answers whether a .env may supply a key, the guard whether the child must start without it. Nub stamps the snapshot itself from the resolved nub.jsonc, so blanking it left the watch child with no tsconfig, define, or loader — runtime-alias stopped resolving on every Node at or above the 20.6 --env-file floor, which is where the guard arms. Below it the guard never ran, which is why Node 18.19 passed. Also compare resolved directories rather than path spellings in the verb-local cwd test. Windows logs the 8.3 short name it was handed while canonicalize returns the extended-length form. EOF * docs: make nub.jsonc a reference page, and surface each field where it applies The config page is retitled nub.jsonc and moves to the bottom of the sidebar, below both package-manager pages and above deployment, where a reference belongs. It opens with one annotated block carrying every field, so the whole surface is legible before the per-field sections. Each field is now also documented on the page for the feature it configures — preload/nodeOptions/v8Flags/define/nodeCompat on the runtime overview, env on environment files, loader on loaders, conditions on module resolution, tsconfig on TypeScript, verifyDepsBeforeRun on the script runner, the dlx fields on the remote bin runner, and the install fields split between the package manager and the virtual store. The introduction gains a short annotated block so the file is discoverable without hunting for it. Install and dlx subheadings take their dotted field names. Both sections had a field colliding with a runtime one (env, nodeOptions), and duplicate headings slug to #env-1 / #nodeoptions-1, so every cross-page link to those fields would have silently landed on the wrong section. Documents that a preload entry may be a bare package specifier and that export conditions steer which file it resolves to. Both verified against Node 26.5.0, including through NODE_OPTIONS, which is the channel Nub uses. Schema hosting: nub.json becomes latest.json, v0.6.json is the first frozen copy, and /schema renders a browsable index of whatever is published. The version bump snapshots a new pinned copy so the set cannot drift from the version the tree claims. * docs: cut config examples to the minimum, and settle their comment style Every nub.jsonc example now carries only the fields its point needs. The export-conditions section was the worst case: it shipped a preload entry because the interaction had just been verified, not because the section needed it. Blocks that paired unrelated fields are split — dlx consent from dlx env, the release-age gate from the lifecycle-script node options — and symlinkDisablePattern drops the nodeLinker line, since symlink is already the default. The nodeLinker beside hoist stays: hoist errors without it. Fragments lead with a `// ...` elision marker so an excerpt reads as one, and annotations are lowercase fragments, inline for a short note and stacked above the field when they run longer. Records both as general rules in PROSE.md, under structure and density, so this does not have to be re-litigated per page. * config: rename env to envFile, and dlx.env to dlx.envFile The name `env` is wanted for a package.json grammar that allowlists and validates individual environment VARIABLES. This field selects env FILES, which is a different concept, so it is the one that moves. No alias and no compat read: the field has never shipped, so a stale `env` key falls through to reject_unknown_keys and fails loud like any other typo. Internal identifiers follow the key — EnvSetting becomes EnvFileSetting and RuntimeEnv becomes RuntimeEnvFile, both for the same collision reason. That changes the serialized __NUB_RUNTIME_CONFIG key from "env" to "envFile"; safe, because the runtime reads only define, loader, and tsconfig from that snapshot, and mixing binaries across a version through it was never supported. The sandbox `env` axis is a different key and is untouched. * test: compare resolved snapshot dirs, not Windows path spellings Windows hands the child whatever spelling the environment carried — an 8.3 short name under a RUNNER~1 home — while canonicalize returns the extended-length \\?\ form. Four assertions compared the two as strings and so failed on every Windows run; the install_engine one was fixed when CI surfaced it, and grepping the pattern found the other three rather than waiting for CI to surface them one at a time. Each now parses the logged path back out and canonicalizes both sides, so the assertion tests which directory the snapshot resolved from, which is the actual contract. * fix(run): stop warning that an override-pinned dependency is stale The freshness check compares each direct dependency's installed version against its declared range and never consulted overrides, so a manifest that pins a direct dep through overrides, resolutions, or pnpm.overrides warned on every invocation — and nub install honours the same pin, so the remedy the warning printed could never clear it. Reproduced on a real project: typescript declared ^5.9.2, pinned ~5.8.3, installed 5.8.3. A dep named by any override now skips the VERSION comparison only; a missing package still reports, because an override excuses a wrong version, never an absent one. The override's value is deliberately not evaluated — $name references, npm: aliases and per-parent objects are not semver ranges, and misreading one would reintroduce the false warning this removes. Selector shapes (name@range, parent>child, nested objects) are collected wide: too wide misses a staleness, too narrow restores a permanent wrong warning. pnpm.overrides is read only under a pnpm incumbent, per the rule that a pnpm-named field is never read otherwise. The set is built lazily, so a tree with nothing mismatched pays nothing. * config: make dlx a global-only section dlx governs whether nubx may reach the registry on a local miss. That is a decision about the operator's machine, not about a checkout, and normal precedence let a project file override it — so cloning a repository whose nub.jsonc set consent to prompt silently widened a user who had set never globally. A project dlx block is now an error naming the global file to move it to, resolved through the real config root so it honours XDG_CONFIG_HOME and the Windows home rather than printing a ~/.config that may not be the reader's. The rejection precedes the unknown-key sweep and carries its own error, so a legitimate block in the wrong file reads as a scope rule rather than a misspelling; a test pins that an actual typo still reports as one. The published schema drops dlx. It describes the project file and sets additionalProperties false, so keeping the key would have an editor bless what the command rejects. GLOBAL_ONLY_KEYS now drives both the parser gate and the schema check, so a second such key follows automatically. The dlx e2e fixtures move to the global file, including the relative envFile source — that resolves against the winning file, so leaving it under the project would have broken the assertion quietly. * config: remove the define field Compile-time identifier replacement earns its keep in a bundler, where the folded branch becomes dead code the bundler drops. Nub transpiles per module into a private on-disk cache, so nothing ships and the size payoff has no consumer; the runtime effect it did have is one line of ordinary JavaScript, since a dead branch's require/import never loads on plain Node either. If dead-code elimination becomes worth having it will be for nub compile, and it belongs on that surface rather than as a runtime field justified by a feature that does not exist yet. Re-adding a config key is purely additive. Removing it also restores the plain-JS fast path unconditionally. The gate that returns modules to Node's native loader byte-identical carried an extra `RUNTIME_DEFINE is empty` term, so a single define key pulled every .js, .cjs and .mjs in the project through the transpiler — and with it the commonjs-sync relabel, require.cache, and the require-of-ESM-.cjs error the native path exists to preserve. The field is unreleased, so this is a deletion rather than a deprecation. The schema drops the property and the now-orphaned stringMap definition; two comments that described the runtime snapshot as carrying define are corrected. * docs: retitle the config page and rebuild its reference block The page is "Config reference" with a right-aligned nub.jsonc chip, the same slot the command pages use — the filename is equally a thing you type, so it reads as a sibling rather than an odd one out with a bare name. The opening block is now the actual reference: every field carries its full value set, including the ones that were only discoverable by reading a section (loader's eight names, the duration grammar, verifyDepsBeforeRun's pnpm-compat values behaving as warn, nodeLinker's reserved pnp). Long notes sit above their field rather than trailing it, which is the house style and also stops the block scrolling sideways. The $schema section is gone — the key needs no explanation beyond appearing in the example. "Project and global configuration" splits into a global section, whose prose about XDG_CONFIG_HOME becomes a precedence-ordered list of the locations actually checked with the OS-specific ones parenthesised, and a precedence section of its own. Env examples take idiomatic dotenv names (.env.development, not development.env), and tsconfig links out to the TypeScript docs for path mapping while saying plainly that Nub reads the file for execution options rather than type checking. * schema: publish one schema covering both config files The dlx block is back. Dropping it left the global file — the only file that may carry dlx — with no completion or validation at all, which is a worse trade than an editor not enforcing scope. Scope stays the parser's job, where it already fails loud with a message naming the file to move the block to. The schema's job is completion and value validation, and it now carries every key either file accepts. GLOBAL_ONLY_KEYS still drives the parser gate; it no longer drives the schema. The sandbox surface stays omitted, for the opposite reason: it parses but nothing consumes it, so offering it would invite a security policy that silently does nothing. * fix(runtime): stop a project loader config reaching inside node_modules The loader hook has three dispatch branches. Two refuse to act on a URL inside node_modules; the data-extension branch did not, and the parser accepts any key beginning with a dot. So a project setting {"loader": {".json": "text"}} changed how every DEPENDENCY's package.json import resolved — project state rewriting third-party code. The fix swaps the map rather than bailing, because the two halves have different provenance. The built-in extensions are a runtime capability a dependency may legitimately rely on; only the config-supplied entries are project state. A bail would have removed data loading from dependencies entirely, which the parity argument behind the sibling gates does not support: Node throws for TypeScript under node_modules, so transpiling there would out-permit Node, but Node has no opinion about YAML. Swapping also covers the deletion direction, which a bail misses — a config pointing a built-in extension at a transpile loader removes it from the map, and a dependency relying on it would have lost the loader with no diagnostic. loadData now reads its kind through the same helper, so the "does this load" test and the "as what" answer cannot drift apart. * config: rename verifyDeps, and deliver v8Flags on argv verifyDepsBeforeRun becomes verifyDeps, and NUB_VERIFY_DEPS_BEFORE_RUN becomes NUB_VERIFY_DEPS. The name was pnpm's, taken under a policy that grants it only when the value space is adopted verbatim, and that premise lapsed: nub accepts pnpm's five values but honours three, diverging on "install", which is pnpm's own default. It is also not "before run" — the gate fires on file runs, exec, nubx, and run alike. pnpm's .npmrc and pnpm-workspace.yaml keys are untouched; those are the incumbent's and are read as its own. v8Flags now reaches the child on argv rather than through NODE_OPTIONS. Both fields went through NODE_OPTIONS, which accepts only a curated subset, so the V8 flags the field exists for were rejected — measured on 26.5.0, --stack-size, --no-opt, --trace-gc and --harmony are all refused there and all accepted on argv. The allowlist check goes with them, since it describes what NODE_OPTIONS permits and is the wrong gate for an argv flag. The two fields are now genuinely different: nodeOptions is inherited by every Node in the tree and capped at that subset; v8Flags reaches only the process nub spawns and accepts everything. A script that invokes Node by absolute path bypasses both the shim and any argv, and gets neither; that is documented rather than papered over. Docs: nodeCompat moves up beside the Node-behaviour fields, and the loader examples stop mapping extensions that already load by default — .txt was a literal no-op. * config: drop install.nodeOptions No other package manager scopes Node options to the install phase — pnpm's nodeOptions is flat and covers run/exec (Config.ts:271) — and a lifecycle script is not necessarily a Node process, so the field promised something the surface can't guarantee. It was minted for internal "one home per concept" symmetry rather than a reported need. An install.env field can land later if demand appears. The lifecycle path keeps its NPM_CONFIG_NODE_OPTIONS / npm_config_node_options env fallbacks, which are npm/pnpm parity and independent of this field. project_config_sandbox_inert.rs used the field as a liveness control: it steered Error.stackTraceLimit so the test could prove the config layer had actually loaded, without which the surrounding inertness assertions would be vacuous. Nothing left in nub.jsonc is observable from inside a lifecycle script's environment — the engine exports no config-derived npm_config_* — so the control moves out of band: re-plant the same config body with install.nodeLinker "pnp" added and require the install to abort on ERR_NUB_CONFIG_UNSUPPORTED. Reusing the body is what makes it a control; a body that failed to parse would not abort there either. * config: bound every read of an externally-authored config file The project and global nub.jsonc, and the tsconfig the config points at, were read with unbounded read_to_string. Each path is chosen by the config author, so obtaining the bytes is itself unbounded work: a character device consumes memory until the process dies, a writer-less FIFO blocks forever, and a multi-gigabyte regular file is fully resident before the parser is consulted. The failure is an OOM or an unkillable hang rather than an error. jsonc::read_guarded applies a type bound and a size bound. The type check runs first and is what turns a device or a FIFO into an error instead of a hang — stat answers where open would block, and it follows symlinks, so a link to /dev/zero is judged by its target. The size bound is enforced by Read::take, not metadata.len(), because a regular file may under-report its length. 1 MiB matches phantom_scan's existing bound on a hand-written file. The guard lives beside MAX_NESTING_DEPTH because the two are the same job at two stages: bound the bytes, then bound the recursion over them. Every call site keeps its existing error contract — the nub.jsonc readers still return ConfigError::Io, and a tsconfig naming a device still fails as a ConfigError::Value pointing at the `tsconfig` key rather than as a bare I/O error. config.rs's three best-effort reads of the same global file are routed through the guard too; leaving one reader of a file bounded and another unbounded closes nothing. Their fallbacks are unchanged, so a device or an over-cap file now takes the path any other read error already took. * config: replace the flat linker trio with a discriminated union install.nodeLinker, install.hoist and install.symlinkDisablePattern were three flat siblings whose legal combinations were enforced by runtime bails in lower_native_install_settings: hoist was an error under anything but isolated, symlinkDisablePattern under anything but the shared store. The constraint was real but the shape did not express it, so a wrong pairing was accepted by the parser, published by the schema, and only rejected after a resolve. They collapse into one union tagged on strategy, carrying only the knob its layout admits: "linker": "global" | "isolated" | "hoisted" | "pnp" "linker": { "strategy": "global", "eject": [...] } "linker": { "strategy": "isolated", "hoist": true | false | [...] } Each knob is meaningless rather than merely ignored outside its strategy. hoist fills node_modules/.store/node_modules/, which a package symlinked out of the machine-shared store can never reach: Node realpaths a module before resolving from it, so such a package's walk-up ascends the shared store and never re-enters the project (aube link.rs:1538 states this as the leak-free invariant). eject names packages to pull out of a shared store that a project-local layout does not have. publicHoist is new and sits OUTSIDE the union, exposing aube's publicHoistPattern/shamefullyHoist for the first time. It belongs outside because it means the same thing under every strategy — it writes the project's own root node_modules, which exists wherever the store lives, and all four hoist_remaining_into call sites (link.rs:727/737/1466/1476) are ungated by use_global_virtual_store. It serves tools that resolve from the project root rather than through a dependency's walk-up, which is a different problem from a dependency's undeclared import and wants a different key. symlink is renamed global: both it and isolated are symlink-based, so the old name named the mechanism the two share instead of the thing that differs, which is where the store lives. The schema now enforces the pairing where it is written. Each strategy's if/then carries its own additionalProperties, and the knobs live inside the branch rather than on the wrapper, so a misplaced key is reported on that key and completion offers only the knobs the current strategy accepts. Verified against ajv 2020-12: 21 instance documents, every verdict matching the parser. Only the pnp reservation still bails at install time. That abort is load-bearing beyond itself — project_config_sandbox_inert.rs uses it as the control proving the config layer loaded at all. No migration: project nub.jsonc discovery goes live when this branch merges, so the old spelling was never released. The CLI's --node-linker is deliberately untouched; it mirrors pnpm's grammar and is a separate surface from nub's own config. * config: stop the config gating what never reads it, and name the file that failed Review findings on #587. Discovery walks ancestors unbounded to the filesystem root, so one malformed nub.jsonc anywhere above the cwd was taking out commands that consume no project config at all. `nub --version` and `nub --help` were gated because initialization ran before their short-circuits — the two commands a user reaches for when something is already broken. `nub upgrade` was gated too, which is worse: upgrading is a plausible remedy for the thing that broke. Those, plus `init` and `agent`, no longer initialize at all. Forced compat was gated as well, so `--node`, NODE_COMPAT, and the persistent node shim aborted on a file whose contents they never apply. That is the documented zero-augmentation escape hatch failing exactly when it is needed. The runtime entrypoints now degrade: warn, drop the project layer, keep every other overlay, and run. Scoped to a separate initializer rather than folded into the shared one, because nub.jsonc also carries install.* and dlx.*, which compat does NOT disable — a blanket rule would have made `NODE_COMPAT=1 nub install` silently ignore real install settings. Dropping the read entirely would have been worse still: NODE_COMPAT reaches effective_compat_mode only through the snapshot's environment overlay, so skipping initialization would make it run augmented, inverting what the user asked for. Config errors now name the file. Discovery's unbounded walk meant a message saying only `nub.jsonc` sent the author hunting; a new InFile wrapper attaches the absolute path at the reader boundary, leaving the ~30 construction sites and the dotted JSON path untouched. Project install config no longer depends on whether scoping warnings print. ConfigScopeNoise decides warning emission; it was also deciding whether the install block applied, so `why`/`outdated`/`list` resolved against a linker and release-age policy no install would use — contradicting the doc comment directly above the gate. The compat PATH restore no longer swallows a join failure. It is only reachable through Windows quote-stripping, but the consequence is a bare `node` re-entering nub from a supposedly vanilla child, which is compat silently ceasing to be compat. NUB_VERIFY_DEPS_BEFORE_RUN, this variable's name on main, now warns and is honored rather than silently reverting a CI job to the default policy. Docs: the release-age gate's fail-closed posture is stated. It is the deliberate locked position, not an oversight, but a reader could not tell that a too-young range stops the install rather than resolving something older. The schema index no longer claims a versioned file is frozen at a release — set-version.mjs rewrites it on every patch bump in that minor. * config: scope the ConfigError kind accessor to tests Clippy's `-D warnings` caught `kind()` as dead code in the bin target: its only callers are assertions. That is the honest state rather than an oversight, so it is now `#[cfg(test)]` instead of silenced with an allow — no production path branches on a ConfigError variant. Every one either renders it, and `write_naming` unwraps the attribution itself, or propagates it. Tests need it because only the READERS wrap: errors from parse_project_config and the validate_* helpers arrive bare, so an assertion matching a variant must not care which of the two it got. Checked the other items this branch added for the same failure mode (`in_file`, `write_naming`, `retain_effective_config`, `config_overlays`, `initialize_effective_config_without_project`, `initialize_runtime_config_snapshot`) — all have production callers. * config: drop the vestigial nub.jsonc sandbox surface `sandbox`, `install.sandbox`, and `dlx.sandbox` were parsed, shape-validated, retained in the snapshot, and pinned inert by two test files — and consumed by nothing. They are a frontend for a feature whose groundwork is still in flight, so they get written when that frontend is built, not carried here as dead surface. This is a removal, not a move. It also settles a real asymmetry rather than papering over it. The published schema already omitted these keys and set `additionalProperties: false`, so an editor pointed at `$schema` rejected a file the CLI accepted — and the drift test could not catch that, because it filtered `sandbox` out of BOTH sides before comparing. With the parser side gone the two agree exactly, so the exemption is deleted and the test now compares the key sets directly: root 12=12, install 4=4, dlx 2=2, against both published schema files. Neither schema file changed. Four fixtures outside the sandbox tests planted a `sandbox` block as their stand-in for "a valid nub.jsonc" and would have started failing loud on an unknown key; they now use a neutral field. Two tests were reshaped rather than trimmed, because their literal subject was sandbox but the mechanism under them was not. One proved `__NUB_RUNTIME_CONFIG` tolerates fields this binary does not know — a version-skew property for nested launches, kept with an honest name and a neutral unknown field. The other covered wrapper-type errors at nested paths, where three of four rows were sandbox; substituted at the same depths so the table still tests nesting. ConfigKey drops 18 → 15. Verified mechanically: 15 variants, 15 ordinal arms, values contiguous 0..=14, 15 spec rows, both hardcoded counts 15. * config: fix what three review passes found Correctness, impact-analysis and code-quality reviews over the whole diff. Three of the defects are in code added earlier on this branch, and two of those had passing tests that could not see them. Discovery now stops at node_modules. A dependency's lifecycle script runs with its own package directory as cwd and nub's shim on PATH, so a bare `node` in a postinstall re-entered nub from inside node_modules and read the DEPENDENCY's nub.jsonc as the project's — npm packs the repo root by default, so any dependency that uses nub ships one. It would have steered that build's Node, and a `dlx` block or a key from a newer nub would have fail-loud aborted the install naming a file the user never wrote. Skipping rather than stopping keeps the real project's config reachable from inside a dependency. install.publicHoist is one nub knob over two aube settings, and each arm wrote only the one it named, leaving the other at whatever a lower tier resolved. An .npmrc `shamefully-hoist=true` under a publicHoist pattern list hoisted everything — the opposite of naming patterns. Both arms now write both keys. Its test asserted only the key each arm happened to write. install.linker `global` was advisory where `isolated` was authoritative: `isolated` pinned enableGlobalVirtualStore, `global` pinned nothing, so a lower-tier .npmrc beat an explicit request and the docs' promise of one machine-shared store did not hold. Its test asserted the gap as intended. A bad tsconfig path defeated --node and NODE_COMPAT. Every runtime entrypoint resolves runtime_config() before deciding compat, so a stale path aborted the zero-augmentation escape hatch exactly when a broken config was what the user was escaping. Compat runs no transpiler, so the value cannot affect the run and is no longer validated there. RuntimeConfig gains serde(default). It travels through __NUB_RUNTIME_CONFIG as a cross-VERSION wire format, and without a default an older child aborted every run on a field a newer parent added, blaming a file the user cannot see. The unknown-field direction was already tolerated and tested. `nub node` and `nub pm` no longer initialize a snapshot they never read — the same class already fixed for version, help and upgrade. A degraded global-config read is now announced. Dropping the layer drops every key in it, so one type error elsewhere in the file silently widened dlx.consent from `never` back to `prompt`. Compat re-entry no longer discards a user's NODE_OPTIONS: the marker wins only while the current value still carries nub's own injected token, which distinguishes residue from a value the script set afterward without adding an env var. The disk-materialize matcher compiles its patterns once instead of parsing every one on every comparison, inside a loop over every package in the graph. Standalone aube never populates the list, so its path is unchanged. Quality: a comment describing the inverse of its own code, a cited SHA that dies at squash-merge, five prose sentence-start violations, four redundant tests, internal epic markers in tracked files, and a test that leaked XDG_CONFIG_HOME on panic because its restore sat after the assertions. * ci: fail at the install step when the install did not happen A node-24 leg failed five transpile tests on unresolvable `@oxc-project/runtime` helper imports while four sibling legs on the same commit passed. The cause was not the code: `npm` runs through the Socket Firewall shim, which `exec`s sfw, so sfw's status IS the step's status. sfw crashed on a `fetch failed` and exited 0, and the step went green having produced no npm output at all — against `added 94 packages` on every healthy leg. That left a half-built tree, package directories present with no readable package.json, which is why Node reported a raw extensionless path: with no `exports` to consult, `packageResolve` falls through to joining the subpath onto the package URL. An absent tree gives a different message, so the shape of the error is itself the fingerprint of a partial install. The five casualties were exactly the tests that need a real package from the root `node_modules` — four importing an `@oxc-project/runtime` helper and one resolving the Temporal polyfill. Nothing that should have failed passed. So the defect worth fixing is the silence: an install that did not install reported success, and surfaced six minutes later as five cryptic failures in unrelated tests. Assert one resolvable dependency immediately after `npm ci` so an infra failure fails where it happened. * aube: carry project_config into a test fixture main added Merge fallout, caught by Aube parity. This branch's first commit added the `project_config` tier to `ResolveCtx`; main independently added `registry_supports_time_field_resolves_from_workspace_yaml`. The merge combined them cleanly by line, so main's new literal was left without the branch's new field and aube-settings failed to compile its test target: error[E0063]: missing field `project_config` in initializer of `values::ResolveCtx<'_>` Only this one literal was affected; all 33 ResolveCtx constructions in the tree now carry it. Nub's own suite never touched the fixture, which is why every nub job stayed green while aube's own workspace test did not — the parity job is the only thing that compiles that target. * aube: drop the resolver test main's #602 supersedes Follow-through on the conflict note. The `semver_util.rs` hunks were already resolved in favour of main — its shared `version_clears_cutoff` also accepts a packument `modified` timestamp as proof of maturity, so a time-less abbreviated document still resolves and the `vulnerable.rs` fail-open stays closed. `tests.rs` auto-merged, though, so this branch's `test_pick_version_strict_cutoff_rejects_missing_time_entries` survived on its own. Main covers the same ground and more with `pick_version_missing_time_fails_closed_unless_modified_proves_maturity`, which asserts both the fail-closed half and the `modified` bound this branch's version predates. The aube-resolver crate now matches main byte-for-byte; the branch carries no divergence there at all. Left the three pre-existing rustfmt drifts in tests.rs alone — they are on main too, and reformatting them would thicken the fork delta for nothing. * docs: sharpen the nub.jsonc reference block Maintainer review of the reference example. `nodeCompat` said only what it equals, not what it does; it now says it disables every augmentation and keeps the version pin. `envFile` showed only the array form, so the string form and the variable expansion each needed a line of prose to describe them. A commented-out `".env.${APP_ENV}"` shows both shapes at once and demonstrates the expansion instead of explaining it, without a duplicate key. The true/false cases are split onto their own lines. `tsconfig` named a file and said nothing about why nub reads it. The comment now names what it is consulted for — JSX, decorators, and path mapping — and what it is not, which is type checking. That set is the complete one nub actually reads (`nub-native/src/tsconfig.rs`: the four `jsx*` keys, `experimentalDecorators`/`emitDecoratorMetadata`, `paths`/`baseUrl`). * config: rename the shared-store strategy, and restructure the reference Maintainer review. `install.linker`'s `global` becomes `global-virtual-store`. The old name mixed two axes: `global` and `isolated` both lower to aube's `nodeLinker=isolated` and differ only in `enableGlobalVirtualStore`, so `global` named a store LOCATION while its sibling `hoisted` named a LAYOUT. The hoisted path never reads the virtual-store flag at all, so there is exactly one layout under the shared store and the name should say which store, not imply a third layout. The install block's divider claimed the block applies "only when Nub is the project's package manager." True of the BLOCK, which `native_pm_mode` gates, but it reads as "Nub only manages layout when it's your PM" — false, since `nub_setting_defaults` pushes `nodeLinker=isolated` unconditionally. Reworded to make Nub the subject and the block the object. Precedence and global-config move to the top of the page as one nested list, worked through a single field that is settable at every rung, and both standalone sections go. `dlx` joins the main example marked global-only rather than sitting in a second block. * docs: stop inventorying the exceptions Maintainer, on a line spelling out that a project file fails loud while the global one is best-effort: "You do not need to be constantly pointing out edge cases to people. It just overwhelms them. Omission is often better." Recorded in PROSE.md beside the existing rule against defensive editorializing, then swept the docs for the same shape — 22 cuts. The two tells that caught most of them: a sentence whose only job is an asymmetry between two things that behave the same in the common case, and a caveat restating what an earlier line already established. Three FAQ answers each told the reader to type `node` for plain-Node runs; one page said the two delivery tiers are equivalent three times on one screen. One exception moved rather than died: the `nub run dev --watch` forwarding gotcha left the FAQ, where it was stacked, and stays where it bites — both the watch page and run's argument-forwarding section already carry it. Kept deliberately, and worth naming since restraint can overshoot: anything describing a SILENT failure (an ignored mode name, best-effort bunfig parsing) stays, because a reader who hits one has no other signal. So does the SHA-256 sidecar caveat — cutting it would let "verified" imply an authenticity guarantee the checksum does not provide. * site: give docs h3 room, and stop flattening the sidebar chips Two maintainer-reported visual issues, both measured in the browser rather than eyeballed. Docs h3 sat at 32px above against the h2's 48px. On a reference page where consecutive h3s are each a field with its own example, that leaves a heading nearly as close to the block it follows as to the content it introduces, so the sections stop reading as separate. 40px splits the difference. Scoped by source order rather than specificity — the `.blog-prose` rules below match the same elements at equal specificity, so the blog keeps its own scale. The sidebar command chips lost their active state: gray inline-code background against the orange active row, no border. Both halves traced to the global inline-code rules. `border: none` there sets border-STYLE, and CSS computes border-width to 0 whenever style is none — so the chip's orange border colour applied with nothing to paint. And the dark-mode background override is (0,1,2), outranking the chip's single-class background utility, so it won that too. Measured before: borderWidth 0px, background rgb(42,38,32) — the override's value exactly. The chip now opts out of both via a marker class, and reads 1px orange at 10% tint. * docs: correct the per-incumbent layout table Two rows were false, and the site contradicted itself about both. Yarn's `.yarnrc.yml` `nodeLinker` was documented as "not read as a layout request." It is read and translated — `node-modules` to hoisted, `pnpm` to isolated (aube-registry yarnrc.rs) — which `install/yarn.mdx:103` already said. Bun was documented as having no layout knob at all; bunfig's `[install].linker` exists and nub reads it (pm_engine/bun_config.rs), which `install/bun.mdx` already said. Both code paths predate the table by weeks. It traces to a local research note that asserted the opposite under a "GROUNDED — verified in code" label, citing a line range that no longer described the path it named. That note is corrected in place with the provenance, since the label is what carried the error into shipped docs. Also qualified the neutral-key claim. pnpm 11 stopped reading behavior settings from `.npmrc` and nub mirrors that per-major, so `node-linker` there is silently inert and `pnpm-workspace.yaml` is the only lever — worth stating rather than omitting, because the failure is silent. * docs(config): state the path-resolution rule once, at the top The rule was repeated under `preload` and `envFile` and stated below the code blocks it governed. It now sits above the reference block — the first one containing a path — and the two repeats are gone. Also links the `envFile: true` description to the env page's file precedence section, so the automatic set is one click away. * feat(config): make install.publicHoist patterns-only The blanket boolean read as if it overrode `linker`, which it does not: it surrenders the isolation guarantee while leaving the layout alone. pnpm has the same knob and desugars it — `shamefully-hoist: true` is stored as `publicHoistPattern: ['*']` (getConfig.ts) — so `["*"]` already spells "everything" without a second, differently-shaped way to say it, and reads honestly as a pattern. A bare `true` is now a type error. `[]` is the opt-out and still beats a lower tier's list. Lowering writes `shamefullyHoist=false` alongside the joined pattern, because naming patterns is always a narrowing and leaving the flag at a lower tier's value would invert the request. * fix(linker): hoist workspace members into the root modules dir pnpm surfaces workspace members in the root `node_modules` alongside the registry packages a hoist pattern matches, and draws them from the project list rather than the dependency graph — so a member nothing depends on is still hoisted. Its own comment says the lockfile walker cannot do this. aube reached them through neither path: the graph hoist skips every `local_source` package, and a member that is nobody's dependency is not in the graph at all. Adds a separate pass over `workspace_dirs`, linking each member to its own directory since it has no `.aube` entry to point at, gated on `hoist_workspace_packages` to match pnpm's `hoist-workspace-packages`. Root direct deps keep their slot, matching the graph hoist's first-writer-wins rule. `link_all` is untouched: a lone project has no members. Verified against pnpm 10.15.1 on a two-member workspace with `public-hoist-pattern=*` — root held `alpha beta debug ms`, where the registry deps prove the pass ran and the members are the behavior under test. With `hoist-workspace-packages=false` it held `debug ms`. The new test mirrors both directions, and fails on the first assertion with the pass disabled. * fix(config): reject global-virtual-store with injected deps in nub's own words An injected dependency resolves through the hidden hoist tree, which only a project-local store has, so the engine pushes an explicit `hoist=true` for one and then refuses that pair. Pinning the shared store from the projectConfig tier put a project that writes the documented default layout on exactly that path: the install aborted quoting `enableGlobalVirtualStore` and `hoist`, neither of which appears in a nub.jsonc. Reject the combination up front, naming `install.linker` and `dependenciesMeta.*.injected` instead. `nub ci` is untouched and correct as-is: its project-local store is an embedder-tier default that explicit user config is documented to outrank (see engine_session_ci). Also corrects two docs claims the code does not make. The release-age gate no longer counts a missing publish time as too young — a packument `modified` at or before the cutoff proves maturity (semver_util.rs) — and the fail-loud rule is the project file's: a malformed global nub.jsonc is reported and skipped so a typo in personal defaults cannot block someone else's project. * fix(aube): carry project_config through the ResolveCtx literals main added The v1.35.0 sync added three ResolveCtx constructions in aube-settings test code. This branch adds a `project_config` field to that struct, and neither side touched the other's lines, so the merge succeeded into a tree that does not compile. Only the parity job builds aube's test targets — aube is not in nub's workspace, so nub's own fmt, clippy and test gates are all green against this. The same shape broke this branch once before. * fix(install): report the layout the install actually builds The header printed `isolated` on the default path while linking a shared-store tree — the first line of the install describing a layout nobody gets. With no config the packages symlink into the machine-global store, which is what `global-virtual-store` names. Three things flip it back to project-local and they arrive by different routes, which is why one setting cannot answer it: an explicit `enableGlobalVirtualStore=false` and the `hoist=true` nub pushes for injected dependencies both reach the settings index, but a CI environment does not — `Linker::new` derives that from `is_ci()` at construction. Verified against all six paths with the store shape on disk as the oracle. Also documents `verifyDeps: true`, which the parser and schema both accept, and corrects a reference-block comment still describing the whole `install` block as package-manager-scoped after the axis split. * feat(config): edit nub.jsonc from the command line, and stop reading layout from branded config `nub config get`/`set`/`delete` now address every `nub.jsonc` field by dotted path, writing through the JSONC concrete syntax tree so comments, blank lines, key order and trailing commas survive an edit. A value is validated by building the one-key document and running the file parser over it, so the writer cannot accept what the reader would refuse and the rejection carries the reader's own wording. Keys that are not fields of this file keep their existing meaning and still reach `.npmrc`. `--help` now lists the wired subcommands, `path` included, and hides the three that hard-error. Layout no longer comes from another package manager's branded config. Yarn's `.yarnrc.yml` `nodeLinker`, Bun's `bunfig` `[install].linker`, and the layout keys in `pnpm-workspace.yaml` no longer steer the tree; the neutral `.npmrc` surface and `nub.jsonc` remain the levers. A lockfile records resolution and stays binding — a config file states intent about arrangement, and arrangement is nub's own axis. The pnpm half is gated on a new embedder posture rather than deleted, so standalone aube is unchanged. Yarn's PnP abort is untouched: refusing a tree nub cannot build is not a layout preference. Also carries three reporting fixes the sweeps caught. The layout header was wrong for a project declaring next or react-native, which reaches a project-local store through the manifest rather than a setting; the hoisting row ignored `shamefully-hoist`, printing nothing while every transitive sat at the root; and `nubx --node` on the dlx fallback ran fully augmented, because that path inherits compat through a subprocess environment a CLI flag never entered. * fix(dlx): honor --node on the dlx and x spellings `nubx`, `nub dlx` and `nub x` are the same command, but `--node` worked only on the first. On the other two it reached the registry as a package name — and took the rest of the line with it: `DlxArgs.params` is trailing_var_arg with allow_hyphen_values, so `--node` became the command positional and swallowed everything behind it. `nub dlx --node -p <spec> <tool>` failed with `package not found: --node` while `-p` sat right there unparsed. Strips the flag off argv before clap sees it, scanning the flag region only and stopping at the first bare positional, so a `--node` past the tool name still forwards to the tool. Feeds the existing `dlx_child_env(compat_mode)` plumbing nubx already uses. Also records the piped-gate foot-gun in the dev-loop skill: a pipeline reports the last command's status, so `cargo test | tail` reads as success while tests fail, and a compile failure prints no FAILED line for a grep to find. * feat(install): name the linker row, and point a dropped layout setting at nub.jsonc The header row is now `linker`, matching the field it reports — `install.linker` in nub.jsonc. It reported `layout` before, naming the axis rather than the knob, so the row and the setting a reader would go edit used different words. A project carrying a layout setting nub no longer reads — Yarn's `.yarnrc.yml` nodeLinker, Bun's bunfig `[install].linker`, or the layout keys in pnpm-workspace.yaml — now gets `(configurable via nub.jsonc)` in place of `(default)`. No warning line: the setting being ignored is a boundary this release moved, not the user's mistake. The pointer yields to real provenance, so a project already using nub.jsonc or .npmrc sees where its value came from instead of advice it does not need. Detection rides the same posture that decides whether nub opens each file at all, so a bunfig in a pnpm project is not misattributed. Docs drop the "layout is Nub's own axis" framing for what it actually means: nub matches the incumbent on version and module resolution and respects its lockfile, and how packages get linked into node_modules is set only in nub.jsonc or the neutral .npmrc keys. Eight sites, plus the rule behind it in PROSE.md — an abstraction standing in for the subject is the tell that a sentence has not been written yet. * fix(config): never replace a config file nub could not read `nub config set` treated an unreadable or unparseable `nub.jsonc` as an empty document, then wrote that document back over the file — so a stray brace in a hand-annotated config, plus any `set`, cost the user the whole file. Only absence is a blank slate now; every other failure refuses and names the line, leaving the bytes untouched. Reads already declined these files, and a `set` that destroys what a `get` would not read is the worst outcome this surface has. Requires jsonc-parser 0.32.4 for `object_value_or_create`, which creates the root object only when there is no root value at all, where 0.24's `ensure_object_value` would coerce whatever it found. A UTF-8 BOM no longer makes the file unusable. Windows editors and PowerShell write one by default, and the parser reported it as a syntax error at line 1 column 1 — pointing at a file that looks correct to whoever wrote it. Stripped at the read boundary, so every reader and the CST writer agree. Writes also restore the file's prior mode. The atomic rename installs a new inode with default permissions, so a config narrowed to 600 came back 644; widening someone's permissions is not ours to do as a side effect of setting a key. * fix(config): stop nub answering config questions with the engine's brand `nub config` printed `defaultLockfileFormat=aube`, and the two settings spelled `aubeNoLock` / `aubeNoAutoInstall` were only settable under that name. All three are the engine's brand on a surface a nub user reads. Fixed additively rather than by renaming: the lockfile-format enum gains a `nub` value mapping to the same format, and the two settings gain `nubNoLock` / `nubNoAutoInstall` aliases. A bare rename would break any standalone aube user who has written the old spelling, and these are user-settable values, so the vendored engine keeps accepting and defaulting to its own names. Nub's fresh-project default now selects `nub`. That is the existing conditional, not a new push — an unconditional one clobbered the `pnpm` arm that keeps a pnpm-signalled project writing pnpm-lock.yaml, which the workspace dedupe test caught. * fix(config): keep the author's BOM across a config write Stripping the BOM at the read boundary made a BOM'd file usable, but the CST writer then wrote the stripped text back — so a one-key `set` rewrote line 1 of the very files the fix was for, defeating the comment-preserving guarantee that writer exists to provide. The CRLF test next to it pins the same Windows concern. Restored in `write_preserving_mode`, alongside the mode it already carries across the atomic rename: both are properties of the file the author had that a new inode would otherwise drop. Only a LEADING U+FEFF is a marker — anywhere else it is data and passes through untouched. * fix(config): apply the preserved mode before the rename, not after `write_preserving_mode` committed the file through `atomic_write` and then restored the prior mode. Between those two steps the config was live at the temp file's default permissions, so a `600` file the user had narrowed was briefly `644` — and stayed `644` for good if the process was killed in that window. It failed in the widening direction, which is the wrong one. `atomic_write_with_permissions` sets the mode on the temp file instead, so it lands in the same atomic step as the content: either the rename happens and the file is wholly correct, or it never happens. Nothing to tear down and nothing to leak on a kill. `atomic_write` delegates with `None`, so no other caller changes. The mode-set is deliberately best-effort rather than `?`: a filesystem that will not carry it must still get the content, which is what the previous restore-afterwards code also ended up with. `pm/shim.rs` already wrote its temp file this way; this brings the config writer in line with it. * fix(config): drop the nubNoAutoInstall npmrc alias Added alongside `nubNoLock` as part of keeping the engine's brand off the settings a nub user reads. `nubNoLock` was right — `take_project_lock` is reached from `ci`/`remove`/`dedupe`/`import`/`clean`/`fetch`/`link`, all live engine verbs under nub. `aubeNoAutoInstall` is not. Its only consumer is `ensure_installed` in `commands/auto_install.rs`, called from `commands/run.rs` and `commands/exec.rs` — both in the nub-reserved exclusion set, where the script-runner family routes to nub's own runner instead. So the alias put nub's name on a setting nub never reads, which is worse than the engine brand it was meant to remove. Nub's equivalent is `install.verifyDeps` in `nub.jsonc`, which `verify_deps::resolve_policy` deliberately keeps independent of the engine's `resolve_verify_deps_before_run`. * feat(install): say why the linker went project-local Four routes force a project-local store. Two of them carried no provenance, so the row rendered a bare `linker isolated` — a value that contradicts the documented `global-virtual-store` default, with nothing to explain it and nothing in the reader's config to check. A stock Next.js project hit this with no configuration at all. linker isolated (global virtual store auto-disabled in Next projects) linker isolated (global virtual store auto-disabled in CI) `Source` gains `Ci` and `IncompatiblePackage`. The latter carries the matched PACKAGE rather than the glob that caught it, since that is the name the reader recognizes from their own manifest, and renders the toolchain's proper name for the two seeded triggers — "react-native projects" reads as a typo for the thing they actually use. Neither is an authored surface, so a project that wrote a layout key somewhere nub no longer reads still gets pointed at the file that would work. Setting it explicitly beats both routes anyway: the engine resolves this as `explicit.or_else(..)` in `commands/install/gvs.rs`. * fix(install): decide the report's booleans the way the engine does Two defects in the resolved-layout header, both found by review. The header compared raw setting text to `"true"`, but the tiers it reads are unparsed `.npmrc` lines and env vars, and the engine's `parse_bool` accepts `1`, `TRUE` and `True`. The two disagreed on exactly those spellings, in both directions, so the header could print the opposite of the tree the install was about to build: `hoist=1` printed `global-virtual-store` while the engine vetoed the shared store and built a hidden tree, and `enable-global-virtual-store=1` printed `isolated` while the engine symlinked into the machine-global store. A wrong provenance is worse than none, and this module's whole claim is that it reproduces the resolver rather than approximating it, so the boolean rule is now the resolver's own. The same compare also called `auto-install-peers=1` a non-default and printed a row for a setting sitting at its default. Separately, `layout_row` asked `is_ci()` itself. That made the CI arm a process global, so its tests had to be wrapped in `if !is_ci()` — which on the leg that gates merge is always true, meaning one test constructed three fixtures and asserted nothing there. CI-ness is now captured into `SourceIndex` alongside every other tier at `load`, the layout decision is a pure function of that struct, and all four assertions run unconditionally. Two arms that were only checking `.0` now assert the provenance too. * test(watch): give the reload wait a budget the machine can meet `wait_for_watch_reload` allowed 15s. What it waits on is a real process restart — nub startup, Node startup, an oxc transform — and a legitimate pass was measured at 16.85s on a contended host: past the budget while doing nothing wrong. Both callers failed that way under load and passed 3/3 once the box quieted. Raised to 60s. This is not a retry wrapper or a loosened assertion: the restarted child must still report the new values to satisfy the caller, and a real failure still fails — just later, on a wait that normally returns in ~6s. The earlier fix in this helper (re-poking to close the watcher-registration race, 7 failures / 200 runs) addressed a different cause and is untouched. * docs(config): correct the Windows global config path The precedence list said `%APPDATA%\nub\nub.jsonc` on Windows. `config_dir` resolves `~/.config/nub` for any home outside the system root, and only falls back to `%APPDATA%` for a service or SYSTEM account. An ordinary Windows user following this would create a file nub never reads — a silent no-op on the one surface whose selling point is that it fails loud. Points at `nub config path`, which prints the truth. Two more, from the same audit: `install.minimumReleaseAge` read as opt-in. A 24-hour window is already on by default, so the field widens or narrows an existing gate; says so, links the cooling-window section, and names `"0s"` as the way off. The `verifyDeps` note said pnpm's `install` and `prompt` "are accepted as values but behave like warn" with no scope. That holds for `.npmrc`, but `nub.jsonc` rejects them — and the sentence now sits below a `nub.jsonc` example, so a reader would write one and get an aborted command. * fix(config): don't panic on a non-UTF-8 environment `expand_runtime_path` collected `std::env::vars()`, which panics outright if any key or value in the process environment is not valid Unicode. It runs on the runtime path — once per `envFile` source, for every `nub <file>` or `nub run` in a project that sets one — so a single odd variable inherited from the shell (a path off a differently-encoded filesystem, an exotic locale string) aborted the run with a Rust panic rather than any nub error. `vars_os()` with a lossless filter instead. A name that cannot round-trip is not a name `$VAR` expansion could reference, so dropping it costs nothing. * test(config): pin that a narrowed config stays narrowed `write_preserving_mode` and `atomic_write_with_permissions` exist for one reason — a `600` config must not come back `644` after `nub config set` — and neither had a test. Dropping the mode from the write was invisible: every other test passed while each set quietly republished a private file at the process umask. Verified by control, not just by passing: with the mode argument replaced by `None` the test fails with "a 600 config must not come back 644 after a set". Also covers the brand-new-file arm the doc comment promises, and returns early under root, where the kernel ignores mode bits and the assertion would measure nothing — reachable because AGENTS.md routes config work through `docker run`, which is root by defa…
|
Shipped in v0.7.0: https://github.com/nubjs/nub/releases/tag/v0.7.0 |

minimumReleaseAgetreated a version with notime:entry as eligible, so a packument without publish times silently disabled the gate, even underminimumReleaseAgeStrict=true.Repro: with a 10-year cutoff and strict on, adding
registry-supports-time-field=trueto.npmrcflippednub add strip-ansi-cjsfromERR_NUB_NO_MATURE_MATCHING_VERSIONto a clean install.A missing time is now resolved against the packument's
modified— an upper bound proving every version in the document is mature, which keeps abbreviated metadata usable without a full fetch. When even that can't settle it,strictdecides: strict blocks, lenient stays eligible as before, so standalone aube's default is preserved. Themodifiedbound is pnpm 11's mechanism (pickPackageFromMeta); the terminal default differs, since pnpm warns-and-skips where nub blocks.The vulnerability re-pick ranks in two tiers rather than vetoing: an undated safe version still beats returning a known-vulnerable fallback, but a dated-and-too-new one stays excluded.
Also gives
registrySupportsTimeFieldaworkspaceYamlsource and field — pnpm reads it only frompnpm-workspace.yaml.BEHAVIOR CHANGE: under strict, a registry serving no
timemap blocks packages whosemodifiedis newer than the cutoff. Escapes:minimumReleaseAgeExclude,minimumReleaseAge=0.Overlaps #587, which carries the same
strictgate without themodifiedbound.Closes #581