Skip to content

fix(resolver): fail closed when publish time can't prove a version's age - #602

Merged
colinhacks merged 5 commits into
mainfrom
age-gate-missing-time
Jul 29, 2026
Merged

fix(resolver): fail closed when publish time can't prove a version's age#602
colinhacks merged 5 commits into
mainfrom
age-gate-missing-time

Conversation

@colinhacks

@colinhacks colinhacks commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

minimumReleaseAge treated a version with no time: entry as eligible, so a packument without publish times silently disabled the gate, even under minimumReleaseAgeStrict=true.

Repro: with a 10-year cutoff and strict on, adding registry-supports-time-field=true to .npmrc flipped nub add strip-ansi-cjs from ERR_NUB_NO_MATURE_MATCHING_VERSION to 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, strict decides: strict blocks, lenient stays eligible as before, so standalone aube's default is preserved. The modified bound 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 registrySupportsTimeField a workspaceYaml source and field — pnpm reads it only from pnpm-workspace.yaml.

BEHAVIOR CHANGE: under strict, a registry serving no time map blocks packages whose modified is newer than the cutoff. Escapes: minimumReleaseAgeExclude, minimumReleaseAge=0.

Overlaps #587, which carries the same strict gate without the modified bound.

Closes #581

…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
Copilot AI review requested due to automatic review settings July 29, 2026 05:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview, Comment Jul 29, 2026 6:35am

Request Review

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The 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_cutoff as the single publish-age wall — one helper in semver_util.rs decides all three cases: time known, hole in a populated time map, and no per-version times at all.
  • Admit timeless packuments via modified as an upper boundmodified <= cutoff proves every version in the document is mature, keeping abbreviated (corgi) metadata usable; absent or newer modified blocks.
  • Treat a created/modified-only time map as timeless — avoids a raw is_empty() misreading a bookkeeping-only mirror response as "populated, with holes".
  • Route the vulnerability re-pick through the same helperprefer_non_vulnerable_pick had its own copy of the comparison that kept the fail-open after pick_version dropped it.
  • Replace the old fail-open test with four-case coverage — plus a bookkeeping-only-map test; both drive pick_version directly with strict=true.
  • Extend registrySupportsTimeField — new docs paragraph on the interaction, plus a workspaceYaml source and precedence = ["workspaceYaml", "npmrc"] matching the minimumReleaseAge* 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-328 states that "corgi's missing time map keeps its versions cutoff-eligible in pick_version, so either format picks correctly" — the justification for serving an offline add from 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:166 skips the trust check on packument.time.is_empty(). For a mirror serving only created/modified, that check reads the map as populated and the next line raises TrustCheckError::MissingTime — the same misread the new helper's comment calls out at semver_util.rs:109-112.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ The wall is unconditional, so this changes standalone aube's behavior on its default path — 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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Comment thread vendor/aube/crates/aube-resolver/src/semver_util.rs Outdated
Comment thread vendor/aube/crates/aube-resolver/src/resolve/vulnerable.rs Outdated
Comment on lines +2822 to +2823
and blocking the rest. Getting this flag wrong costs over-blocking,
never a silent bypass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ 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 itprefer_non_vulnerable_pick now 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_packument become pub(crate) so sibling modules can build packuments.
  • Give registrySupportsTimeField its WorkspaceConfig field — the earlier commit declared a workspaceYaml source without the matching struct field, which the settings-meta parity self-test rejects. The new values.rs test asserts the value actually resolves rather than merely that the metadata names a real field.
  • Rewrite the stale invariant comment on the offline add fallbackadd/manifest.rs no longer claims corgi's missing time map keeps versions cutoff-eligible.

The four other threads from the first review are unchanged by these commits and remain open.

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

Comment on lines +81 to +86
} else if packument.time.contains_key(ver_str) {
// Dated and too new — excluded outright.
continue;
} else {
&mut best_undated
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ 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?
Suggested change
} 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
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Comment on lines +333 to +337
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ "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.
Suggested change
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

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 strictversion_clears_cutoff takes a fourth strict argument and returns modified_proves_maturity || !strict for 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 testpick_version_lenient_mode_keeps_undated_versions_eligible asserts 1.1.0, which distinguishes "stayed eligible" from "blocked, then fell back to the lowest" rather than passing either way.
  • Hard-code strict = true in 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 modified upper 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?

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ This makes 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?

@colinhacks
colinhacks merged commit d34d841 into main Jul 29, 2026
52 checks passed
colinhacks added a commit that referenced this pull request Jul 29, 2026
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.
colinhacks added a commit that referenced this pull request Jul 29, 2026
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.
colinhacks added a commit that referenced this pull request Jul 29, 2026
* 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.
colinhacks added a commit that referenced this pull request Jul 31, 2026
…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…
@colinhacks

Copy link
Copy Markdown
Contributor Author

Shipped in v0.7.0: https://github.com/nubjs/nub/releases/tag/v0.7.0

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

minimumReleaseAge: missing publish-time data fails open, even in strict mode

2 participants