Dependency Update Policy

Nightly maintenance moves a project's dependencies only as far as the project's update_policy allows. Foundry decides what to move in code, before the maintain agent starts. The agent gets a list of the exact updates to apply, and it must not go beyond that list.

The policy

Each registered project has an update_policy. The policy is a ceiling. Each level includes the levels below it.

PolicyWhat maintenance may do
patchMove the lockfile inside the existing manifest constraints. No manifest constraint changes.
minorAlso widen a constraint to the newest release that is not a breaking (major) release.
majorAlso take major releases. A major upgrade never happens inside the nightly maintain session. Each one becomes its own foundry task.

When a project has no policy, maintenance behaves as minor. The maintenance summary lists the project under "No update policy set", so that you choose one.

Set the policy with the registry commands:

foundry registry edit my-tool --update-policy major
foundry registry add --name my-tool ... --update-policy patch

foundry registry show my-tool shows the policy on the Updates: line.

What counts as a major

A move is a major when it changes the first version component. Semver treats 0.x versions differently, and so does Foundry:

  • In a 0.x version, a change to the second component is a major (0.7.4 to 0.8.0).
  • In a 0.0.x version, every change is a major (0.0.3 to 0.0.4).

Calendar versions follow the same rule, so 2025.2 to 2026.1 is a major.

Classification

The Classify Dependency Updates step runs after the gates resolve and before Execute Maintain. For each direct dependency it finds the locked version, the manifest constraint, and the published releases. Then it records three candidate versions:

FieldMeaning
in_rangeThe newest non-major release that the current constraint admits. A lockfile-only move.
non_majorThe newest release that is not a major from the current version.
majorThe newest release, when it is a major.

Foundry classifies every ecosystem in the repository, not only the registered stack. Manifests at the root are always classified. In subdirectories, a manifest is classified where its lockfile sits beside it (for example apps/cli/Cargo.lock); build output, installed dependencies, fixtures and vendored trees are skipped.

Foundry reads each ecosystem's own files and asks each ecosystem's own registry. It does not need the project's toolchain to be installed.

EcosystemDirect dependenciesLocked versionReleases
CargoCargo.toml and its workspace membersCargo.lockcrates.io sparse index
Hexmix.exs of each Mix project the audit coversmix.lockhex.pm API
npmpackage.jsonpackage-lock.json or bun.locknpm registry
PyPIpyproject.toml (project, optional, dependency groups)uv.lockPyPI JSON API
Mavengradle/libs.versions.tomlthe catalog itselfMaven Central, Google Maven, Gradle plugin portal
SwiftPMPackage.swiftPackage.resolvedthe package repository's git tags

Each ecosystem's constraint operators are read correctly: Cargo caret and tilde, Elixir ~>, npm ^, ~, x-ranges and hyphen ranges, PEP 440 specifiers, Gradle catalog versions and Maven ranges, and SwiftPM from:, upToNextMinor, exact: and ranges. Pre-releases are never candidates. A Maven qualifier is a flavor, not an upgrade: 33.1.0-jre follows 33.0.0-jre, and 0.8.0-0.6.x-compat is not an upgrade of 0.8.0.

Vendored code

A vendored project changes only through its own repository, so its dependencies are not classified and never appear in the brief. A scope is vendored when its path has a vendor/ or third_party/ directory, or when .gitattributes marks the path with the GitHub Linguist attribute:

