Offset policy
Most governance tools have two default modes: pin everything (Dependabot-style “every update is a PR”) or update everything (Renovate-style “stay on HEAD”). Both are defensible. Neither matches how most teams actually ship.
PackGuard’s model is different — offset. You declare how far behind latest each package is allowed to drift, independently on each of the three semver axes (major, minor, patch), and the scanner classifies every installed version against that rule.
The three-axis offset
Offset is an object with three non-positive integer keys. Missing keys default to 0. The canonical shape:
offset:
major: 0 # "stay on the latest major"
minor: -1 # "… one minor behind"
patch: 0 # "… always pick the latest patch"That example — { major: 0, minor: -1, patch: 0 } — is the default packguard init writes. It encodes the security posture most teams converge on:
- Patches are overwhelmingly bug and security fixes → always take them.
- Minors sometimes carry regressions → let them bake for one cycle.
- Majors ship breaking changes → staying on the latest is the fastest way to get newer fixes, as long as you actively track upgrades.
How resolution works (v0.3.0)
The resolver treats each axis as an inclusive lexicographic upper bound on the (major, minor, patch) triple, then picks the highest version that satisfies all bounds.
Given latest = X.Y.Z (the tip of the post-stability + min_age_days pool):
bound_major = if a > 0: (X - a, ∞, ∞) ; infeasible when a > X
bound_minor = if b > 0:
if b ≤ Y: (X, Y - b, ∞)
else: (X - 1, ∞, ∞) ; cross-boundary
; infeasible when X = 0
bound_patch = if c > 0:
if c ≤ Z: (X, Y, Z - c)
elif Y ≥ 1: (X, Y - 1, ∞) ; spill to prev minor
elif X ≥ 1: (X - 1, ∞, ∞) ; spill to prev major
else: infeasible
effective = min_lex over all active axes (tightest bound wins)
recommended = max version ≤ effective, after stability + min_age + malware + CVE filtersInsufficientCandidates only shows up when:
- every axis underflows past
(0, 0, 0)— the user asked for a version below the earliest possible release, or - the registry has literally zero version ≤ the effective bound, or
- every candidate ≤ the bound is blocked by CVE / malware remediation.
Cross-boundary fallback
Version histories in real registries are often clairsemées — packages skip majors, minors, or patches for months. The lex bound naturally falls back in those cases:
{ minor: -1 }on latest5.0.0—Y - 1 < 0, so the minor bound cross-boundaries to(4, ∞, ∞). The resolver picks4.max.maxinstead of surfacingInsufficientCandidates.{ patch: -1 }on latest19.2.5when19.2.4doesn’t exist — the bound stays at(19, 2, 4)and the resolver picks the next-highest release below it (19.2.0on the React fixture).{ major: -1 }on a package that jumped from major 2 to major 4 — bound is(3, ∞, ∞); no3.xexists, so the resolver falls back to the highest2.x.{ minor: -99 }— a single cross-boundary step back to(X - 1, ∞, ∞). It’s not proportional to the overshoot; “I want any older version” is expressed by a large magnitude on the closest axis.
Worked examples (React history: 12 versions across 16 → 19)
Fixture: 16.14.0, 17.0.0, 17.0.2, 18.0.0, 18.2.0, 18.3.1, 19.0.0, 19.1.0, 19.2.0, 19.2.5 (prereleases elided).
| Offset | Effective bound | Recommended |
|---|---|---|
{ major: 0, minor: 0, patch: 0 } | (∞, ∞, ∞) | 19.2.5 |
{ major: 0, minor: -1, patch: 0 } | (19, 1, ∞) | 19.1.0 |
{ major: -1 } | (18, ∞, ∞) | 18.3.1 |
{ major: -2 } | (17, ∞, ∞) | 17.0.2 |
{ major: 0, minor: 0, patch: -1 } | (19, 2, 4) | 19.2.0 |
{ major: -1, minor: -1, patch: -1 } | (18, ∞, ∞) | 18.3.1 |
{ minor: -99 } | (18, ∞, ∞) (cross) | 18.3.1 |
The cascade is deterministic. If two teams write the same policy they get the same answer on the same registry history, always.
Migrating from v0.2.0
No YAML changes needed. The resolver became strictly more permissive: every InsufficientCandidates verdict v0.2.0 produced on a clairsemé history now returns an actual version recommendation. If you rely on InsufficientCandidates as a fail-closed signal, double-check the specific policies — it now only fires when every axis underflows past major 0, the registry truly has no version ≤ the bound, or CVE remediation dropped every candidate.
Full .packguard.yml
defaults:
offset:
major: 0
minor: -1
patch: 0
allow_patch: true
allow_security_patch: true
stability: stable # exclude prereleases from consideration
min_age_days: 7 # ignore releases younger than a week
block:
cve_severity: [high, critical] # installed match → cve-violation
malware: true # MAL-* / GHSA malware → malware
deprecated: true
yanked: true
typosquat: warn # warn | strict | off
overrides:
- match: "react"
offset: { major: 0, minor: 0, patch: 0 } # always latest
- match: "lodash"
pin: "4.17.21" # hard pin, no drift allowed
- match: "@babel/*" # glob — apply to every Babel scoped package
offset: { major: -1 }
groups:
- name: security-critical
match: ["jsonwebtoken", "bcrypt*", "@auth/*"]
offset: { major: 0, minor: 0, patch: 0 } # always latest
min_age_days: 0Resolution cascade: defaults → every matching group → every matching override. Later layers strictly override per-field. A package can match multiple groups — later matches win, which is why overrides (applied last) give you the last word.
Policy cascade across a monorepo
In a monorepo, packguard walks up from the scan path collecting every .packguard.yml it finds and deep-merges them into a single effective policy:
Level 0 : built-in conservative defaults
Level 1 : ~/.packguard.yml (optional, user-wide)
Level 2 : <repo root>/.packguard.yml (monorepo root)
Level 3 : <intermediate dirs>/.packguard.yml (groups, e.g. front/)
Level 4 : <scan path>/.packguard.yml (project-level)Later layers override earlier ones on a per-key basis. Nested objects deep-merge (a child’s offset: { major: -1 } overrides just major, preserving parent’s minor and patch). Arrays replace, they don’t concatenate — a child’s block.cve_severity: [critical] fully replaces a parent’s [high, critical]. Use extends: when you want to inherit a parent’s overrides / groups list.
The walk stops at (in priority order):
- A
.packguard.ymlthat declaresroot: true(explicit barrier for non-git monorepos). - The first directory that contains a
.git/folder (implicit repo boundary — the.packguard.ymlat that level is included). - The home directory (safety — the walk never escapes into
$HOME’s ancestors).
extends: — explicit inclusion
extends: "../presets/security.yml"
defaults:
min_age_days: 14The extended file is loaded and merged just before the declaring file. Paths are resolved against the declaring file’s directory. Cycles are detected and fail the load with an explicit error.
Inspecting the resolved policy
packguard report <path> --show-policyprints the effective policy with per-key provenance (which file and line each value came from), so you can answer “why does this key have this value?” without reading the three .packguard.yml files yourself:
# Effective policy for /repos/nalo/monorepo/front/vesta
# Sources (merge order — later wins):
# [0] built-in default
# [1] /repos/nalo/monorepo/.packguard.yml
# [2] /repos/nalo/monorepo/front/.packguard.yml
# [3] /repos/nalo/monorepo/front/vesta/.packguard.yml
defaults.offset.major = -1 (from /repos/nalo/monorepo/.packguard.yml:L4)
defaults.offset.minor = -1 (from /repos/nalo/monorepo/front/.packguard.yml:L3)
defaults.offset.patch = 0 (from built-in default)
defaults.block.typosquat = strict (from /repos/nalo/monorepo/front/vesta/.packguard.yml:L3)
…Migrating from v0.2.0 (no cascade)
Existing repos keep working: if a project has exactly one .packguard.yml at its scan path, the cascade walks up to it, finds no parent files, and the result is identical to v0.2.0. No YAML changes required. The new cascade only kicks in when you actually want it.
Migrating from v0.1.x scalar offsets
The pre-0.2.0 scalar form offset: -1 is no longer accepted. packguard scan / report / audit will error out at parse time with a rewrite hint:
Error: policy `offset` must be an object with major/minor/patch keys,
got scalar `-1`.
Rewrite as `offset: { major: -1, minor: 0, patch: 0 }` (or the
long form). See https://packguard-docs.vercel.app/concepts/offset-policyTranslation table for the common cases:
| v0.1.x | v0.2.0+ |
|---|---|
offset: 0 | offset: { major: 0 } |
offset: -1 | offset: { major: -1 } |
offset: -2 | offset: { major: -2 } |
That’s the minimum-change migration. Consider switching to { major: 0, minor: -1, patch: 0 } — the new packguard init default — if you want the security-fix-flow described above.
Why offset instead of pin-or-latest
Pin-everything (Dependabot-style) — every minor bump is a PR. Teams stop reading them. You end up running versions that are months out of date because no one wants to review 40 Dependabot PRs on a Friday.
Update-everything (Renovate-style) — aggressive auto-merge is great until a breaking minor sneaks through. And you can’t ask “are we drifted?” of the codebase — every repo is always on HEAD, by definition.
Offset (PackGuard) — you declare the bar (“one minor behind, always take patches”), and the CI gate tells you when a workspace has drifted below it. The drift signal is the thing you actually care about, expressed as a policy, not as a PR stream.
What offset measures
Offset compares what’s installed against what’s available on the registry right now. For a package on ^4.17.21 with latest = 4.17.23, the installed delta is 0 minor · 2 patch — that’s compliant under { major: 0, minor: -1, patch: 0 }.
For a package on ^2.0.0 with latest = 5.4.0, the installed delta is 3 major · … · … — that’s a violation under { major: -1 } (the installed package is too old for the policy).
The classifier uses the full version history returned by the registry, so the answer doesn’t depend on what your lockfile was last quarter; it depends on what ships today.
What the policy does NOT do
PackGuard is a governance + auditing tool, not a resolver. It won’t bump your lockfile for you — it’ll fail CI when a lockfile drifts past policy, and leave the upgrade work to you (or to Renovate / Dependabot / a human).
That separation is intentional: the tools that pick which version to install are different from the tools that decide whether a version is allowed. Most teams end up wanting both, but the concerns don’t belong in the same binary.
Related
- Per-project scoping — how
.packguard.ymlworks across a monorepo. packguard init— generates the policy skeleton.packguard report— evaluates installed versions against the policy.