Skip to content

Config lock — vx lock / vx-lock.json (2026-06)

Status: shipped.

vx configs are programs (vx.config.ts is evaluated, not parsed). That is the source of vx’s “resolved-config hashing” power — imports and computed values participate in cache keys — but it has two costs:

  1. Eval time. Evaluating ~1000 configs costs ~200 ms, the dominant fixed cost of small runs (scoped loading already trims this to the dep closure; the lock removes it entirely for locked projects).
  2. Eval-time nondeterminism. A config that reads process.env.X resolves to different objects in different environments. File hashes are blind to this: the bytes on disk never changed.

vx lock makes the resolved configs explicit, reviewable state: a single JSON file freezing “what this workspace’s tasks ARE”.

A resolved-config eval cache (transparently cache pure-literal configs on content hash) was designed and REJECTED in June 2026: its static purity gate was correctness-critical heuristic machinery. The lock is the sound dependency story that rejection asked for:

  • Explicit, user-invoked. Nothing is cached behind the user’s back; vx lock is a deliberate act, like pnpm install writing a lockfile. No purity heuristics — the user asserts “this resolution is the truth” and owns when to refresh it.
  • Hash-pinned + hard-fail. A changed config file is a loud error, never a silent stale replay.
  • Auditable. vx lock --check re-derives the truth from scratch and diffs it.

vx-lock.json at the workspace root:

{
"version": 1,
"projects": {
"<package-name>": {
"configPath": "packages/app/vx.config.ts",
"configHash": "<xxh3 hex of file bytes>",
"config": { "tasks": { ... } }
}
}
}
  • config is the resolved (post-evaluation) ProjectConfig, JSON-normalized (a JSON.stringify round-trip drops undefined fields, so the stored form equals what a later read produces).
  • Entries are sorted by project name (stable diffs).
  • Only project configs are covered. vx.workspace.ts is not locked: it holds concurrency / cacheDir only, neither of which participates in cache keys or task semantics.

Implementation: src/workspace/lockfile.ts (format, read/write, run-time verification), src/cli/lock.ts (subcommand), one hook in src/orchestrator/prepare.ts (run-time load path).

Discovers the workspace, freshly evaluates every config-bearing project’s config in the current environment (a per-invocation cache bust bypasses Bun’s module cache, which would otherwise replay an evaluation made under earlier env values in the same process), and writes the lock. Exit 0.

Runs (vx run, vx watch, --dry / --graph) — TRUST

Section titled “Runs (vx run, vx watch, --dry / --graph) — TRUST”

When vx-lock.json exists, prepareRun loads each in-scope project’s config from the lock after a content-hash check of the config file. No evaluation happens — frozen-env semantics: a config that read process.env.X at lock time keeps the locked value no matter what X is at run time.

Hash-only verification, hard failures (UserError, exit 1):

conditionoutcome
file hash matches lock entryfrozen config used, eval-free
config file changed since lockvx-lock.json is stale: <path> changed since \vx lock` ()`
project has a config but no lock entry (or moved)vx-lock.json has no entry for "<project>"

There is deliberately no silent fallback to evaluation: the lock’s contract is “what runs is what was locked”. Falling back would reintroduce exactly the env-dependence the user locked against. The frozen config is still shape-validated on load — vx-lock.json is a hand-editable file, i.e. a system boundary.

Cache-key interaction: keys hash the resolved config object (principle “resolved-config hashing”), so frozen configs simply pin the hashed object. Key derivation is untouched — no CACHE_VERSION bump.

--check is strictly stronger than run-time verification. Per config-bearing project:

  1. The run-time hash check (file bytes vs configHash); a mismatch reports config file changed since lock (<project>).

  2. Full re-evaluation in the current environment (fresh, module cache bypassed), JSON-normalized, then Bun.deepEquals(fresh, stored, /* strict */ true). A mismatch reports:

    lock differs from fresh evaluation in this environment (<project>) —
    env-dependent config? run 'vx lock' here or remove env reads from config

