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 under --frozen (vx run, vx watch) — TRUST
Section titled “Runs under --frozen (vx run, vx watch) — TRUST”As revised on 2026-06-13 (the two sections at the end; item 973
brought this one in line with them): a plain run always evaluates
live, and only --frozen reads the lock. Under it, prepareRun loads
each in-scope project’s config from the lock, with no staleness
check of any kind. 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, and a config edited since keeps
its locked form until vx lock.
Hard failures (UserError, exit 1):
| condition | outcome |
|---|---|
no vx-lock.json | --frozen refuses and names 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 the only verification there is. Per
config-bearing project:
-
The file hash check (file bytes vs
configHash, which only--checkreads); 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>) —a file the config imports changed, or the config reads the environment; run 'vx lock' here
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.
- A
--frozenrun verifies nothing — 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 only “is the lock internally consistent with the files?” (its hash step answers 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 watch --frozen: a config edited mid-watch starts a cycle that runs the locked config, unchanged, untilvx lock; the loop hears the re-lock and runs the new one (item 971).- 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. - Changed file: edit config after lock →
--checkexits 1 (file-changed), a livevx runuses the edit,vx run --frozenkeeps the freeze until a re-lock. --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 (a hash tripwire was kept here for direct config edits, then dropped by the revision below; 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.