Skip to content

Instantly share code, notes, and snippets.

@ericmj
Last active May 13, 2026 17:08
Show Gist options
  • Select an option

  • Save ericmj/16488f164ca2045e12f0f79a73c45031 to your computer and use it in GitHub Desktop.

Select an option

Save ericmj/16488f164ca2045e12f0f79a73c45031 to your computer and use it in GitHub Desktop.
Hex Dependency Cooldown — Public Design

Hex Dependency Cooldown — Design

Date: 2026-04-29

Status: Design proposal (user facing design only; internal implementation deferred)

Background

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.

Goals

  • 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.

Non-goals

  • 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).

Threat model

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.

Design

Resolution model

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.get against an existing lockfile: cooldown does not apply; install proceeds from the lockfile.
  • mix deps.get for 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).

Configuration

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.

Override hierarchy

Local cooldown uses standard Hex precedence:

  1. HEX_COOLDOWN=<duration> env var (HEX_COOLDOWN=0 disables for the invocation; empty HEX_COOLDOWN= contributes nothing — falls through to the next source)
  2. cooldown in mix.exs :hex block (project policy)
  3. cooldown in ~/.hex/hex.config (user / persistent machine policy)
  4. 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.

Duration format

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 behavior matrix

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.

Resolution behavior

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:

  1. 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.

  2. 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.

Error UX

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.

Bypass for unsafe current versions

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 foo says nothing about the safety of a fresh bar release. 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.7 constraint 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.get with 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 existing mix hex.audit regime: 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.

mix hex.outdated UX

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.

Lockfile

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.

Examples

Minimum opt-in

# mix.exs
defp hex do
  [cooldown: "7d"]
end

That's it. Every direct and transitive dep now requires versions to be at least 7 days old before the solver considers them.

CI enforces a stricter floor

# CI config
HEX_COOLDOWN=14d mix deps.get

Project may declare cooldown: "7d" in mix.exs; CI tightens to 14 days for its runs without changing the project file.

Per-machine default

# ~/.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.

One-off bypass on the command line

HEX_COOLDOWN=0 mix deps.update phoenix

User 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.

Org-enforced cooldown through policy

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.

Mix.install/2 with cooldown

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.

Open questions deferred to implementation

These are real questions but they're internal-implementation, not API:

  • Where exactly the filter hooks into Hex.Registry.Server.versions/2 and 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.outdated retrieves the eligible-on date.
  • Telemetry / diagnostic logging.

Out of scope (explicit)

  • Refusing to install lockfile entries. Resolution-time only. The lockfile is trusted.
  • CLI flags. HEX_COOLDOWN env 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 with override:) 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 via Policy.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-dep cooldown:. Local cooldown can be bypassed with HEX_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 specifications work. v1 ships retirement-only bypass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment