Date: 2026-04-29
Status: Design proposal (user facing design only; internal implementation deferred)
Recent supply-chain attacks against package registries (npm, PyPI, RubyGems) have followed a recurring pattern: a maintainer's credentials are compromised or a dormant package is taken over, a malicious version is published, and automated dependency tooling pulls it into thousands of downstream projects within hours — well before the release can be reported and either flagged, retired or removed. Analyses of these incidents observe windows of opportunity under a week between publication and detection.
The mitigation that's converging across ecosystems is a dependency cooldown: a configurable delay between when a package version is published and when a package manager will install it. Versions younger than the cooldown are excluded from the solver's candidate set. By the time they age into eligibility, the community has typically had time to detect and yank malicious releases.
This spec defines how Hex exposes that capability through local project/user config and, when the Hex Organization Dependency Policy feature is active, through a signed org policy field. Implementation details are deferred to a follow-up.
- Filter young package versions out of dependency resolution, so automated upgrades don't pick up freshly compromised releases.
- Be opt-in. Default behavior is unchanged (cooldown = 0 days).
- Match how Hex users already configure things: same precedence, same place in
mix.exs, same env-var pattern. - Allow org admins to enforce a cooldown through dependency policy; policy cooldowns only make the effective cooldown stricter.
- Match conventions other ecosystems have settled on, so users coming from npm/uv/cargo find familiar shapes.
- Keep the surface area small: one duration knob, contributed by local config or by active org policies. No per-dep options, no exclusion lists in v1.
- Refusing to install a young version that's already in
mix.lock. The lockfile is an explicit, reviewable artifact; if a young version is in there, a human put it there. Resolution-time filtering only. - Server-side enforcement at the registry level. Out of scope for this design — purely client-side.
- A CLI flag like
--cooldown/--no-cooldown. Mix doesn't pass flags through to Hex tasks reliably; environment variables cover the same use case. - Per-dep tuning. The single project-wide value plus env/global config and optional org-policy floor are enough for v1; per-package carve-outs are deferred (see Out of scope).
In scope: an attacker publishes a malicious version of a package the project depends on (directly or transitively). The attack succeeds when automated tooling — a developer running mix deps.update, a CI job re-resolving, a Renovate-style bot — locks the malicious version before it's detected.
Out of scope: malicious versions inserted into a project's mix.lock through review-bypassing means (forged PRs, compromised CI credentials, etc.). Lockfile review is the right control for that.
A related concern: cooldown protects against unknown-bad versions, but it must not trap users on known-bad ones. If the project's currently locked version has been retired since it was put in the lockfile — or, prospectively, has been flagged with a security advisory — the user re-resolving is trying to escape that version. Cooldown must not block the escape. See Bypass for unsafe current versions in the design.
Cooldown applies only at resolution time, by shrinking the candidate version set the solver sees. The solver runs normally on the filtered set. The lockfile is trusted at install time.
Concrete consequences:
mix deps.getagainst an existing lockfile: cooldown does not apply; install proceeds from the lockfile.mix deps.getfor a new dep with no lock entry: cooldown filters candidates; solver picks the newest eligible.mix deps.update/mix deps.update <pkg>: re-resolution; cooldown filters candidates.mix hex.outdated: shows latest version including in-cooldown ones, with an indicator.mix hex.info <pkg>,mix hex.package fetch,mix hex.publish: cooldown does not apply (information / explicit / publishing).
One knob — cooldown, a duration string — readable from three local sources and from active signed organization policies:
# mix.exs
defmodule MyApp.MixProject do
def project do
[
deps: deps(),
hex: [cooldown: "7d"]
]
end
end# ~/.hex/hex.config
cooldown: "7d"# Environment
HEX_COOLDOWN=7d mix deps.get# Signed Hex Organization Dependency Policy payload
cooldown: "14d"| Key / field | Sources | Default | Meaning |
|---|---|---|---|
cooldown |
mix.exs :hex block, hex.config |
"0d" (off) |
Local minimum age a version must have to be considered. |
HEX_COOLDOWN |
env | empty | Same value as local cooldown. Empty string contributes nothing. |
Policy.cooldown |
active org dependency policies | unset | Org-enforced minimum age. Only makes effective cooldown stricter. |
Local cooldown uses standard Hex precedence:
HEX_COOLDOWN=<duration>env var (HEX_COOLDOWN=0disables for the invocation; emptyHEX_COOLDOWN=contributes nothing — falls through to the next source)cooldowninmix.exs:hexblock (project policy)cooldownin~/.hex/hex.config(user / persistent machine policy)- Default
"0d"(no cooldown — current behavior, fully backwards compatible)
Then Hex takes the maximum duration across that local result and every cooldown field from active organization dependency policies. This is strictest-wins composition. A policy cooldown is a floor: HEX_COOLDOWN=0 can disable the local contribution for one invocation, but it cannot reduce or disable an active policy cooldown.
Relative durations only. Integer + unit. Units: d (days), w (weeks), mo (30-day months). One canonical spelling per unit.
- Valid:
"7d","14d","2w","1mo","0d","0" - Invalid:
"7 days","7day","1m","1 month"
Relative durations evaluate against wall-clock time at resolve start; the same project re-resolved a week later yields a different cutoff. Absolute timestamps are deferred to v2 — they exist for reproducible-build use cases, but the demand hasn't surfaced yet and adding them now would expand the validator surface.
| Command | Cooldown applies? | Behavior |
|---|---|---|
mix deps.get (lockfile present, no changes) |
No | Install from lockfile. |
mix deps.get (resolution needed) |
Yes | Filter candidates, solve normally. |
mix deps.update |
Yes | Re-resolve with filtered candidates. |
mix deps.update <pkg> |
Yes | Re-resolve <pkg>; hard error if no eligible version exists within constraint. |
mix deps.unlock --all + mix deps.get |
Yes | Same as fresh resolution. |
Mix.install/2 |
Yes | Resolution runs every invocation; cooldown from env var + global config + active policies applies. |
mix hex.outdated |
Display-only | Show latest with (cooldown) indicator + eligible-on date; do not hide. |
mix hex.info <pkg> |
No | Info command, list all versions. |
mix hex.package fetch |
No | Explicit user request for a specific version. |
mix hex.publish |
No | Cooldown is about consuming, not publishing. |
In any row above where cooldown applies, the per-package bypass for retired current versions (see Bypass for unsafe current versions) lifts the filter for that package only.
Filter has remaining candidates. Solver runs normally on the filtered set, picks the newest eligible version within the constraint. No warning. This is the common case (e.g. 1.7.2 is in cooldown so the solver picks 1.7.1).
Filter empties the candidate set within a constraint. Hard error. Two flavors:
-
Pre-flight check (direct deps). Before invoking the solver, for each top-level requirement compare the unfiltered version set to the cooldown-filtered set. If unfiltered has matching versions but filtered is empty, raise a Hex-specific cooldown error immediately. This catches the common case crisply.
-
Solver-output augmentation (transitive deps). If the solver fails after running, scan the failure tree for terms whose "no matching versions" was caused by cooldown filtering, and append a
Note:block explaining cooldown was the reason and how to bypass.
Pre-flight error (direct dep with all candidates in cooldown):
** (Mix) Hex dependency resolution failed
All versions of "phoenix" matching "~> 1.7" are in cooldown:
1.7.14 published 2026-04-26 (3 days ago), eligible 2026-05-10
1.7.13 published 2026-04-25 (4 days ago), eligible 2026-05-09
Effective cooldown is 14 days (7d from mix.exs, 14d from myorg/strict-prod policy).
To proceed:
* Wait until 2026-05-09 and re-run
* If the cooldown is local-only, bypass for this run: HEX_COOLDOWN=0 mix deps.get
* If a policy contributes the effective cooldown, ask the org admin to lower or remove it
When cooldown is configured globally rather than in mix.exs, the source line names ~/.hex/hex.config. When set via env var, it names HEX_COOLDOWN. When one or more policies contribute, the source line lists only the source or sources that determine the effective maximum.
Augmented solver output (transitive dep filtered out by cooldown):
Because "ecto ~> 3.12" depends on "decimal ~> 2.0" which doesn't match any versions, version solving failed.
Note: cooldown filtered all versions of "decimal" matching "~> 2.0":
decimal 2.0.1 published 2026-04-26 (3 days ago), eligible 2026-05-10
If this cooldown is local-only, re-run with HEX_COOLDOWN=0 to confirm and bypass.
If a policy contributes the effective cooldown, update that policy or wait for eligibility.
The PubGrub explanation is preserved verbatim. The Note: block is appended below it so the solver's voice and the cooldown advisory don't collide.
A cooldown defends against unknown threats — a maliciously published version not yet detected. It must not strand users on known threats. If the lockfile currently points at a version that has been retired (or, prospectively, flagged with a security advisory), the user re-resolving is trying to escape that version; cooldown must not block the escape.
When re-resolution runs (mix deps.update, mix deps.update <pkg>, a fresh resolve after mix deps.unlock, Mix.install/2), Hex first walks the existing lockfile. For each entry it consults the registry's retirement status for that exact {name, version}. Any package whose currently-locked version is marked retired is added to a per-resolution bypass set: cooldown filtering and the pre-flight check skip that package. Other packages still have cooldown applied normally.
The solver then picks the newest matching version as usual. If every newer version is also retired, that is not a cooldown problem.
Properties worth being explicit about:
- Per-package, not global. A retired version of
foosays nothing about the safety of a freshbarrelease. Only the affected package's cooldown is lifted. - Driven by current state, not constraints. What matters is what the lockfile currently points at. A
~> 1.7constraint with no lock entry, or a lock entry pointing at a non-retired version, behaves as before. - Empty / new projects. No lockfile means no entries to inspect; nothing is bypassed; cooldown applies normally to the initial resolve.
mix deps.getwith an unchanged lockfile. Resolution does not run, so the bypass set is irrelevant — but the install proceeds from a retired locked version. This is the existingmix hex.auditregime: surfaced as a warning, not blocked. Cooldown does not change that.
Vulnerability flagging. The same principle applies to versions with a published security advisory, but Hex's registry protocol does not yet carry per-release advisory data. v1 covers retirement only. Extending the bypass to advisory-flagged versions is a follow-up gated on protocol work in specifications — when it lands, the bypass mechanism is identical, only the input changes.
Cooldown changes display, not filtering, in this command:
Dependency Current Latest Update possible Requirement
phoenix 1.7.10 1.7.14 Yes ~> 1.7
ecto 3.11.0 3.12.5 Yes (cooldown) ~> 3.11
plug 1.15.0 1.16.0 No ~> 1.15
Versions in cooldown:
ecto 3.12.5 — eligible 2026-05-02 (3 days)
Yes (cooldown) means an update exists but is currently filtered out. The legend below the table lists each held-back version with its eligible date and remaining time. This mirrors how Renovate surfaces "pending" updates.
No format changes. The lockfile records the chosen version and checksum; cooldown is a property of the resolution input, not the locked output. A re-resolve at a different time naturally produces a different lockfile.
# mix.exs
defp hex do
[cooldown: "7d"]
endThat's it. Every direct and transitive dep now requires versions to be at least 7 days old before the solver considers them.
# CI config
HEX_COOLDOWN=14d mix deps.getProject may declare cooldown: "7d" in mix.exs; CI tightens to 14 days for its runs without changing the project file.
# ~/.hex/hex.config
cooldown: "7d"Every Hex resolution this user runs — across all projects, including Mix.install/2 scripts — applies the cooldown unless the project overrides it.
HEX_COOLDOWN=0 mix deps.update phoenixUser has decided phoenix's just-released hotfix is worth pulling immediately. This bypasses only the local cooldown contribution; if an active org policy contributes a cooldown, the policy value still applies.
An organization policy with cooldown: "14d" requires every governed fresh resolution to use at least a 14-day minimum release age. A project may choose cooldown: "30d" to be stricter, but it cannot lower the policy value.
HEX_COOLDOWN=7d elixir -e 'Mix.install([{:phoenix, "~> 1.7"}])'The project-less script still applies the cooldown. Same mechanism a CI step would use to enforce a baseline.
These are real questions but they're internal-implementation, not API:
- Where exactly the filter hooks into
Hex.Registry.Server.versions/2and how it interacts with the cache. - Where publication timestamps come from in the registry payload (they exist; the question is the surface).
- Exact algorithm for the post-solver failure augmentation (walking PubGrub failure trees to identify cooldown-caused terms).
- Performance: pre-flight check needs to avoid extra registry round-trips.
- How
mix hex.outdatedretrieves the eligible-on date. - Telemetry / diagnostic logging.
- Refusing to install lockfile entries. Resolution-time only. The lockfile is trusted.
- CLI flags.
HEX_COOLDOWNenv var covers the same use cases without Mix-pass-through complications. - Per-dep
cooldown:option ({:phoenix, "~> 1.7", cooldown: false}/cooldown: "30d"). Deferred unless real demand surfaces in v1 usage. Per-dep tuning is a real ergonomic loss for projects that need a single "trust this internal package" exemption, but the cost in surface area (validation, error-message variants, interaction withoverride:) outweighed the demand at the time of revision. - Per-org / per-repo exclusion lists (
cooldown_exclude_orgs,cooldown_exclude_repos). Org-wide enforcement belongs to Hex Organization Dependency Policy viaPolicy.cooldown; exclusions remain out of scope. - Absolute-timestamp duration format (
HEX_COOLDOWN=2026-04-22T00:00:00Z). Useful for fully reproducible builds, but no concrete demand at v1; relative durations cover the threat-model goal. Adding ISO 8601 later is non-breaking. - Per-package allowlist under
:hex. Same rationale as per-depcooldown:. Local cooldown can be bypassed withHEX_COOLDOWN=0; policy cooldown changes require a policy update. - Per-semver-level cooldowns (e.g. 7d for patch, 30d for major, à la Dependabot). Worth revisiting if real demand surfaces; not in v1.
- Server-side cooldown enforcement. Different project — this is purely client-side.
- Advisory-driven cooldown bypass. When a currently-locked version has a known security advisory, cooldown should bypass for that package on the same principle as retirement. The mechanism is identical; the gap is that the registry protocol does not yet expose per-release advisory data. Tracked as a follow-up gated on
specificationswork. v1 ships retirement-only bypass.