Plus set-level drift: projects missing from the lock, and locked projects that no longer exist in the workspace. Any failure → every mismatched project is listed on stderr, exit 1. Clean → exit 0.

Runs trust the lock; --check audits it.

  • Run-time verification is hash-only — fast and eval-free. That is the entire point of the lock on the hot path: zero config evaluation per run, and frozen-env semantics — the run’s behavior cannot drift with the environment because the environment is never consulted. Re-evaluating on every run to “verify” would (a) pay the eval cost the lock exists to remove, and (b) be self-defeating: in an environment where evaluation resolves differently, the frozen value is the intended one, not an error.
  • --check re-evaluates and deep-compares. It answers a different question — not “is the lock internally consistent with the files?” (hashes answer that) but “would locking here, now produce the same truth?” Only evaluation can answer it, because eval-time env-var drift leaves file bytes — and therefore every hash — unchanged.

Intended workflow: run vx lock --check where environments are supposed to agree (CI gate, post-checkout hook). A failure means the lock was produced under assumptions this environment violates — either re-lock here on purpose, or remove the env read from the config (move it to exec.env / cache.inputs.env, which are run-time surfaces the cache key tracks properly).

  • vx watch with a lock: editing a config mid-watch fails the next cycle with the stale-lock error until the user re-locks (or deletes the lock). Consistent with “no silent fallback”; the error message says exactly what to do.
  • The lock does not cover vx.workspace.ts (see Format).
  • --check’s re-evaluation runs config code; like any vx invocation it assumes configs are trusted code in the repo.

tests/lock.test.ts (e2e, real CLI subprocesses — env drift is cross-invocation by nature):

  1. The mandated drift scenario: config reads process.env.X; lock under X=a; --check under X=a → 0; --check under X=b → 1 naming the project; vx run under X=b → 0 with the frozen flavor-a output.
  2. Stale file: edit config after lock → --check exits 1 (file-changed), vx run hard-fails with the stale-lock error, re-lock heals both.
  3. --check with no lock present → exit 1, points at vx lock.

Why isn’t package.json in the lock? Configs are frozen because they’re programs — evaluation can differ per machine (env, imports). Manifests are committed data: local and CI parse the same bytes from git, so they’re reproducible without freezing, and edits flow live (dep edges reshape the graph immediately; the whole-file byte hash already shifts every cache key via the v12 implicit- dependency rule). Freezing them would mean relocking on every version bump for zero reproducibility gain.

Do we need to parse exports / files like Nx? No. Nx parses them to infer dependencies from source imports and compute production file sets; vx’s graph is declared, not inferred. Those fields still participate in cache identity byte-wise through the manifest hash — changing them invalidates correctly without vx understanding them.

Semantics revision (2026-06-13, owner decision)

Section titled “Semantics revision (2026-06-13, owner decision)”

Byte-hashing a config file cannot see its IMPORT CLOSURE: editing a shared preset changes the resolved config while the config file’s bytes — and therefore the staleness tripwire — stay unchanged. Consuming the lock on every local run therefore gave false confidence. Revised contract:

  • vx run — ALWAYS live evaluation. Local truth has no asterisks; costs the eval time (~120 ms / 1000 pkgs).
  • vx run --frozen — CI mode: configs from the lock (hash tripwire still catches direct config edits; import-closure/env drift is —check’s job). Errors if no lock exists.
  • vx lock / vx lock --check — unchanged: the only full-graph operations. pnpm-style auto-relock on plain runs was considered and rejected: scoped runs evaluate only a dep closure and cannot correctly rewrite a whole-workspace lock.

Revision (owner, 2026-06-13): --frozen performs no staleness checks at all — in the canonical pipeline vx lock --check runs first and re-evaluates everything, so a per-file byte-hash re-check inside the run is redundant work wearing a safety costume. configHash remains in the lock purely for --check’s fast “file changed” reporting.