Skip to Content
ConceptsOffset policy

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 filters

InsufficientCandidates only shows up when:

  1. every axis underflows past (0, 0, 0) — the user asked for a version below the earliest possible release, or
  2. the registry has literally zero version ≤ the effective bound, or
  3. 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 latest 5.0.0Y - 1 < 0, so the minor bound cross-boundaries to (4, ∞, ∞). The resolver picks 4.max.max instead of surfacing InsufficientCandidates.
  • { patch: -1 } on latest 19.2.5 when 19.2.4 doesn’t exist — the bound stays at (19, 2, 4) and the resolver picks the next-highest release below it (19.2.0 on the React fixture).
  • { major: -1 } on a package that jumped from major 2 to major 4 — bound is (3, ∞, ∞); no 3.x exists, so the resolver falls back to the highest 2.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).

OffsetEffective boundRecommended
{ 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: 0

Resolution 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):

  1. A .packguard.yml that declares root: true (explicit barrier for non-git monorepos).
  2. The first directory that contains a .git/ folder (implicit repo boundary — the .packguard.yml at that level is included).
  3. The home directory (safety — the walk never escapes into $HOME’s ancestors).

extends: — explicit inclusion

extends: "../presets/security.yml" defaults: min_age_days: 14

The 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-policy

prints 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-policy

Translation table for the common cases:

v0.1.xv0.2.0+
offset: 0offset: { major: 0 }
offset: -1offset: { major: -1 }
offset: -2offset: { 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.

Last updated on