Config lock — vx lock / vx-lock.json (2026-06)
Status: shipped.
Problem
Section titled “Problem”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:
- 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).
- Eval-time nondeterminism. A config that reads
process.env.Xresolves 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”.
Relation to the rejected eval cache
Section titled “Relation to the rejected eval cache”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 lockis a deliberate act, likepnpm installwriting 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 --checkre-derives the truth from scratch and diffs it.
Format
Section titled “Format”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": { ... } } } }}configis the resolved (post-evaluation)ProjectConfig, JSON-normalized (aJSON.stringifyround-trip dropsundefinedfields, 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.tsis not locked: it holdsconcurrency/cacheDironly, 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).
Semantics
Section titled “Semantics”vx lock (write)
Section titled “vx lock (write)”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):
| condition | outcome |
|---|---|
| file hash matches lock entry | frozen config used, eval-free |
| config file changed since lock | vx-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.
vx lock --check — AUDIT
Section titled “vx lock --check — AUDIT”--check is strictly stronger than run-time verification. Per
config-bearing project:
-
The run-time hash check (file bytes vs
configHash); a mismatch reportsconfig file changed since lock (<project>). -
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.
The asymmetry, explicitly
Section titled “The asymmetry, explicitly”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.
--checkre-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).
Known limits
Section titled “Known limits”vx watchwith 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):
- The mandated drift scenario: config reads
process.env.X; lock underX=a;--checkunderX=a→ 0;--checkunderX=b→ 1 naming the project;vx rununderX=b→ 0 with the frozenflavor-aoutput. - Stale file: edit config after lock →
--checkexits 1 (file-changed),vx runhard-fails with the stale-lock error, re-lock heals both. --checkwith no lock present → exit 1, points atvx lock.
FAQ (owner questions, 2026-06)
Section titled “FAQ (owner questions, 2026-06)”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.