libs/forked/** linguist-vendored

foundry deps and the summary list these scopes as "vendored, updated upstream". The security audit still scans them, because an advisory in vendored code still needs a person to see it.

The checkout

Foundry classifies the files in the registered checkout. The nightly run syncs the checkout with its remote first. foundry deps fetches and compares: when the checkout is behind origin/<branch>, the output starts with a warning, because the lockfiles it read are not the remote's. The output also names the commit it read.

A Gradle [versions] key that several libraries share (for example ktor) moves as one, so it is classified once, under the key's name.

Not classified

Anything Foundry cannot classify is reported as "not classified", with the reason. It is never reported as up to date. Examples:

  • a stack with no classifier (C++),
  • a missing or unsupported lockfile (bun.lockb, yarn.lock, poetry.lock),
  • a Kotlin project without a version catalog,
  • a dependency that is declared but not in the lockfile,
  • a registry that did not answer.

The maintain brief

From the classification and the policy, Foundry builds the brief. For each outdated dependency it decides one of these:

  • Apply the move. patch projects get the in_range version. minor and major projects get the non_major version. The brief says whether the move is lockfile-only or needs a constraint edit.
  • Held back by policy: a patch project's move that needs a constraint edit.
  • Held by a hold: see Holds.
  • Major: never applied in the maintain session. See The majors lane.

The maintain prompt lists the updates to apply, then the held and major updates for information. It tells the agent to use targeted commands (for example cargo update -p <pkg> --precise <version> or uv lock --upgrade-package <pkg>==<version>), never a blanket upgrade, and not to change any other dependency. A retry of a failed maintain run is told not to move any dependency beyond what the first attempt applied.

To see the brief for a project without running maintenance:

foundry deps my-tool
foundry deps my-tool --policy major    # preview another policy; the registry does not change

foundry deps emits dependency_review_requested. It prints the outdated dependencies, the brief, and what the majors lane would do. It changes nothing.

Security fixes override the ceiling

The brief uses the advisories from the most recent nightly supply-chain scan (at most eight days old). When the policy's target does not reach a fixed release:

  • A fix that is a patch or a minor move is applied anyway. The brief marks it "beyond the policy ceiling".
  • A fix that is a major move goes to the majors lane, also for minor and patch projects.
  • A vulnerable package that is not a direct dependency becomes a lockfile move to at least the fixed version, or a major task when that move is a major.

The supply-chain auto-fix engine follows the same rule. See Supply-chain scan.

Suppressions are not fixes

Maintenance has no reviewer, so the maintain prompt, its retry prompt, the vulnerability remediation prompt and the iterate and task prompt carry three rules: never suppress, ignore or allowlist an advisory that has a fixed release (upgrade instead, past the policy ceiling if need be); never edit .supply-chain-allow.json, ignore_advisories, Dependency-Check suppressions, pip-audit --ignore-vuln, cargo deny/audit ignore lists or equivalent files; and never claim that no fix exists without citing the registry or OSV entry.

Foundry also checks the result, for every agent whose commits it pushes: maintain, iterate, task, vulnerability remediation and pipeline remediation. Before the first agent session it records where the run started (HEAD and origin/<branch>), and after each attempt it diffs the checkout against the commit those two shared. The diff covers the agent's commits, pushed or not, anything left uncommitted, new files never added to Git, and commits an earlier failed run left unpushed; a retry is checked against the same start as the first attempt. A run that added an allowlist entry, an ignore_advisories entry, a Dependency-Check suppression, an ignore flag or an ignore-list advisory, or an npm override that pins a package to a version the latest supply-chain scan marks vulnerable, fails with "needs review: …". It is never green, and its commits are not pushed. Maintain and iterate retry it (the retry is told to remove the suppression); remediation and tasks stop for review.

The check reads added lines only in files where a suppression takes effect. These are the auditors' configuration files (.supply-chain-allow.json, mix.exs, a mix_audit skips file, a Dependency-Check suppressions XML, deny.toml, audit.toml). They are also the files that run commands, where an auditor invocation can carry --ignore-vuln or cargo audit --ignore, for example .hone-gates.json, the YAML files in the root .github/ and .circleci/, shell scripts, extensionless files in a bin/ or scripts/ directory, Makefiles, justfiles, package.json, pyproject.toml, setup.cfg, tox.ini, noxfile.py, .pre-commit-config.yaml, .gitlab-ci.yml and Dockerfile. A log, an evidence archive, a brief, a README or a CHANGELOG can quote a flag without applying it, so the check does not read them. On 2026-10-11 a campaign cycle stopped because an archived controller log quoted the guard's own earlier rejection.

The check does not compare against origin/<branch> because the agent can move it: on 2026-09-29 a maintain agent pushed its own commit, the diff against the remote came back empty, and a suppression in that commit would have passed unchecked. See Agents never push.

Holds

A hold keeps a package at or below a version. Commit a .dependency-holds.json file in the repository root:

{
  "version": 1,
  "holds": [
    {
      "package": "phoenix_live_view",
      "max": "1.1",
      "reason": "vendored Roost requires ~> 1.1",
      "expires": "2026-12-24"
    }
  ]
}
  • max is a version prefix. "1.1" admits every 1.1.x and lower. "1" admits every 1.x.
  • expires is a date. After that date the hold lapses: the held update comes back, and the summary lists the hold under "Lapsed holds — re-decide". A date that does not parse also lapses the hold.
  • ecosystem (optional) limits the hold to one ecosystem, for a package name that two ecosystems share.

An active hold caps the brief at the newest release inside the hold. A major that the hold blocks does not go to the majors lane.

A hold whose cap is below the version already locked is stale. Foundry reports it as "stale hold: locked X is above cap Y, re-decide" in the brief, foundry deps and the summary. It never tells the agent to downgrade. While the stale hold stays in the file, it still blocks further upgrades of that package. foundry deps never says "Everything classified is up to date" while a hold is holding something back or is stale.

A security fix respects a hold when a fixed release exists inside it. When no fixed release exists inside the hold, the fix overrides the hold, and the brief and the summary say so.

If the file is not valid JSON, no holds apply, and the classification carries a warning that names the file. This mirrors .supply-chain-allow.json.

The majors lane

After the nightly maintenance run, the Plan Major Upgrades step decides each major:

StatusWhen
dispatchThe project's policy is major (or the major is a security fix), and nothing below applies.
proposedThe project's policy is minor or patch. The summary prints the foundry task command to run it.
deferredMaintenance for the project did not succeed.
dedupedA task for the same project, package and target version is in flight (started in the last 24 hours), or preserved event history has no matching preserved ledger obligation and its branch or bundle still exists.
overflowOver the per-project or per-night cap. The summary prints the command.

Each dispatched major becomes one foundry task with the objective "Upgrade <pkg> from <a> to <b> in <project>: adapt call sites, keep all gates green". The task runs through the normal task formation: an isolated worktree, the project's gates, and a landing on trunk only when the task is complete and green. Otherwise its work is preserved.

The daemon starts the dispatched tasks after the maintenance summary is written, one after another, each as its own workflow. Under dry_run nothing is dispatched; the summary says "would dispatch".

When an otherwise eligible nightly major upgrade matches a preserved ledger item by exact registered project, package and target version, the majors lane resumes that obligation from its preservation ref and original objective. Its child records kind: major_upgrade, lane: maintenance, origin nightly majors lane and the exact resumes id; automation records no operator action. The parent retains its submission identity and evidence. Only an actual child landing settles the preserved parent landed; other outcomes leave it open, and owner cancellation remains terminal. A selected resume admission failure is reported and never falls back to a fresh task.

Continuations count against the existing nightly caps and run sequentially with fresh upgrades in the existing order. Update policies, holds, security eligibility, successful maintenance and in-flight suppression still apply. Reviews, dry runs and interrupted-cycle summaries dispatch nothing. Without a matching preserved ledger item, the existing fresh-task and history-suppression rules apply. Owner queue resume remains an interactive, asynchronous task.

Caps bound the cost:

VariableDefaultPurpose
FOUNDRY_MAJOR_TASKS_PER_PROJECT2Most major-upgrade tasks dispatched for one project in one night
FOUNDRY_MAJOR_TASKS_PER_NIGHT6Most major-upgrade tasks dispatched in one night, across every project

Projects are taken in name order, security majors first, so the caps fall the same way each night.

The summary

The maintenance summary has a "Dependency drift" table near the top, beside "Unpushed commits" and "Scanner failures". It has one row per project: the policy, the updates applied by class, the counts held by policy and by holds, the majors lane's decisions, and the scopes not classified. Below the table it lists:

  • Applied beyond the brief: moves the agent made that the brief did not list, and any major. Foundry finds these by comparing the classification before the agent ran with the one after maintenance completed.
  • No update policy set.
  • Lapsed holds — re-decide.

A "Dependencies" section after the status table gives each project's detail, with the foundry task command for every major that was not dispatched.