Releases
What changed in each version. Binaries for every release are on GitHub; brew upgrade vci or the install script gets the latest. The GitHub Action's @v2 follows the newest 2.x release; @v1 stays on 1.9.2.
| Version | Date | In one line |
|---|---|---|
| 2.10.3 | 12 Nov 2026 | vci push keeps the store small by itself: before it publishes it removes each unit's attestations by one signer beyond the 4 newest, from the keys alone, so no vci prune needs scheduling; and a plan reads at most a unit's 16 newest candidates after its first pass. On vex, four days without the hourly prune took the store from 30,000 attestations to 289,159 and a plan from 9 s to 400 s |
| 2.10.2 | 8 Nov 2026 | The lookups that let round 2 fetch a run's objects beside its candidates stop after 300 ms in all, so that a git log walking a long metadata history no longer adds seconds to a plan: on vex's first 26 Rails plans with 2.10.1, 4 spent 2.9 to 3.8 s on it, more than the round trip it saves |
| 2.10.1 | 8 Nov 2026 | A plan is faster after its first verdicts and decides and prints the same: on a git-meta clone it makes one round trip to the remote after its first verdicts instead of three (two when the state objects to fetch are more than [store] plan_states or a run's publishing commit added more than 64 objects), round 2 fetching, beside the candidates, the objects its verdicts and the failure text after them read, at the cost of up to plan_states state objects downloaded and not read when those units then skip; round 2 no longer checks again the candidates its first pass checked, the candidates of one run observe their shared inputs once instead of each going through the checkout cache's locks, and two runs' records of a unit with the same descriptor share one input root: on 2,000 Vitest files that all ran, with 12,000 candidates set aside, round 2 took 0.38 to 0.43 s instead of 3.25 to 7.69 s and the plan 1.4 to 1.6 s instead of 5.4 to 9.5 s; VCI_FETCH_BESIDE=0 fetches and VCI_PLAN_RECHECK=1 checks as 2.10.0 did; the libSQL store sends a server one write transaction at a time per command: threads of a command that write together wait in vci, holding no connection, instead of in the server's queue for its write lock, which sqld 0.24.32 can leave stalled for good (CI's shared sqld on 7 November 2026), and that wait counts as no failed write |
| 2.10.0 | 7 Nov 2026 | A plain vci prune keeps the store small: attestations older than [store] max_age_days (14) go even on a live branch, except a unit's newest and what the checked-out commit's plan used; a unit's format-1 attestations go once it has a newer format-3 one, except within the age on a live branch; on git-meta deletion records are kept 6 hours instead of 24 once their deletion is published, a history longer than [store] history_max_commits (2000) is published again as one commit by a prune in CI, once however many jobs prune at the same time, a clone that fetched before such a rewrite (a restored CI cache) merges it on the tip it last fetched and publishes nothing deleted since, and the local file is compacted; vci prune --dry-run counts each rule; vci store upgrade --finish works on git-meta; git-meta: the local metadata commit only ever moves to a serialization vci checked, even when the command is killed or a write fails halfway, and a push or fetch puts back the shared values a local commit lost without this store deleting them instead of refusing every push until the store is reset by hand; A unit that runs because a format-3 record's inputs changed names the input again, best failed inputs: entry:app/models/organization.rb (+3 more), from its run's state object, which the plan loads after its verdicts for the units that run only (at most [store] plan_states = 8 objects, of which it reads at most plan_states_mib = 16 MiB), on git-meta too through a run-state link vci run now writes; otherwise it says why (attested hash not loaded: state expired); the text plan lists the inputs that made the most units run before its last line; the text format and vci explain print names with their control characters escaped, so a file name cannot add a SKIP line, while the github format's file lists name files as they are on disk; git-meta: a fetch or push killed halfway no longer leaves a later merge publishing what was deleted after this clone's last push, a ref naming a missing commit no longer sticks the clone, and the first fetch after a rewrite no longer spends minutes dropping deletion records |
| 2.9.1 | 7 Nov 2026 | vci run no longer deletes an attestation made on a commit the clone does not have: a pull request's checkout never has another pull request's merge commit, and from 2.1.3 each pull request job took those for commits rebased away and deleted the other pull requests' attestations of every unit it ran (vex, 6 Nov: 133,397 of the 138,019 records the shared pull request key published in a day); in an RSpec process SimpleCov 1.x's git lookups for the branch and commit answer nil instead of tainting the file |
| 2.9.0 | 31 Oct 2026 | Store format 3: an attestation is a text frame of about 1.8 KB instead of a 36 KB envelope and manifest, its toolchain, global inputs and the run's paths kept once per run in four small objects, so a run's records and objects together take 14.5x less at rest on vex's sample and an 800-unit partition is written to libSQL in two transactions of under 1 MiB; the plan checks each run's header once instead of once per candidate, checks that a record sits under its signer's slot, and verifies the set of paths it observes against the signed root; vci store upgrade --begin and --finish, vci prune --objects, [store] record_format, explain_hashes and explain_hashes_days; every vci reads both formats and a 2.8 vci runs the units it cannot read; 2.8.2's libSQL changes ship inside it |
| 2.8.2 | never released alone: inside 2.9.0 | The libSQL store writes vci run's attestations in transactions of about a megabyte, one after the other, and sends one that runs out of time again, applied once, within a bounded time, so a busy server costs only the units whose transaction still fails, not the run's remaining attestations; a plan reads its values in requests sized from the answers, each with the manifests it needs, and only the candidates it checks first; a request out of time is sent once more instead of giving up for the rest of the run; hint writes wait at most 5 s and never stop the command; [store] compress = "zstd" (or VCI_STORE_COMPRESS=zstd) stores values about 3x smaller; superseded input manifests go with their attestations |
| 2.8.1 | 27 Oct 2026 | The libSQL store sends a request again on a new connection when the server closed the one it went out on, instead of giving up for the rest of the run; a failing [store] token_command now says to run it with its standard output discarded, where a command that works prints the credential, and names it quoted for a shell, so that the redirect applies to the whole command; new pages for choosing and setting up a store (Turso step by step) and for every measurement, Turso's hosted service included, and store rules in the agent skill |
| 2.8.0 | 26 Oct 2026 | Attestations can live in a local SQLite file, on a shared PostgreSQL server or in a libSQL database (Turso) instead of git-meta (VCI_STORE, VCI_STORE_URL, VCI_STORE_TOKEN, [store], and a token_command so that developers export nothing), with vci store info and a publication gate for shared stores; git-meta stays the default, unchanged; a plan re-reads the older candidates it set aside even when it had nothing to fetch; a run signs in process and writes each project's attestations in one transaction (1,200 units checked, signed and stored in 0.35 s instead of 1.57 s) |
| 2.7.36 | 26 Oct 2026 | Rails: what Rails reads at boot (routes, initializers, environments, the boot files, migrations, locales, the autoload records) is no longer an input by default (rails_inputs = "loose"), so a change there no longer runs every test of a pull request again; rails_inputs = "strict" records everything, ignore_inputs adds globs |
| 2.7.35 | 26 Oct 2026 | The merge of a fetched tip merges on git 2.43 too (Ubuntu 24.04's, vex's runners): the tip's tree is put back to the base at every path both sides changed, through git's index, and given a commit, so git merges one-sided changes only and reads no blob |
| 2.7.34 | 26 Oct 2026 | A push whose other job deleted the same superseded attestations merges through git merge-tree again (ours written into the tip's tree first, no rename search): no failed merge-tree and no git-meta merge, 20 to 120 s on vex's busy evenings; a Rails batch opens one slot per shard, not per file; a per-process profile and measurement switches for the Rails collector |
| 2.7.33 | 25 Oct 2026 | One git-meta store per repository, shared by all its worktrees: what one worktree stores another plans with at once, and one fetch serves them all; new vci update: installs the latest release the way vci was installed (Homebrew, the install script, or a release binary replaced in place after its SHA-256 is checked); --check for scripts |
| 2.7.32 | 25 Oct 2026 | A vci waiting for another's fetch or push in the same repository says so, names it, and gives up after VCI_LOCK_TIMEOUT_SECS (600); the kept tree listing is one blob, brought up to date only from a nearby commit; a push marks the commit it writes as complete |
| 2.7.31 | 25 Oct 2026 | Dropping old deletion records no longer makes the store forget that it covers its metadata commit (a 1.6 s check on every fetch after a drop) |
| 2.7.30 | 25 Oct 2026 | Verdicts check a shared base manifest once for the units sharing it; a fetch lists the tip from the kept listing plus the difference; no second round for a unit whose remembered attestation failed on its inputs; a run's warnings no longer ask git about tracked paths |
| 2.7.29 | 24 Oct 2026 | A plan remembers which attestation passed for each unit on its tree, and a plan whose base commit has that tree reads those first: a pull request's plan no longer downloads other branches' attestations before master's |
| 2.7.28 | 24 Oct 2026 | A run's publish writes its metadata commit with one git fast-import (10 s of loose objects on vex's runners), and a merge resolves deletion records both jobs wrote differently itself (6 s) |
| 2.7.27 | 24 Oct 2026 | A pull request's plan reads the attestations made on its base commit's tree first (not another branch's newest), the tree check walks only the new objects (14 s once), and the store does not sync to disk on CI (a 6 s commit) |
| 2.7.26 | 24 Oct 2026 | A Rails plan starts the application's boot for the native-library check as a child process and no longer waits for it before reading its candidates and starting the verdicts |
| 2.7.25 | 24 Oct 2026 | A plan reads the store file through in the background at its start, so the scans of a store a CI job just restored from its cache find it in memory |
| 2.7.24 | 24 Oct 2026 | A fetch lists the store's manifests through the key index (1.2 s), skips listing a tip this clone pushed (0.85 s), and a plan reads two of each unit's newest candidates instead of three |
| 2.7.23 | 24 Oct 2026 | A plan reads only the few newest candidates of each unit before its first verdict (4,100 of 18,700 stored on vex), reads attestation rows alone, and reads the second round's candidates in one batch |
| 2.7.22 | 24 Oct 2026 | A Rails plan boots the application for the native-library check beside the fetch instead of in the verdicts (5.8 s on vex), reads its candidates in one batch instead of one by one, and skips two store scans only a push needs (2-4 s) |
| 2.7.21 | 24 Oct 2026 | The Rails toolchain probe runs from a file, so Bundler switches it to the lockfile's version as it does the tests: after vex moved to Bundler 4.0.23 no file was attested; includes 2.7.20 |
| 2.7.20 | 23 Oct 2026 | A push drops deletion records older than a day (431,000 of vex's 465,000 metadata entries), and putting back manifests reads them in one batch: and the store keeps its coverage record through superseded attestations: a vex plan 13-15 s after master moved, 3 s when it has not |
| 2.7.19 | 23 Oct 2026 | A plan reuses what it learnt before: what each attestation and manifest blob names, which metadata commits have complete trees, and the tip it last merged; a vex plan went from 10-28 s to 2-7 s on a laptop |
| 2.7.18 | 23 Oct 2026 | A push asks again whether the remote moved right before git push, so the run's other job pushing during the pre-push checks no longer costs a rejected push |
| 2.7.17 | 23 Oct 2026 | A push asks whether the remote moved before its pre-push checks and fetches, not after (about 18 s saved on each round that has to merge again) |
| 2.7.16 | 23 Oct 2026 | With VCI_TIMING, a Rails batch compares how long a file's tests took with how long its process took |
| 2.7.15 | 23 Oct 2026 | Storing a run's attestations shares one store session instead of opening three per unit, and no longer reads the unit's earlier attestations to replace them |
| 2.7.14 | 23 Oct 2026 | With VCI_TIMING, a Rails batch reports each part of building a file's recorded inputs |
| 2.7.13 | 23 Oct 2026 | With VCI_TIMING, a Rails batch reports what part of writing each file's output takes the time |
| 2.7.12 | 23 Oct 2026 | A push lists the local metadata commit's keys once for its two checks instead of twice |
| 2.7.11 | 23 Oct 2026 | 2.7.10's fast merge also applies when both sides made the same change to a path (both jobs of a run write the same deletion records) |
| 2.7.10 | 23 Oct 2026 | Merging fetched metadata when the two sides changed different keys lets git build the merged tree from the changed subtrees alone (18 s a push round on vex) |
| 2.7.9 | 23 Oct 2026 | Looking up a stored input manifest uses the store's index instead of scanning every project row (a plan looks up about 2,000); a merge applies its deletion records in one transaction |
| 2.7.8 | 23 Oct 2026 | A plan no longer reads every stored attestation of every unit twice to choose which to fetch; with VCI_TIMING, the plan's fetch of stored values and a metadata merge report their parts |
| 2.7.7 | 23 Oct 2026 | The record of the local commit a store covers survives a merge of fetched metadata, so a push after a fetch skips the coverage check too |
| 2.7.6 | 23 Oct 2026 | Checking that a fetched metadata tree is complete no longer asks the promisor for a missing subtree on git before 2.44 (about 11 s each time, twice a fetch on vex) |
| 2.7.5 | 23 Oct 2026 | A fetch no longer re-reads the whole store and local tree to check that the store covers the local metadata commit when the store has recorded that it does (up to 26 s a fetch in CI) |
| 2.7.4 | 23 Oct 2026 | With VCI_TIMING, merging fetched metadata reports each of its parts |
| 2.7.3 | 23 Oct 2026 | A push that merges again because the remote moved no longer uses up an attempt (up to 30 such rounds), so a busy hour no longer makes a job give up without one rejected push |
| 2.7.2 | 22 Oct 2026 | A Rails test file that runs past VCI_RAILS_FILE_TIMEOUT (default 10 min) is stopped, fails and is not attested, instead of holding the job with no output until the job times out |
| 2.7.1 | 22 Oct 2026 | A push onto a tip the remote has moved past is skipped after one ls-remote instead of sending the whole history to be rejected (about 70 s each on vex); a CI job no longer repacks the whole repository before it pushes |
| 2.7.0 | 22 Oct 2026 | Editing vci.toml no longer runs every test again: only a project's own settings are an input of its units. A project the checkout lacks is left out instead of running everything, and a restored store whose local commit lacks trees gets them from the remote |
| 2.6.5 | 20 Oct 2026 | A push over https first fetches the tip's versions of the values it changes: git's https helper always builds a thin pack, which takes them as delta bases (every vex full run since 19:48 failed ten pushes) |
| 2.6.4 | 20 Oct 2026 | Autoload lookups are checked against one index per manifest (1,061 thread-seconds of a vex job checking inputs); a push that fails on an object it lacks says what the remote's tip is against the tip it merged onto |
| 2.6.3 | 20 Oct 2026 | A push that fails on an object this clone lacks fetches every object the pack needs, as git lists them, and tries again (two vex jobs failed ten attempts each, one deletion record per attempt) |
| 2.6.2 | 20 Oct 2026 | A plan or push no longer starts git cat-file again for every stored value the object database lacks (a laptop plan ran for 20 min, a push for 46 holding the exchange lock) |
| 2.6.1 | 20 Oct 2026 | rails_autoload_lookups also counts the constants a test's repository code asks defined? about |
| 2.6.0 | 20 Oct 2026 | Opt-in rails_autoload_lookups: a file added to an autoload directory runs again only the tests that looked up the constant it defines (27 of 38 recent vex pull request runs ran everything, 22 of them because an autoload directory gained a file) |
| 2.5.0 | 20 Oct 2026 | Every vci command logs how long it and each of its steps took; vci metrics summarizes them, and vci ci adds its slowest steps to a GitHub job's summary |
| 2.4.1 | 20 Oct 2026 | vci's own store connection no longer syncs the disk after every write |
| 2.4.0 | 20 Oct 2026 | --partition splits a project of files by size, so the jobs get about the same amount of tests (vex's Rails jobs: 2,762 and 3,231 s split by test id, 3,047 and 2,946 s by size) |
| 2.3.9 | 20 Oct 2026 | Up to 4,000 values are fetched in one request instead of four, so a plan's fetches no longer push a restored store into a full repack |
| 2.3.8 | 20 Oct 2026 | Supersession checks that an earlier commit is present without asking the remote for it (12.7 s per git command on vex's git 2.43) |
| 2.3.7 | 20 Oct 2026 | With VCI_TIMING, a git command that took 2 s or more is reported, and so is each question supersession asks about a commit |
| 2.3.6 | 20 Oct 2026 | Supersession writes a commit-graph first when the repository has none, so each question about history reads commits from it |
| 2.3.5 | 20 Oct 2026 | Supersession finds the commits only this run's commit holds with one walk of the refs, not one per earlier commit |
| 2.3.4 | 20 Oct 2026 | A plan reads each base manifest once; supersession reports where its time goes |
| 2.3.3 | 20 Oct 2026 | Supersession asks git about each earlier commit once, with one walk of the refs instead of two |
| 2.3.2 | 20 Oct 2026 | Supersession checks an older attestation's commit before reading its manifest; a batched fetch merges packs only when they near gix's limit (git 2.43 repacked the whole repository after each) |
| 2.3.1 | 20 Oct 2026 | Serializing the local metadata writes only the changed entries (git-meta rewrote every input manifest: 18 s a vex push); no merge when the local metadata already descends from the fetched tip |
| 2.3.0 | 20 Oct 2026 | A long Rails file runs as shards across a batch's slots, each a share of its test methods (one vex file took 258 s in one process, the end of its job's batch) |
| 2.2.13 | 20 Oct 2026 | A plan's second round fetches only what its first found missing (13 s on a full vex run with nothing to fetch); a Rails batch says when its groups ran at once, with each group's own time; storing a unit's attestation asks git for its directory once, not eight times (about 20 ms a unit, 21 s of a vex job) |
| 2.2.12 | 20 Oct 2026 | A Rails batch runs its boot groups at once, each in a temp directory of its own (a vex job waited 94 s for one file's group) |
| 2.2.11 | 20 Oct 2026 | A Rails batch with several boot groups says how long each took |
| 2.2.10 | 20 Oct 2026 | A Rails batch no longer walks and removes its tests' temp files one by one after its last group |
| 2.2.9 | 20 Oct 2026 | A push that fails on an unreadable object first fetches every deletion record of the pushed tree the clone lacks, in one batch |
| 2.2.8 | 20 Oct 2026 | A push whose own deletion record was lost, with no row left to write it from, replaces it with a new one for the same key |
| 2.2.7 | 20 Oct 2026 | A Rails batch says how long its process took to reach its slots from the moment vci started it |
| 2.2.6 | 20 Oct 2026 | A Rails batch no longer waits for a process a test left running in the background (about 90 s of a full vex job) |
| 2.2.5 | 20 Oct 2026 | A Rails batch says when its slots had all exited and how long its own work after them took |
| 2.2.4 | 20 Oct 2026 | A push writes back, from the store, a deletion record whose blob the clone lost, instead of failing ten times |
| 2.2.3 | 20 Oct 2026 | A Rails batch says how long it booted before its slots and how long the slots' cleanup took |
| 2.2.2 | 20 Oct 2026 | The cause of the lost pushes: on git before 2.45 the pack consolidation deleted values the job had just written; it now keeps them |
| 2.2.1 | 19 Oct 2026 | A push first downloads the deletion records in the targets it changes, so one another job wrote for the same key never stops it |
| 2.2.0 | 19 Oct 2026 | The key says when an attestation was issued and on which tree: a plan downloads one candidate per unit instead of five; vci prune --keep N keeps a unit's newest N per signer |
| 2.1.18 | 19 Oct 2026 | A push whose pack hits an object this clone cannot read fetches that object by id and tries again at once; the diagnosis says whether a loose object file exists and what git said reading it |
| 2.1.17 | 19 Oct 2026 | A push packs with the full object walk (pack.useSparse=false): git's sparse pack walk wanted values the tip held and the clone never fetched, every attempt, on vex's master attest jobs; a push that still fails carries its diagnosis in the error |
| 2.1.16 | 19 Oct 2026 | A push re-checks the tip's tree and the changed blobs right before git push; a push that still fails on an unreadable object says which, whether the tip's tree was complete and how many objects were missing |
| 2.1.15 | 19 Oct 2026 | Two pushes that lost a job's attestations: a tree fetch the server left subtrees out of is completed by id; a push whose pack needed an object this clone could not read fetches and tries again; batched fetches consolidate their packs (gix's slot map); no gc --auto behind a metadata fetch |
| 2.1.14 | 19 Oct 2026 | VCI_TIMING=1 times each step of a metadata fetch (a job's push spent 28 s fetching before a 3.5 s materialize, in no step the log named) |
| 2.1.13 | 19 Oct 2026 | A plan fetches the manifests of each unit's newest attestation first and the rest only for the units it could not skip; the store is listed once per plan, not once per batch |
| 2.1.12 | 19 Oct 2026 | An attestation whose tree is still at some ref's tip is never superseded: another pull request's run deleted a merged pull request's attestations, and master re-ran every file |
| 2.1.11 | 19 Oct 2026 | Values are fetched by four git processes at once: a plan's fetch halves |
| 2.1.10 | 19 Oct 2026 | A push fetches the objects its pack needs before pushing (a job's push failed on a deletion record it held as a key only) |
| 2.1.9 | 19 Oct 2026 | The shared base is per slot: what a Rails file shares is its slot's boot, not the whole run's |
| 2.1.8 | 19 Oct 2026 | The entries a run's units share are stored once: a Rails file's manifest shrinks from about 60 KB to the few it adds, and so does what every job fetches |
| 2.1.7 | 19 Oct 2026 | Rails files with skipped tests can be waived like pytest's; result:no-tests is waivable; two absent probes do not collide under case folding; the slot retry proven on a real collision |
| 2.1.6 | 19 Oct 2026 | VCI_TIMING=1 times the Rails batch's Ruby process and the reading of its outputs |
| 2.1.5 | 19 Oct 2026 | A Rails file that fails in a slot runs again on its own, and the retry's result is the file's |
| 2.1.4 | 19 Oct 2026 | The manifests a run orphans are deleted in one session (a minute, before); the Rails batch says when its slots' last file ended and how busy the slots were |
| 2.1.3 | 19 Oct 2026 | Files run longest first; a pull request's rebased-away attestations are superseded; the superseded ones are found in parallel; the push shows its parts |
| 2.1.2 | 19 Oct 2026 | The Linux binaries use mimalloc: on a 16-core runner a plan's verdicts phase was 20 times slower than on a laptop because musl's allocator serializes threads |
| 2.1.1 | 19 Oct 2026 | Rails on CI: the plan reads manifests in parallel, load-path probes fold into one listing, and timing shows every part |
| 2.1.0 | 18 Oct 2026 | Rails runs its files in parallel slots, one seeded database each; a plan reads the checkout once (minutes to seconds); units are checked and signed in parallel |
| 2.0.4 | 18 Oct 2026 | No more warning about .DS_Store in a listing git ignores it in |
| 2.0.3 | 18 Oct 2026 | Directory listings leave out what git ignores, so an attestation made on a laptop verifies in CI |
| 2.0.2 | 18 Oct 2026 | A read of PATH, HOME or another built-in-ignore variable no longer pins an attestation to one machine; a private key file signs without ssh-agent |
| 2.0.1 | 17 Oct 2026 | A blobless fetch works on git before 2.45 (Ubuntu 24.04's 2.43): the presence check no longer dies on a value not yet downloaded |
| 2.0.0 | 17 Oct 2026 | A fetch downloads keys, not values; CI attests keylessly with the job's OIDC token; one suite over N jobs with --partition; Rails boots once per batch and attests per file |
| 1.9.2 | 17 Oct 2026 | policy.attest_on_any_ref: pull requests attest with a CI key, so the merge queue and main skip what they ran |
| 1.9.1 | 16 Oct 2026 | A cargo target without tests is attested (0 tests) instead of rebuilt on every push |
| 1.9.0 | 16 Oct 2026 | Attestations reference their input manifest, stored once; a fetch applies only what changed; vci run deletes what it supersedes |
| 1.8.2 | 16 Oct 2026 | Fetch and push no longer read every attestation; vci prune --superseded; vci push --rewrite-history |
| 1.8.1 | 16 Oct 2026 | A CARGO_HOME inside the checkout is not part of the repository |
| 1.8.0 | 16 Oct 2026 | Cargo: one build, then the test binaries run in parallel; integration tests of packages with binaries attest; undeclared paths can be waived |
| 1.7.1 | 16 Oct 2026 | ignore_taints waives reads outside the repository, writes inside it and skipped tests; Vite's caches are not writes |
| 1.7.0 | 16 Oct 2026 | policy.ignore_taints: attest through refusals, by pattern |
| 1.6.2 | 16 Oct 2026 | --project on vci ci and vci plan; node:test leftovers run in one process |
| 1.6.1 | 16 Oct 2026 | vci run with explicit files fails open on an unrelated project's listing error |
| 1.6.0 | 16 Oct 2026 | Environment policy: set, hash, ignore; nothing to export; vci env |
| 1.5.0 | 16 Oct 2026 | vci's fixed cost per CI job drops from 12 s to about a second |
| 1.4.1 | 15 Oct 2026 | The action never compiles vci in your CI; version tags carry their own binaries |
| 1.4.0 | 15 Oct 2026 | The Rails test database is the test engine's: its contents are never an input, and database servers need no policy |
| 1.3.0 | 15 Oct 2026 | CI attests its own runs, so unchanged tests are skipped from one commit of main to the next; macOS attestations count on Linux |
| 1.2.0 | 14 Oct 2026 | Rails apps with Active Storage can attest; fixes from the first large monorepo deployment |
| 1.1.0 | 14 Oct 2026 | New node-test adapter for Node's built-in test runner |
| 1.0.0 | 13 Oct 2026 | First stable release: six runners, the GitHub Action, prebuilt binaries |
| 0.3.0 | 2 Oct 2026 | RSpec support |
| 0.2.0 | 1 Oct 2026 | Attestations move to git-meta; Rails (Minitest) adapter |
| 0.1.x | 28 Sep 2026 | Vitest, pytest, Go and Cargo adapters; the action with prebuilt binaries |
2.10.3
12 November 2026. A push keeps the store small. Until now only vci prune removed superseded attestations, and on vex it ran in one hourly job. That job was switched off on 8 November. Pull request jobs publish an attestation of every file they run, and by 12 November the store held 289,159 attestations where it had held about 30,000: 125 of each Rails file. Every Rails job's plan took about 400 s where it had taken 9: 99 s to fetch and merge the store from a cache four days old, then 288 s in round 2, which fetched and read every one of the 180,033 candidates of the 1,798 files it could not skip (214 s of that in a git repack of the 46 packs those fetches left).
On git-meta, vci push and vci ci --push now remove, before they publish, the attestations of each unit by one signer that are older than the 4 newest, except one made on a tree some branch or tag has at its tip (as vci prune keeps), and the state objects past [store] explain_hashes_days. The keys say when an attestation was issued and on which tree, so nothing is fetched or read. The push says vci: removed N of M attestation(s): older than the 4 newest of their unit and signer, ... and publishes the deletions. VCI_STORE_AUTO_PRUNE=N keeps N (0: every one, as before). vci prune is still there for expired attestations, unreferenced manifests and objects, and a long history. Test: e2e_cargo::cargo_push_keeps_the_newest_attestations_of_a_unit_without_a_prune.
Round 2 reads a unit's newest candidates. After its first pass a plan reads at most the 16 newest candidates not read yet of each unit it could not skip (16,384 in all, 2 a unit at least; VCI_PLAN_CANDIDATES=N, 0 for every one). A unit only an older attestation covers runs, and the plan warns that older attestations were not read and that vci prune removes them. A store kept small never reaches the cap. Test: plan_rounds::round_two_reads_the_newest_candidates_on_git_meta and _on_sqlite.
2.10.2
8 November 2026. The publishing commit is looked up within 300 ms. 2.10.1 has round 2 of a plan on a git-meta clone fetch, beside its candidates, the hdr and tables objects of their runs, found from the metadata commit that published each run: a git log -1 of a record's path, walking back from the commit its row came with. On vex's first 26 Rails pull-request plans with 2.10.1, that walk reached records published hundreds of commits before on 4 plans and took 2.9 to 3.8 s (round 2: 47 object(s) to fetch beside the candidates: 3.79s), more than the 0.8 s round trip it saves, and those plans still made three round trips. The lookups now share one deadline of 300 ms; one still running then is stopped and its run names no object, so its objects are fetched where 2.10.0 fetched them. VCI_TIMING=1 says round 2: the publishing commits' lookup stopped at its deadline. Nothing a plan decides, says or explains changes: e2e_round_trips::a_slow_history_walk_does_not_hold_up_the_plan makes every walk take 5 s and requires the guess to give up within 1.5 s, the plan to take at most 2 s more than 2.10.0's fetching, and its JSON, text and vci explain to be the same (with 2.10.1 the guess waited for the walks and the test fails).
The same 26 plans show where the rest of the time goes: how stale the attestation store cache a job restored was. With 0 to 45 entries changed since the cache was saved, plans took 4.7 to 8.6 s and made one round trip or none after their first verdicts; with 145 to 4,444 changed, 9 to 22 s. On a quiet day, when master moves rarely, the cache a pull request job restores lags the store by hours.
2.10.1
8 November 2026. This release makes a plan faster after its first verdicts, changing nothing a plan decides or prints, in the two cases vex's Rails pull request jobs showed on 7 November 2026 (vci 2.10.0), and has the libSQL store send a server one write transaction at a time. On a small change, a plan of 9.01 s waited, after its first verdicts, for three git fetch round trips to GitHub one after the other, about 0.8 s each: round 2's candidates (fetch 2 value(s) (2 blob(s) downloaded): 0.85s), then the objects those candidates name, known only once they were read (fetch 4 value(s) ...: 0.81s), then the state objects that name the inputs that changed (fetch 5 value(s) ...: 0.88s). On a change to inputs of every Rails file (2,208 ran, 135 skipped), a plan of 10.63 s spent 4.06 s in round 2: round 2: read 16968 candidate(s) set aside for 2199 unit(s) not skipped, check them again (2208 still not), 0.77 s of it reading those candidates and the rest checking them, and checking again the ones the first pass had checked.
Round 2 in one round trip (git-meta). Round 2 now fetches, beside its candidates and at the same time, the objects it can name from keys alone: the shape and state objects of the run-state links that carry the candidates' key hints, the hdr and tables objects the metadata commit that published a candidate added (at most 8 runs looked up), and the state objects the failure text after the verdicts could load, when they are at most [store] plan_states (so a plan still downloads at most that many). They come in a git fetch of their own, tried once: its failure never fails the candidates' fetch, nothing is fetched beside the candidates when the candidates need no fetch, and once the candidates' fetch ended the objects' is given as long again as it took (at least 1 s), then stopped, so that a slow or large object never holds the round trip up longer (with a git fetch asking for a guessed state object stalled 20 s, the plan as first merged took 22.74 s instead of 3.35 s); what it did not bring is fetched where 2.10.0 fetched it. Which objects are fetched decides nothing: every object is still checked against its id when read, and one still missing is fetched where 2.10.0 fetched it. The plan decides, says and explains exactly what 2.10.0 does; a test compares both on the same clone, and with the objects' fetch failing. VCI_TIMING=1 prints round trips for values after the first verdicts: N; VCI_FETCH_BESIDE=0 fetches as 2.10.0 did.
Measured with release builds on a cached clone of 68 Vitest units, every git fetch slowed by 0.8 s, whose pull request changes a module another clone attested in a run of its own and one nobody did (five plans each, medians, on a loaded machine): one round trip after the first verdicts instead of three, 1.64 s from the end of the first verdicts to the end of the plan instead of 3.57 s (plan total 4.42 s instead of 5.88 s), one more blob downloaded (1.7 KB, a state object no text then read); with the other run made on a changed global input (12 new hdr objects) 1.54 s instead of 3.57 s (plan total 3.85 s instead of 5.75 s). Not yet measured on vex.
One round trip, or two. It is one round trip when the state objects the text could load are at most plan_states and the commit that published each run of round 2's candidates added at most 64 objects; otherwise two. An independent measurement (release builds, lazy clones of local remotes, every git fetch slowed by 0.8 s, medians of 7 to 9 plans) found one instead of three on 150 Vitest files whose units round 2 checks again were attested by 4 runs (1.17 s instead of 3.08 s after the first verdicts, plan total 2.80 s instead of 4.72 s); two instead of three with those units attested by 16 runs, whose 11 state objects are more than 8 (2.70 s instead of 3.72 s); and two instead of three on 2,000 files whose runs' publishing commits each added more than 64 objects (2.73 s instead of 4.87 s).
What the guess costs. State objects fetched beside the candidates are downloaded whether or not a text then reads one: when every unit round 2 checks again then skips, none is. From keys alone a plan cannot tell which of those units will skip, and guessing state objects only for the units round 2 does not check again would bring back the third round trip whenever one it checks again still runs, as on vex's small pull requests. So a plan may download up to plan_states state objects (each at most 16 MiB) that it does not read: in the same measurement, 2 state objects (8,292 bytes) on 16 units that all skipped, 167 KB more per plan on 2,000 files of about 4,458 inputs each when the 50 units round 2 checked again all skipped (the plan still 7.42 s instead of 13.08 s), 307 KB when all 2,000 went to round 2 and skipped, and 12 to 30 KB on 150 files.
Round 2 is now cheap when almost every unit runs. Where the time went: a profile of a plan on a synthetic project of the second shape (2,000 Vitest files of about 4,450 inputs each, 8 runs, every file run by the pull request) put most of the verdicts' time in waiting for the checkout cache's locks: each candidate observed its ~4,450 entries one by one through the cache, the same paths for every candidate on every thread. The rest was hashing each candidate's input root, twice for a candidate that failed, with two hasher calls per field.
What a plan keeps between candidates and rounds. A format-3 candidate's outcome (what vci explain prints of it, and how it passed or failed) is kept and taken by a later round instead of checking the candidate again, when its value is the same bytes; it is not kept when a later round could change it (a value the store could not read, a hdr, tables or shape object not at hand, a failed observation, a panic) nor for a format-1 record, whose manifest a round may fetch. Each entry of a run's shape is observed once for all of that run's candidates, except directory listings and autoload lookups, which depend on each unit's exclusions and autoload directories and are observed for each candidate as before. The root of a descriptor's observed set is kept by tables object and descriptor, so a unit attested by two runs of one shape has its root computed once. A unit's global manifests are made once for all its rounds. A failing candidate's root is computed once, a root's fields are hashed in 64 KiB buffers (the same bytes, the same BLAKE3), and the pass that names the changed input marks a run's entries instead of sorting every unit's indices together (8 million on that project).
The same verdicts. VCI_PLAN_RECHECK=1 keeps none of this and checks every candidate in full in every round, as 2.10.0 did. crates/vci-cli/tests/plan_rounds.rs builds a store whose units have five or six candidates each (one that passes only in round 2, one by a signer the base does not trust, one expired, one whose signature was edited, ones made on other inputs) on SQLite and git-meta, and requires the plan's text and JSON and vci explain of every unit to be the same as with VCI_PLAN_RECHECK=1 and as with a vci built before this change when VCI_TEST_VCI_BEFORE names one (it was run against a build of main at bd92e31, 2.10.0 and three small fixes). VCI_TIMING=1 adds round N: K candidate outcome(s) kept from the rounds before (no seconds, so the round's own line keeps its metric key).
Measured with release builds on a 14-core laptop shared with other jobs (load average 14 to 19; three plans each, alternating with 2.10.0). On the project above (16,000 candidates, the first pass reading 4,000 and round 2 the 12,000 set aside; the 8 runs share one shape): round 2 took 3.25, 3.75 and 7.69 s with 2.10.0 and 0.38, 0.43 and 0.41 s now; the plan 5.54, 5.43 and 9.52 s and 1.38, 1.46 and 1.57 s; the verdicts' rehash 50 to 113 thread-seconds and 1.6 to 2.5. On 500 files and 6 runs that each add a file, so that no two runs share a shape: round 2 0.67, 0.79 and 0.63 s and 0.12, 0.14 and 0.13 s, the plan 1.79, 1.50 and 1.40 s and 0.71, 0.75 and 0.70 s, the time left being mostly each candidate's root, about a millisecond for 4,450 entries. Under a load average of 150 to 600 the first project's round 2 took 8.0 to 36.9 s with 2.10.0 (lock waits grow with the threads descheduled while holding a lock) and 0.44 to 1.57 s now. In a profile of that plan (macOS sample), 2.10.0's plan spent 18% of its samples waiting for the checkout cache's locks and 14% in BLAKE3's one-block compression; this release's plan took a tenth as many samples. How much a vex plan gains depends on how many of its candidates' runs share a shape (the plan's objects: timing line counts its shapes); the lock waits are gone either way. measure_round_two_when_every_unit_runs in the same test file builds these projects.
One libSQL write at a time per command. A libSQL server applies one write transaction at a time. The libSQL store sent each thread's write at once, so threads of a command that wrote together each held one of its pool's connections while they queued for the server's write lock (the command's reads then waited for a connection), and on sqld 0.24.32 that queue can stall for good: every later write waits out its timeout until the server restarts, while reads go on. On 7 November 2026 this stalled the sqld CI shares between the store tests, in the conformance suite's c12 (eight threads writing through one handle, six through three others), and 65 of the suite's 132 tests failed over 65 minutes. The store now sends one write transaction at a time: the others wait in vci, holding no connection, until the one before them was answered, and reads never wait for them. The wait is the other write's time, not the server's: it counts against no request timeout, no retry time and no transaction's size (as first merged it was given the request timeout and counted as a write that failed, so that four threads behind a link holding each answer 1.5 s, at a 4 s timeout, lost 6 to 9 of their 12 puts to a healthy server and latched the breaker). A transaction that failed keeps the turn until its failure is counted, and the breaker wakes every waiter when it latches: a server that stops answering writes fails all of a command's writing threads within the retry time (about 4 s for six threads at a 2 s timeout), and the breaker's reason is the server's timeout, writes kept failing (libsql store: a request timed out after 2s), ..., not the wait for a turn. A turn lasts the write's whole round trip, so one command's threads take turns over the link's latency too (12 puts from four threads with each answer held 100 ms: 1.3 s instead of 0.34 s). On a loaded laptop the suite's remote half stalled a fresh sqld at c12 in 5 of 27 runs before the change and in 0 of 27 after, the two run side by side. vci run sends its units' writes from one thread, so a command seldom had two at once; several processes writing at once can still stall a sqld 0.24.32 (STORE-BACKENDS.md, section 21, item 17).
2.10.0
7 November 2026. vex runs vci prune after every attest job on master, and its shared store still grew: on 6 November 2026 refs/meta/main held 30,325 attestations, 152,281 deletion records, 24,681 input manifests and 7,698 objects in its tip (377,200 tree objects, 33 MB) and 7,426 commits of history in three weeks, whose trees are 21.3 million objects (a full blobless fetch of them passed 3 GB before it was stopped); a developer's .git/git-meta.sqlite had reached 1.8 GB and a fresh clone's 390 MB. A plain vci prune now keeps the store small, with settings under [store] and defaults that need no change:
Age. An attestation issued more than max_age_days ago (14; VCI_STORE_MAX_AGE_DAYS, vci prune --max-age DAYS, 0 for none) goes even when it was made on a tree some branch or tag still has at its tip, which before kept it until it expired however stale the branch. A unit's newest attestation by each signer never goes but by expiry, and neither does one a plan of the checked-out commit used (vci plan and vci run remember those for a week): on the default branch's attest job, what that branch's plan uses stays. The --keep newest and the live trees still decide within the age.
Format 1. Once a unit has a format-3 attestation by a signer, that signer's format-1 attestations of it issued before it go (one issued after it, by a writer still on format 1, is ranked with the rest), and with them the input manifests nothing else references (not while record_format = 1). One made on a tree some branch or tag has at its tip stays while it is within max_age_days, as any record there does, so that branch's plan still skips the unit on it (as this change first stood, a prune on another branch removed it, and the branch ran the unit again); it does not count towards --keep. A referenced manifest the store does not hold no longer keeps every manifest (on vex one such reference kept all 24,681). The summary says whether a format-1 attestation remains, and vci store upgrade --finish on git-meta now counts them (exit 1 while one remains) and at zero removes the manifests left.
Deletion records (git-meta). Kept deletion_records_hours (6, VCI_STORE_DELETION_RECORDS_HOURS) instead of 24, and dropped by vci prune whatever their number (a push still waits for 2,000, down from 20,000). Of vex's 148,423 attestation records, 145,306 were under a day old and 22,897 under six hours: each run supersedes the last one's attestations, most within hours of their issue, so the window decided the tree's size. A record goes only once its deletion is published, when neither the local metadata tree nor the remote tip last fetched or pushed holds the value; until then it stays however old, and the prune says vci prune: 0 of 2 deletion record(s) older than 0 hour(s) dropped ([store] deletion_records_hours); 2 more past the window kept until a `vci push` publishes their deletion. Dropped before that (as this change first did), a prune with deletion_records_hours = 0 dropped the records it had just written, and every push and fetch from that clone was refused after it (serializing the local git-meta store would drop 2 value(s) of refs/meta/local/main that it has no deletion record for), its own history rewrite included; with the default window a record nothing had pushed for six hours went, and the push put the value back. A record only stops a clone whose history does not reach the deletion from publishing the value again; such a value is superseded, expired or unreferenced, the next prune deletes it again, and no push is refused for it.
Prune, then push. On git-meta a prune's deletions are written in the local store, and the push after it publishes them: a job prunes, then pushes, as vex's attest jobs do (vci prune || true; vci push || true). The documentation said to prune after the job's push, which leaves the deletions unpublished until a later push.
History (git-meta). Once the fetched metadata history has more than history_max_commits commits (2000, about two to three days of vex; 0: never), vci prune in CI (CI set to anything but empty, false or 0, as GitHub Actions sets it) publishes the store as one commit, as vci push --rewrite-history does. Elsewhere, a laptop for one, it publishes nothing and says so: vci prune: note: the metadata history has N commits, more than [store] history_max_commits = 2000: `vci push --rewrite-history` publishes the store as one commit (`vci prune` does that itself only in CI, where CI is set) (as this change first stood, a plain prune past the limit published a rewrite wherever it ran). The push decides again on each attempt, on the history it has just fetched: a clone that last fetched before another one's rewrite, vex's second hourly attest job for one, publishes on top of that rewrite (vci prune: meta's refs/meta/main was published as one commit since this clone last fetched it: published the store on top of it), and of two rewrites racing, the one whose lease fails merges the other and pushes on top of it, so crossing the limit leads to one rewrite (as this change first stood, it counted the history as last fetched, and a second rewrite dropped the first and what had been pushed on it). The parentless commit becomes the local metadata commit only once it is published: moved before, a rewrite whose lease failed because another clone pushed meanwhile merged that push without a common ancestor, and a manifest the other clone had deleted came back. A clone still holding the old history merges the new one without a common ancestor, three-way on the newest tip it had from the remote, when the new history starts with the rewrite's commit (the first its local history contains of the tracking ref before the fetch moves it, the tips earlier fetches killed before their merge moved it from, and the marker, which names the tip it last merged or the commit it last pushed; or the rewrite's commit itself, when it was made from a commit the clone has, see below): a value it had from the remote that the new tip lacks was deleted there, before or after the rewrite, and is deleted here too (note: meta's metadata history was rewritten since this clone last fetched it: merged it on the tip this clone last fetched (<id>), so the N value(s) deleted there since are deleted here too). A stale CI cache therefore publishes nothing deleted since it was saved, however many jobs restore it; with only the rule that the new tip's deletion record of an attestation wins over the value (kept for a clone without such a tip), every restore published again what had been deleted since and whose record had passed the window (a test restoring one cache three times: 3 of 3). A clone without such a tip also lets the new tip's deletion record of a manifest or object win over a value it put or fetched before the record was made (one a run put since stays and is published). Its push is one commit on top of the new history; a push that lands during the rewrite makes the rewrite's lease fail and is merged by its next round, its additions and deletions both kept.
Rolling out the rewrite. A vci before 2.10 merges a rewritten history without a common ancestor and keeps every value it holds: a 2.9 clone that last fetched before the first rewrite publishes again what was deleted since, and the records dropped (on a copy of vex just after the rewrite, a 2.9.1 clone's fetch and push put back 845 deleted values, 354 attestations and 491 manifests, and about 74,000 deletion records, taking the tip from 91,383 entries back to 168,106), once per such clone and rewrite, until the next 2.10 prune deletes them again. Upgrade every clone that pushes, laptops included, and let jobs already running 2.9 finish (vex's attest-rails runs up to 90 minutes) before the first rewrite, or set VCI_STORE_HISTORY_MAX_COMMITS=0 in the jobs that prune until then.
A fetch or push killed half-way (git-meta). git fetch moves the tracking ref refs/meta/remotes/main before vci merges, and git push moves it too (the remote's fetch refspec maps it) before vci moves its own refs. As this change first stood, a process killed in between (a CI step's timeout, a cancel) left a later merge with a rewritten history on the tip before this clone's last push: what that push published counted as the clone's own addition, so a value another clone had deleted (with a record or not) before rewriting the history was published again, by every job restoring a CI cache saved in that state (3 of 3 in the tests), and the clone's own deletion of a value its killed rewrite had published was undone (both the value and its record were published). Now the marker beside the store names the commit each push published as well as each merged tip (which also stops vex's cache step from keeping the whole tree of the tip before a rewrite: 246 MB compressed instead of 216 MB on a copy), a fetch notes the tip it moves the ref from (vci-materialized-<ref>.unmerged) until its merge is done, and a rewrite's commit names the local commit whose tree it publishes (Rewrite-of: <id>), which a clone killed before its local commit moved to the rewrite then merges on. (A tree equal to an older commit's is no base: a value added and deleted again, its record dropped, leaves one.) Whatever the base, a merged tree no longer keeps both a value and its key's deletion record where the record is newer: always for an attestation, whose key is never written twice; for a manifest or object, when this store wrote the value no later than the record (note: the merge held both a value and a deletion record of N key(s); the record, the newer change, was kept).
A ref naming a missing commit (git-meta). A local metadata ref or tracking ref naming a commit the clone lacks (a CI cache restored with its refs but not every object) failed every git fetch (fatal: bad object refs/meta/local/main) and every serialization until it was removed by hand; this predates 2.10. A fetch or push now deletes it with a warning: the next serialization writes the whole store as a new local commit, merged with the remote's tip like any local history (a marker naming the tip no longer skips that merge for the primary remote), and the tracking ref is fetched again.
A fetch that deletes and drops many is not quadratic (git-meta). A merge that deletes values notes their keys in a table of vci's (vci_cover_gone, which keeps the store's coverage of its commit), and each deletion record the merge drops was then forgotten there by a scan of that table: the cost grew as values deleted × records dropped. Rehearsed on copies of vex's store, the first fetch of a CI cache saved before the prune's rewrite deleted 23,300 values and dropped 103,700 records in 190 of its 224 s (376 of 401 s for a cache 5.5 hours older; 2.9.1's own incremental fetch spent 48 s on 9,474 × 44,758). The table is now indexed by key: 37 s, of which 9.9 s applying the records.
The local file. git-meta's log rows older than the last materialization and a day go, and on git-meta and SQLite a file whose free pages are a quarter of it and at least 16 MiB is compacted (PRAGMA incremental_vacuum, after one VACUUM into incremental mode).
Measured with this release's CI=true vci prune --dry-run on a copy of vex's store at its tip of 7 November 2026, 04:57 UTC (195,083 entries, 7,612 commits; vex's TTL is 14 days): 60 expired attestations, 4,237 beyond the 2 newest of their unit and signer (not a 2.10 rule: 2.9.1's dry run removes 4,248 as superseded), none older than 14 days and 291 format-1 ones would go, with 18,658 unreferenced manifests, 51 unreferenced objects and 103,692 of 128,465 deletion records past 6 hours, and the 7,612 commits would become one; --max-age 7 would remove 7,771 attestations, --max-age 3 14,038. The prune itself published the store as one commit of 91,383 entries (192,590 tree objects instead of 352,606) and compacted git-meta.sqlite from 455 to 238 MB, and the push after it had nothing to send; a pull request job restoring the cache saved before it then deleted the 23,300 values pruned and published none of them again. (The figures this note first gave, 2,008 format-1 attestations and 4 beyond --keep on the tip of 6 November, were measured before format-1 records on a live branch's tree were kept and before 2.9.1.) A vci.toml that sets one of the three keys is refused by a vci before 2.10 (an unknown key): set the variables during a rollout. Details: Keeping the store small.
Local metadata that lost shared values (git-meta). On 6 November 2026 a laptop's vci push in vex failed on every attempt with pushing would delete 4402 value(s) of meta's refs/meta/main that this store never deleted (first: project vci:manifest:00214f3b...); not pushing, until its local metadata was reset by hand to the remote's. Its refs/meta/local/main held a commit, git-meta: serialize (31 changes) at 20:32:28 UTC, that dropped 4,484 values of the project target (2,648 input manifests and 1,836 format-3 objects the clone had never fetched) with no deletion record, and every merge after it carried the loss on. The commit came from a vci push that ran from 20:31:10 to 20:42:04 (exit 2): its fetch's own serialization wrote the 31 changes (vci: serialize 62 change(s), 20:32:11) and moved the ref, then could not mark the store serialized (an error the fetch retries as a lock race, four rounds over about eight minutes: most likely SQLite's database is locked, the 1.8 GB store being shared by the clone's many worktrees; no command that finished overlapped, so the writer that held it left no metrics line), so vci handed the same changes to git-meta's serialization, which rebuilds a changed target from the rows it holds values for, moved the ref itself, and failed on the same marker write before vci's check could put the values back; the fetch's four retries took the ref as they found it.
The local metadata commit only moves to a checked serialization. git-meta now serializes in a scratch git directory of its own (<git common dir>/vci-serialize-<pid>, its objects and store symbolic links to the repository's), vci grafts back the values it left out, refuses a commit that drops a value without a deletion record, and only then moves refs/meta/local/main from the commit it read (compare-and-swap): a process killed at any instant, or a step that fails, leaves the ref at the previous commit or a checked one, and the store's serialization marker, which git-meta moves first, is put back (at once, or by the next command after a kill) so the next serialization writes the same changes again. A marker write that fails after vci's own serialization moved the ref is no longer an error. A test kills a push right after git-meta's serialization returns (VCI_FAILPOINT=serialize-after-git-meta, for tests only) and one makes the marker write fail as it did on vex; with 2.9.0 both leave the ref dropping the values not fetched.
A push or fetch puts lost values back. The published tree holds every value of the remote tip except those this store holds a deletion record for: a push that finds the local metadata lacking such values grafts them back at the remote's blobs, without downloading them, gives the store a row for each (not fetched), says note: restored N value(s) of <remote>'s refs/meta/main that this store had lost without deleting them (first: ...) and pushes; vci fetch does the same after merging, from one git diff-tree of the fetched tip and the local commit, so that the clone's plans read those values again. A clone 2.9.0 left in vex's state recovers on its next push, nothing deleted.
The input that changed, on format 3. A format-3 record keeps no per-input hash: the attested sizes and hashes of a run's inputs are in its state object, which no verdict reads, so 2.9.0's plan said no valid attestation (N candidates; best failed inputs: entry: changed (attested hash not loaded)) where format 1 had named the file. On vex that was 477,407 of 500,784 Rails re-runs in a day: nobody could tell from the plan which file made a pull request run its suite. On git-meta, vex's store, it could never be otherwise, since nothing said which state object a record's run wrote (a SQL store keeps it in a listing column; 2.9.0 read that column for every candidate every round, and loaded up to 16 state objects during the verdicts, whether the unit then skipped or not).
The plan names the input that changed. After the verdicts and their rounds, for each unit that runs whose best candidate failed that way, the plan groups the candidates by run (their shape and key hint) and ranks the runs: the one holding the most units' best candidates first (in vci explain, the run of the candidate its reason names, so that it names what the plan names), then the most candidates, then the newest. It asks the store which state object a run wrote only for the runs whose state objects the budget can load, in that order (one request for plan_states runs, more only while those named fewer, each for twice as many runs: the record's state_obj column on SQLite, PostgreSQL and libSQL; on git-meta the new run-state link below, found from the record's key and descriptor without reading a value, one range of the store's index a run: 0.003 ms on a copy of vex's store, whose 32,358 project rows a test of every key would read at 3.2 ms a run), and loads each distinct one once, in the runs' order, within [store] plan_states (default 8; VCI_STORE_PLAN_STATES: how many are asked for, fetched and read; a git-meta clone that has not fetched them fetches those in one git fetch) and plan_states_mib (default 16; VCI_STORE_PLAN_STATES_MIB: how many MiB of them are read, in order, the store saying their sizes first without reading them, the first that would pass it and every one after it not read). With either at 0 nothing is asked for, fetched or read. It then makes the candidate's entry: details again from it: the reason reads as format 1's did, best failed inputs: entry:vex-api/app/models/organization.rb (+3 more), and vci explain prints the attested hash labelled attested (unsigned, from the run's state object). Where it cannot, the reason says why: (attested hash not loaded: state expired) (the store names an object and says it no longer holds it: [store] explain_hashes_days), (attested hash not loaded: over the plan's budget), or (attested hash not loaded: no state object for the run) (the run wrote none, explain_hashes = false, or a vci before 2.10 wrote the record on git-meta); any other failure, a state object the store holds but cannot read (not base64) among them, leaves 2.9.0's text. It is text only: no verdict changes, and a plan in which every unit skips asks the store nothing for it (the verdicts load no state object and no longer ask for any column). vci explain names every candidate of its file that failed this way, within the same budget.
The inputs that made units run, summed up. The text plan (and so vci ci's log and the action's step summary) lists before its last line the inputs that made the most units run, counted once per unit from each running unit's best failure (inputs and global-inputs): changed inputs, by the units each made run (top 5 of 12):, one 412 app/models/organization.rb line each, then 37 unit(s) ran on changed inputs not named (attested hash not loaded) for the rest. No line starts with RUN or SKIP, and the plan still ends with its N to skip, M to run line.
Names are printed escaped. A committed file name may hold a newline, so a reason that names it (format 1's and SQLite's always could, git-meta's now does, and the summary above lists it again) could print a line of its own, SKIP forged.test.ts (…) or 9 to skip, 0 to run, which the action's grep -c '^SKIP ', its step summary and scripts that read the text plan would take for vci's. The text format of vci plan, the github format's ::notice and ::warning lines, the plan vci ci prints and vci explain now print every path, key, name and reason with its control characters (C0, DEL, C1), the Unicode line and paragraph separators and the bidirectional formatting characters (U+061C, U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069) written as a Rust string literal writes them (\n, \r, \t, \u{1b}, \u{2028}); the github format's ::notice and ::warning lines also write % as %25. Other characters, a backslash too, are printed as they are; --format json is unchanged and gives the exact names. The github format's file lists (run=, skip=, run_<name>=) are what a runner is given, so they name files as they are on disk, as 2.9 did (as this change first stood they were escaped too, and vitest given src/t\tab.test.ts found no such file and passed with the new failing test never run); only a line break would end the output's line, so a file to run whose name holds one makes run_all=true with a warning, and a skipped one is left out of skip=.
git-meta: run-state links. vci run now writes, beside a run's objects and in the same batch, one small string value vci:run-state:<shape id>:<the records' key hint>:<state id> on the project target (the store trait's new put_run_state; run_state_objects reads it, from the key alone, so a lazy clone needs no fetch for it). The trait's new object_sizes says, reading no object, which objects a store holds and how big they are (git-meta, SQLite, PostgreSQL and libSQL answer it; a backend that does not says Unknown, and the plan then reads such an object alone and calls none expired). vci prune and vci run's pass over aged state objects remove a link once no record carries its run's hint; it outlives its state object while the run's records stay, so the plan can say the state expired. A vci before 2.10 lists and deletes none of these keys. Records written before 2.10 on git-meta have no link and say no state object for the run until they are attested again; state objects last two days anyway.
Measured (the measure_naming_the_changed_input test of crates/vci-cli/tests/e2e_m3.rs): on a Vitest project of 1,004 units that each import 500 shared modules through one barrel (about 505 entries a unit, attested in one run, so one state object for all of them; a release build on a 14-core laptop shared with other jobs), with every unit skipping the pass asks the store nothing and loads no state object (0.000 s; 0 state in the objects line). With one shared module edited, so that 1,000 units run, it named the module for all 1,000 (best failed inputs: entry:src/lib/m0007.ts, and 1000 src/lib/m0007.ts in the summary) in a median 0.18 s on git-meta and on SQLite (the one state object read in 0.00 to 0.01 s, the details made again in 0.16 to 0.25 s), and in 0.29 to 0.30 s in a fresh git-meta clone after vci fetch, where the plan first fetches the state object (0.11 to 0.12 s from a local remote; a fetch over the network adds its round trip). The plans themselves took 2.3 to 4.7 s and varied by more than that between identical runs. The details are made once per run: the entries every unit observes the same way once for all of them, a unit's listings and autoload lookups with its own exclusions; made once per unit, as the verdict makes them, the same pass took 0.48 to 0.69 s for 300 units (about 40 ms a unit, 12 thread-seconds). A vex Rails unit names about 2,850 entries and its run's state object is about 285 KB, so expect the pass to grow with the run's distinct entries (6,639 for 973 units), observed once and already in the plan's checkout cache, rather than with entries times units.
2.9.1
Pull request jobs keep each other's attestations. When vci run (or vci ci --key) re-attests a unit, it deletes the same signer's earlier attestations of it that a line of history has moved on from. From 2.1.3 that included any attestation made on a commit the clone does not have at all, taken for one rebased or amended away. A pull request's CI checkout never has another pull request's merge commit (refs/pull/<n>/merge is not fetched), so with one key shared by every pull request job, each job deleted the other pull requests' attestations of every unit it ran whose inputs differed, and what survived was mostly the newest pull request's, of little use to the others. On vex on 6 Nov the pull request key published 138,019 Rails records and 133,397 of them were deleted that way. Now nothing is concluded from a commit the clone does not have, in a full clone as in a shallow one: such an attestation is kept, and vci prune removes what is left behind. A commit the clone has that no branch, remote-tracking branch or tag contains is still taken for one rebased away.
SimpleCov in RSpec. SimpleCov 1.3 asks git for the branch and the commit when it reports (its coverage history and the JSON report's meta), and the branch picks the maximum_coverage_drop baseline, so the exit status could depend on .git, an input vci does not record, and the Ruby collector tainted the file. Those two lookups now answer nil, as in a project that is not a git checkout; any other spawn still taints the file.
2.9.0
31 October 2026. An attestation in vci 2.8 is a DSSE envelope of about 21 KB and an input manifest of about 15 KB (36,320 bytes a unit on vex's sample of 1,175 real units), and most of those bytes say the same thing in every record of a run: the toolchain, the global inputs, the project, the waived taints, and the paths the run's units share. A Rails job of 800 units wrote 29 MB of them; a plan read 60 to 85 MB; Turso's write lock was held for 15 to 37 s a request. Store format 3 keeps each of those facts once. A unit's record is now a text frame: the line vci3, the signed statement exactly as signed, the signature, and a descriptor, about 1.8 KB in all. The statement (predicateType v2) carries the unit's own facts and two digests: of a hdr object (toolchain, global manifest, project; shared by every run at the same toolchain and global inputs, so the plan checks it once per project rather than once per candidate) and of a per-run tables object (the waiver table, env names and externals). The descriptor is a list of indices into a per-run shape object of paths and kinds, and the attested sizes and hashes live in a per-run state object that no verdict reads: it exists so that vci explain can print the attested hash beside the observed one, and it is removed after [store] explain_hashes_days (2) even while records reference it, or never written under [store] explain_hashes = false. Each object is stored under the BLAKE3 of its bytes and the plan recomputes every id it loads. What does not change: one SSHSIG (or Sigstore bundle) per unit over the exact statement bytes, the keys and slots, the trust files, the order and names of the plan's checks, the per-entry explain text, and fail-open. vci verify --envelope <record> prints the DSSE envelope rebuilt from a frame, so in-toto tooling verifies the same bytes it verifies today.
How a skip is decided now. The plan tells a record's format by its first byte (never by anything the store says beside it), verifies the signature over the statement bytes as found, and resolves the descriptor against the run's shape and tables into the set of paths and kinds the unit read. It then observes every one of those paths at HEAD and requires the root of what it observed, with the attested externals and env, to be the signed input root. The store contributes paths, kinds and ids; the attested file hashes enter no verdict, so a store that alters a shape, a descriptor or which objects a record names can only make the root differ (producing another set whose observation hashes to the signed root is a second preimage of BLAKE3). Ten store-side mutations from the design's security review are negative tests in vci-core. The hdr, tables and shape objects a plan's candidates name are loaded in one request per round, in candidate order, up to 16 MiB; past the cap a candidate fails inputs and is counted (VCI_TIMING=1 prints objects: N hdr, M tables, K shape, S state, B bytes, C candidate(s) past the cap, R distinct run(s)). A new check, for records of either format: the store key's signer segment must be the verified key's signer id, or the candidate fails signer. 2.8 never compared it, so a record copied under another signer's slot stayed there until that signer's history deleted it. A hash printed from a state object is labelled attested (unsigned, from the run's state object); once the object is gone the detail reads entry:<path> with expected <kind> exec=<bool> changed (attested hash not loaded) for an entry whose kind or exec bit differs, else entry: changed (attested hash not loaded) with both roots.
Supersession and prune delete on signed values only. On SQLite, PostgreSQL and libSQL a record's row carries listing columns copied from its statement (commit, platform, state, duration, expiry, the four object ids); they choose and pre-filter which old records vci run and vci prune read, and nothing more: a delete happens only when the old record's signed commit, platform, issuedAt and entriesRoot (a new digest over the manifest's entries, used for nothing else) or its signed expiry and tree confirm what the column said, so an edited column or key hint costs a 1.8 KB read of the row, or of its unit's rows, and never a wrong deletion (and while a store still holds an input manifest from 2.8, every row is read, since a 2.8 writer may have left its value under a frame's columns). vci run then removes the superseded run's objects that no record references any more (an hour of grace after a put) and the run's aged state objects; on git-meta, whose referrer scan reads every value, the objects are left to vci prune. vci prune --objects [--older-than DAYS] runs only the object passes. The waived list enters the storage key sorted and distinct for both formats (a v2 statement carries ids that are sorted and distinct), so a 2.8 record whose list repeated a string or was unsorted, which is almost every Rails record of the sample, sits in another slot than 2.9's record of the same facts, beside it, until supersession or expiry removes it: one more candidate per such unit, fail-open.
Measured, on the 1,175 real vex units of 28 October 2026 (9 runs, 18 header groups; the replay check crates/vci-cli/tests/replay.rs builds every unit's record and its run's objects from the sample's envelopes and manifests and checks each design claim: the descriptor resolves to the manifest's set for 1,175 of 1,175 units, entriesRoot and every v1 accessor agree for all, the 47 object ids recompute, and the signed subject digest is reproduced for the 3 units that list no base manifest, since the sample holds the deltas and not the bases). A record is 1,782 bytes mean (p50 1,760, p90 1,917, max 2,707) against 36,320; the statement 1,100 bytes, the signature frame 234, the descriptor 271. The 973-unit Rails run's objects: hdr 4,489 bytes (the same one for both Rails runs), tables 19,387, shape 123,265 (6,639 entries, 18.6 bytes each), state 285,128. Everything at rest, records and objects: 2,936,328 bytes against 42,676,097, 14.5x less (2,499 bytes a unit; 16.8x without the state objects). The sample holds no base manifests, so the design's worst case of nine shared groups per run (11.7x) is still to be measured on a real run. Against a local sqld (crates/vci-store-libsql/tests/format3.rs, release build), the largest run's first 800 units are 4 objects of 329,446 bytes and 800 records of 1,417,069 bytes, written in 2 transactions and 0.18 s (2.58 MB of SQL in all), and a second run superseding every one of them, with its 800 removals, in 2 transactions and 0.20 s. At vex scale on the same local sqld (loopback, so the transactions, bytes and rows carry over to Turso and the times do not; STORE-V2 section 10): 800 real Rails units are written in 2 transactions and 2.59 MB of SQL against format 1's 33 transactions and 30.24 MB (11.7x), 2.46 MB at rest against 28.86 MB (11.7x), 0.15 s against 0.86 s of store time, none lost; three format-3 writers at once stored every unit, where three format-1 writers stalled sqld 0.24's write lock in 25 of 25 attempts; and a vex-api plan of 1,156 candidates reads 2.9 MB against 43 MB (15x) in 2.60 s of wall, a store's first plan in 3.16 s (2.8.1 on format 1: 2.60 and 3.25 s, where loopback makes the 43 MB cost 0.10 s), with 90 MB less memory; most of either is the Rails boot that reads the native libraries. The design, its decisions and the measurement scripts are in docs/design/. Not measured yet: a write or a plan against Turso's hosted service, where the design expects an 800-unit partition at 2.4 MB on the wire in transactions of at most 1 MiB and a plan's reads at 5 to 6 MB instead of 81 to 87 MB at vex's spread of candidates (STORE-BACKENDS.md, section 2, "Measured (format 3)", and section 18, for which numbers are measurements and which are the design's estimates).
Migrating, in this order. (1) vci 2.9.0 reads format 1 and 3 and writes format 3; [store] record_format = 1 (or VCI_STORE_RECORD_FORMAT=1) keeps writing format 1 for one release, for a fleet that cannot upgrade every reader at once. A 2.8 vci reads a frame as a value it cannot parse and runs its unit, never skipping it wrongly, and keeps planning on the format-1 records until they expire, so skips are lost per unit until the binary is upgraded: pin the action and the binary in one change. (2) The SQL schema stays version 1 while a format-1 row can exist; the nullable columns and the objects and gone tables are added by vci store upgrade --begin ahead of the rollout (idempotent), or by 2.9's first write on a store that lacks them, in a request of its own before its batch, never inside one. (3) A 2.8 vci prune stops collecting input manifests once one frame exists (it cannot know what a record it cannot parse references): keep the window short, or run 2.9's vci prune once during it. (4) vci store upgrade --finish, by hand, once no format-1 row remains: it drops the manifests and sets schema version 2, after which a 2.8 vci reads the store as too new and fails open; it is refused with the count while a format-1 row exists, counted by each value's first byte and never by a column (a 2.8 writer leaves the column stale), and on libSQL this release ends the migration at gate 3: --finish at zero prints vci: error: the libsql store does not support finishing the format-3 upgrade (dropping the manifests table and the change log): a later vci does it and exits 2 (the count is still read by running it), the namespace stays at schema version 1, a 2.8 writer is never turned away (rotate the Turso write token, or vci update on every machine that writes), and each 2.8 write re-opens the manifest-collection path (every row read by prune and supersession, the superseded-manifest statements in every batch) until a 2.9 vci prune or supersession removes its manifest; libSQL's --finish lands in 2.10. (5) git-meta has no schema: a 2.8 clone reads the keys it knows, ignores the object keys, deletes nothing it cannot parse. (6) Existing records keep working until they expire or are superseded; nothing is re-signed. (7) A 2.9 libSQL replica syncs frames and deletions by a per-transaction counter and asks its server for objects and columns (a replica cut off from its server verifies no format-3 record); a replica file 2.8 synced is synced in full once; a 2.8 replica never sees a format-3 deletion and keeps superseded rows until their signed expiry, so upgrade it with the rest. (8) Plan JSON and vci explain keep their keys, targets and check names, by.metaKey byte for byte. (9) Rollback: 2.9 with record_format = 1 writes what 2.8 writes; after --finish a downgrade is not supported.
From the whole-branch review. vci verify takes the run's shape object by its id recomputed over the bytes the store returned, as the plan and the hdr and tables already were: another well-formed shape filed under the descriptor's shape id is manifest: INVALID: the shape object <id> is corrupt (its BLAKE3 is <found>), exit 1, instead of a resolved set from the planted bytes. vci run's supersession reads every record of a unit while the store still holds an input manifest, as vci prune reads every row then, instead of pre-filtering by columns a 2.8 upsert can have left stale beside its format-1 value (a missed deletion, never a wrong one). vci prune ends with run `vci push` to publish the deletions on git-meta alone, the one store with something to publish. The keyless side of the plan's signer-segment check (the id built from the certificate against the one vci run --oidc wrote from the token's claims) has tests of its own, with a certificate carrying a deployment environment; and a vci 2.8 binary on a libSQL namespace 2.9 wrote, remote and through a replica, is tested as the SQLite file and the git-meta store were.
Stopping a command on Linux. When a [store] token_command runs past its deadline, or a Rails file runs past its own, vci stops the command's process group with kill -KILL -- -<pgid>. Before, it passed -<pgid> without --, which the procps kill of Linux distributions misreads: on Ubuntu 22.04 it killed vci's own process group, and on Ubuntu 24.04 it killed vci's group as well or nothing at all, leaving a wrapper script's child running. macOS read it as meant. In a container whose PID 1 never reaps, a stopped child that is a zombie now counts as ended.
2.8.2 ships inside 2.9.0. The libSQL store changes listed under 2.8.2 below (transactions of about a megabyte sent one after the other, a transaction out of time sent again and applied once, a bounded time for writes that fail, a plan's values read in requests sized from the answers, hint writes that wait at most 5 s, [store] compress = "zstd" for format-1 values, superseded manifests removed with their attestations) were not released on their own; 2.9.0 is the first release that carries them. A format-3 frame is never stored compressed, whatever compress says: its first byte is what the server-side count and the replica's sync read, and it is under 2 KB of mostly base64.
2.8.2
Never released on its own: its changes ship inside 2.9.0. vex moved its attestations to Turso on 27 October at 14:40 UTC (vci 2.8.1). From its CI logs since then: vci run's "check, sign and store" took a median 53 s against 1.4 s on git-meta. Most of that was writes: a run sent its attestations in requests of 8 MB (about 200 units with their manifests), each taking about 15 s to commit even alone and holding the database's one write lock meanwhile; the small writes of other jobs (flags, memo, a shared manifest) queued 10 to 30 s behind them, and 44 of 207 Rails jobs had a request pass the 30 s deadline, after which the store refused every later call of the process ("failed earlier in this process; not retried") and about 800 attestations per job were lost: 39% of units were not attested, against 2% on git-meta, so later pull requests ran those files again. The plan read too much too: one request of 1,600 to 2,000 values (60 to 85 MB) that took 2 to 22 s, then a second for each unit's newest candidates, and one request out of time made the whole plan run every unit.
Short write transactions. vci run now writes a project's units in transactions of at most 1 MiB of values or 64 attestations, one right after the other, each holding the lock for one to a few seconds rather than 15 to 37 s on a quiet database, made half as large (down to 256 KiB) after one ran out of time and twice as large again while they commit fast (a review of a draft that halved them after any slow one found that most of such time is a wait a smaller transaction pays again, another writer's lock or the link, so it only made more transactions); a unit's attestation, its input manifest and the superseded attestation it removes always go in the same transaction, also when 4 MiB queued are written early (a draft of 2.8.2 could cut a unit in two there, and a refused write could then leave an attestation stored without its manifest, or remove the attestation a lost one superseded). From a laptop to vex's database, a transaction of 1 MB committed in 1.8 to 3.7 s, of 8 MB in 33.6 to 36.8 s. Shorter transactions do not stop another job's small write from waiting behind every writer's next one: beside three processes writing at once, a manifest put still waited up to 25 s and a hint was sometimes dropped at its 5 s deadline (see Measured).
A write that fails costs its own units, and a down server a bounded time. A transaction that runs out of time (or waited for the lock until then) first asks the server whether it was stored (one stored is not sent again; the question gets a quarter of the timeout, at most 5 s, so a server that answers nothing still leaves time to send the transaction again), then is sent again as it was after a short randomized pause. The question cannot see a transaction still waiting for the lock, which may commit after it (on vex's database two did, 30 s late, and a draft of 2.8.2 then wrote their 18 units twice), so each transaction carries a nonce in the change log and begins with a check of it: whichever copy the server applies second is refused, and the transaction is applied once. One the server refuses is split down to the unit it refuses. Only the units whose transaction still fails are not attested (signing or storing failed: storing the batch: …); every other unit of the run is attested and reported, where 2.8.1 refused the whole batch. A process spends at most two request timeouts in all (60 s at the default) on writes that fail and the pauses between them; after that the store stops for the rest of the process, so a server that takes no writes is given up in about a minute, not once per transaction, and a server that cannot be reached is still given up at once. A process also spends a bounded time on writes in all, those that succeed included: six request timeouts, plus one for each 4 MiB of values its transactions stored (180 s at the default, and 7.5 s more for each MiB stored), so a server that takes writes slowly costs a job a bounded time while one that keeps storing finishes a Rails job's writes (a draft's flat six timeouts cut short three Rails-sized writers in every round on Turso, a quiet one included). Then the units it has not sent are not attested and run again next time, removing unreferenced input manifests after the run is skipped with a warning, and nothing else stops; a transaction out of time that is to be sent again is still sent, since it may have committed late (the draft reported such units lost while the server held them). A unit reported not attested may still have been stored, when its transaction ran out of time, was given up and committed afterwards; the next plan finds it. The store crate's batches say which writes they lost (WriteBatch::finish_partial).
A slow answer costs one more try, not the run. A request whose deadline runs out, once the server has answered the run, is sent once more on a new connection with a deadline of its own; only the same request out of time again stops the store, as a server that cannot be reached does at once.
Reads in requests sized from the answers. A plan reads its values 100 to a request at first (about 4 MB of vex's), as many at once as min(VCI_JOBS, 4), then in requests sized from how long the answers took beyond the wait every answer has (the run's shortest answer; each aimed at 15% of the deadline beyond it, 25 to 400 values), fewer at once when one takes more than half the deadline: on a link with bandwidth to spare few requests go, on a slow one small ones. Each request brings the input manifests and base manifests its values need, but the bases already read. The statements are described to the server ahead, while the plan does other work. A request out of time is read again in halves, a half answered counting even when the other is not; one that fails anyway costs only its own units, which run. The whole read is given two deadlines, one more for each 800 values beyond 1,600, and once a read ran out of time the plan's later reads of values end when it did, so a slow link costs a plan one read's time. On the libSQL store's remote mode (a new Caps::remote_reads) the plan's first round reads only the candidate it checks first for each unit, and after an inputs failure of the remembered one only the candidates issued after it.
Hints are best effort. A flag already set sends nothing, and a plan's memo writes only the entries the server does not hold, so a second plan of the same tree writes none (each vex plan rewrote about 1,900). Such a write waits at most 5 s behind other writers, is never sent again, and never stops the store, not even when the server cannot be reached. 2.8.1 waited for the native-library flag inside the check every verdict thread waits on, up to 30 s, and then stopped the store.
Compressed values. [store] compress = "zstd" in vci.toml, or VCI_STORE_COMPRESS=zstd (which wins; off turns it off for one command), stores each attestation and input manifest as a zstd frame, the schema's encoding 1: about 3x smaller on vex's, and about two thirds of their size on the wire (sent as hex). Every vci from 2.8.2 reads both forms, and the signature and every check cover the bytes as they were; 2.8.1 and earlier read a compressed value as one they cannot read and run its unit, never skipping it wrongly, and refuse a vci.toml that sets [store] compress, so turn it on once every machine that plans from the database runs 2.8.2. A replica file (VCI_STORE_SYNC=replica) that 2.8.1 synced while such values were written left them out; 2.8.2 syncs a file 2.8.1 synced in full once (deleting <file>.sync forces that with any version). The schema stays version 1.
Less left behind. A write that replaces or removes an attestation now removes, in the same transaction, the input manifest only that attestation referenced, once it was stored more than an hour before: 90% of vex's manifests (730 MB of 806 MB on 27 October) were such orphans, waiting for vci prune, which still removes those left from earlier. The timing lines say how many megabytes each read and write moved, and how many transactions a write took and its longest.
Measured on 28 October between 04:00 and 05:05 UTC, from a laptop in California to vex's Turso database in aws-eu-west-1 (about 0.65 s for a trivial request on a new connection), while vex's CI, all on 2.8.1, wrote to the same database (about 1,400 attestations every ten minutes). The numbers in drafts of these notes came from earlier drafts of 2.8.2 on 27 October and are superseded.
Writes, replayed into a scratch namespace seeded on the server with 1,200 of vex's attestations and their manifests: each unit one of vex's real attestations (21.2 kB on average) and input manifests (15.2 kB) under a new test id, 500 units from one process or 350 from each of three at once, the versions in turn, each at its own time (so these rows compare loads as much as versions: see the interleaved rounds below):
| writers | vci | per process | longest request | attestations lost |
|---|---|---|---|---|
| one, three runs | 2.8.1 | 30.0 s, 45.6 s, about 30 s | 8 MB, out of time at 30 s in two runs | 500 of 500 in two runs, 0 in one |
| one, two runs | 2.8.2 | 145.0 s, 213.9 s (19 transactions) | 25.6 s, 26.4 s | 0 |
| one, two runs | 2.8.2, zstd | 19.5 s, 30.7 s (11 to 12 transactions) | 2.6 s, 8.1 s | 0 |
| three at once | 2.8.1 | 30.0, 30.0, 50.5 s | out of time at 30 s in each | 1,050 of 1,050 |
| three at once | 2.8.2 | 95.1, 119.9, 132.8 s | 25.9 s | 0 |
| three at once | 2.8.2, zstd | 28.6, 30.1, 37.8 s | 9.9 s | 0 |
The time a transaction took swung with vex's CI's own 8 MB writes (in one 2.8.2 run the first 4.2 MB took 82.6 s and the last 4.9 s); none of 2.8.2's ran out of time. 2.8.1's requests that ran out of time were often applied after the client gave up, but vci run cannot know that and reports the units as not attested. A one-row write every 1.2 s beside one writer took 0.7 to 18.7 s beside 2.8.1 (5 of 7 over 2 s), and once 56 s while the server applied a request 2.8.1 had given up on; beside 2.8.2, 0.7 to 16.9 s, 22 of 27 under 2.2 s. A flag set 4 s into the writes took 0.2 s beside 2.8.1, whose first 8 MB were still on their way, and was dropped at its 5 s deadline beside 2.8.2's stream of 1 MB transactions, as hints are; a manifest put then took 0.5 to 0.8 s and 1.0 to 10.9 s.
The review of these notes found that the compressed and uncompressed rows above came from different times (04:26 and 04:29) and that its own interleaved runs flipped the order, so the writes were measured again in interleaved rounds, after the review's fixes, on 28 October between 07:07 and 07:27 UTC: three processes at once, 100 of vex's units each, into a fresh scratch namespace, the configurations one after the other within each round. vex's CI (2.8.1) committed 0 to 2,073 attestations a minute meanwhile, in 8 MB requests; seconds per process:
| round (UTC), vex's CI a minute | 2.8.2 | 2.8.2, zstd | 2.8.1 |
|---|---|---|---|
| 07:07-07:09, 4 to 17 | 10.3, 16.4, 24.2 | 20.2, 20.2, 21.1 | 7.2, 5.1, 12.5 |
| 07:09-07:12, 7 to 369 | 98.4, 34.5, 80.7 (two transactions out of time, sent again) | 8.2, 10.9, 9.8 | 28.0, 23.3, 18.7 |
| 07:12-07:17, 4 to 909 | 16.3, 15.2, 17.7 | 95.4, 95.6, 104.9 (one out of time in each, sent again) | 10.3, then 30.0 and 30.0: two processes lost all 100 of their units each |
| 07:18-07:21, 2 to 2,073 | 61.5 s each: two transactions out of time in each, the minute's retry time spent, all 300 reported lost (80 committed after the client gave up) | ||
| 07:21-07:24, 2,027 to 2,073 | 127.8, 128.4, 132.4 (two out of time in each, sent again) | ||
| 07:24-07:26, 784 to 847 | 12.4, 16.1, 14.5 |
At 100 units a process the order of compressed and uncompressed flips from round to round: the time is the load's, not the encoding's, so at that size compression is a smaller database, not faster writes (at a Rails job's size it is faster too: below). On a quiet database 2.8.2 wrote slower per process than 2.8.1 (10 to 24 s against 5 to 13 s), and in the review's rounds of three processes of 150 units it took 84.5 to 88.3 s and then 15.6 to 30.9 s against 2.8.1's 23.3 to 35.4 s; what it gains is the units it no longer loses. When the whole database stalls (every transaction of every process out of time at the same 30.7 s, and a one-row write beside them waiting 19 to 47 s), 2.8.2 gives up within its minute of retries like 2.8.1, and its timed-out transactions may still commit. Across these rounds the draft builds sent at least 7 transactions again and the server applied 130 of their units twice; 2.8.2 sent at least 15 again and applied none twice. A small write beside the three writers took, for the manifest put, 0.5 to 24.7 s beside 2.8.2 (a draft: 1.1 to 17.5 s; 2.8.1: 4.1 to 30.3 s), and the flag was dropped at its 5 s deadline in 1 of 6 runs (the draft 1 of 3); one-row writes every half second took 0.7 to 46.9 s beside 2.8.2 and 0.7 to 44.9 s beside a draft without the pause between transactions. The pause had no effect that this load lets one see and cost a lone writer about 10% (11.1 to 11.5 s against 10.1 to 10.2 s behind a local proxy), so it is gone; small writes still wait behind every writer: that is the next thing to fix (STORE-BACKENDS.md, section 21, item 19).
The third review measured Rails-sized writes and found that the time spent on writes that succeed had no bound, that the question after a deadline could use up the whole retry time, and that the pause cost time for nothing; with its fixes (a draft with a flat bound of six timeouts and transactions halved after any slow one), on 28 October between 08:27 and 08:42 UTC, three processes at once wrote 800 of vex's units each (28 to 31 MB, a Rails job's), seconds of writes per process:
| round (UTC), vex's CI a minute | the third review's draft | the second's | 2.8.1 |
|---|---|---|---|
| 08:27-08:31, 1 to 2 | 99.4, 104.0, 130.5 (longest transaction 13.5 s) | all three out of time at 30 s: 436 of 2,400 units stored | |
| 08:33-08:42, 2, then 659 to 683 | 143.5, 143.7, 155.6 (longest 10.0 s) | 200.4, 217.2, and 310.5 with two transactions out of time at 30.7 s: 449 of its 800 stored |
The draft lost nothing there and no transaction of it ran out of time, and a manifest put 20 s into the writes took 1.7 to 2.4 s beside it (11.3 s beside 2.8.1). The fourth review ran the same draft, units and sizes in five more rounds between 08:52 and 09:28 UTC, each run into a fresh scratch namespace, and found those two rounds the lucky end: seconds of writes per process, units lost of 800 each:
| round (UTC), vex's CI a minute | uncompressed | zstd |
|---|---|---|
| 08:52-08:57, 2 to 14, no Rails job writing | 187.1, 189.4, 188.0; 0, 20 and 200 lost to the bound; one transaction out of time (30.7 s) in each | 58.5, 72.9, 63.9; none lost, longest transaction 6.4 to 7.5 s |
| 08:58-09:04, 9 to 14, Rails jobs running | transactions out of time, the minute of retries spent: 600 of 2,400 stored | each namespace's first transaction (creating its tables) 60.2 s: none stored |
| 09:04-09:12, 9 to 928 | 205.1, 205.0, 204.8; 294, 410 and 378 lost to the bound | 190.9, 190.7, 191.4; 412, 401 and 472 lost to the bound |
| 09:12-09:17, 928 to 1,607 | every first transaction 60.2 s: none stored | 143, 143 and 83 s, then the minute of retries spent: 441 of 2,400 stored |
| 09:21-09:28, 2 to 621 | 187.6, 187.5, 187.7; 678, 660 and 690 lost to the bound | 155.2, 180.4, 161.2; none lost (one transaction out of time in each) |
Uncompressed, the flat bound cut every round short in which the server took writes at all, a quiet one included; that is why the bound now grows with what a process stores. At this size compression writes faster too (58 to 73 s against 187 to 189 s on the quiet database), unlike at 100 units. One-row writes every half second beside the three writers took a median of 0.8 to 12.2 s and at most 30 to 50 s, a manifest put 20 s in 1.0 to 16.0 s, and a transaction of about 1 MB 15 to 26 s uncompressed without running out of time (1.8 to 3.7 s alone). vex's CI's rate in its change log understates its load: a 2.8.1 transaction that fails never reaches the log, so the last round, which logged 2 a minute while uncompressed, was among the most contended. A Rails job's writes take three minutes and more from a laptop when three jobs write at once (vex's runners not measured), against a median of 53 s for 2.8.1, which lost 39% of its units.
With the fixes of that review (the bound growing with what is stored, transactions halved only after one runs out of time), the same three uncompressed writers on 28 October at 09:39 UTC, with vex's CI logging 14 attestations a minute and then none, spent 151.5, 147.7 and 142.2 s on writes, lost nothing, and no transaction ran out of time (34 to 37 transactions, the longest 9.3 to 13.2 s); one-row writes beside them took a median of 0.96 s and at most 9.9 s, the manifest put 3.2 s and the flag 1.9 s. That round stayed under 180 s, so it shows the build sound on Turso at this size, not the larger bound at work (by the end each process had earned about 7.5 s for each of its 28 to 31 MB). One round on a quieter database than most of the above; vex's runners not measured.
Reads, a plan of vex-api's 1,992 Rails units with every write refused (the laptop cannot run vex's Rails checks, so this is the plan's first round, where its reads are), runs of the two versions interleaved:
| where | vci | plan, median (range) | read |
|---|---|---|---|
| vex's database, six runs each | 2.8.1 | 13.2 s (12.1-16.4) | about 99 MB in 2 requests of values |
| vex's database, six runs each | 2.8.2 | 7.3 s (6.5-10.1) | 63.5 MB in 8 or 9 (9 to 11 in a later set) |
| a local copy, each answer held 1 s, five runs each | 2.8.1 | 13.5 s (10.6-16.6) | |
| a local copy, each answer held 1 s, five runs each | 2.8.2 | 9.1 s (8.4-9.9) | in two rounds of requests |
| a local copy at 2 MB/s, two runs each | 2.8.1 | 31.2-31.5 s | nothing: its one request ran out of time, and every unit would run |
| a local copy at 2 MB/s, two runs each | 2.8.2 | 43.9-47.7 s | every value, in 37 or 38 requests |
A later interleaved set on vex's database (09:18 to 09:21 UTC, five runs each) gave 2.8.2 a median of 7.5 s (6.6 to 11.6) and 2.8.1 18.3 s (15.4 to 34.3), 2.8.1 slower under that hour's load.
The review of a draft had found 2.8.2 slower than 2.8.1 with each answer held 1 s (16.5 to 17.6 s against 11 to 14 s); across all 18 runs of 2.8.1 that morning with the answers held 1 s, its plan took 10.5 to 16.6 s. See STORE-BACKENDS.md, section 18, for the conditions and every run.
2.8.1
27 October 2026. On vex's CI a Go job ran its tests for minutes against a Turso database, then stored its attestations: the first write failed with libsql store unavailable: cannot reach …: Hrana: `http error: `connection closed before message completed``, every later one with (failed earlier in this process; not retried), and the job failed. The store keeps its HTTP connection to the server open between requests, and it had been closed while it sat idle; the store finds that out only when the next request has gone out on it, and 2.8.0 took it for a server it could not reach. Such a request is now sent again on a new connection, at once and then after 100 ms and 500 ms, within its deadline, once the server has answered the store, and only when that fails too does the store give up for the rest of the process. A server that cannot be reached at all (nothing listening, DNS, TLS, or one that accepts a connection and closes it without ever answering) still fails at once, an answer of the server, such as a refused token or a proxy's error page, is never sent again, whatever its text says (unless it was cut off before it arrived whole, which looks like a lost connection), and a read whose answer was cut off part way fails alone, as before. Sending a write again is safe: it is one transaction, and applied twice (the server may have stored it and lost only the answer) it leaves the database as applied once, except that its change log's rows are written twice, which replicas tolerate; another job's write that landed in between, in the time before it was sent again (usually well under a second, at most the request's deadline), can be undone by it, which at worst runs a test again. See STORE-BACKENDS.md, section 18.
When a [store] token_command fails, vci shows nothing it printed, since its output may hold the credential, and said to run the command yourself to see why. Run plainly, a command that works prints the credential into the terminal. The message now ends run the command yourself with its standard output discarded (> /dev/null) to see why (com secrets get vci-store-token --org vex > /dev/null). Only standard output is discarded: the error shows on standard error, and so does anything a wrapper or a pipeline stage logs there, the token included, so run it in your own terminal. The agent skill tells agents not to run it at all.
The command the message names is quoted for a shell (sh, bash and zsh): every argument holding anything but ASCII letters, digits and @%+=:,./-_ is in single quotes. 2.8.0 quoted only whitespace, quotes, backslashes, $ and backquotes, so an argument such as https://vault.example.test/v1/token?name=vci&raw=1, pasted with > /dev/null after it, ended the command at the &: the shell ran it in the background with its standard output on the terminal and printed the token.
Two new pages. Choosing a store compares git-meta, SQLite, PostgreSQL and libSQL, and sets each up: Turso from turso db create to the tokens, the GitHub secrets set from standard input, the workflow, [store] and token_command for laptops and agents, vci store info, the first runs, replicas and namespaces, and what each error means. It also says what vci cannot hide from a test, and why a job that runs untrusted code gets a read-only token. Benchmarks gathers every measurement with its source and date: vex's Rails jobs in production (including the later reading that puts the plain job at 4.5 minutes, not 3.5), the plan step from 67 s to 15 s, checking, signing and storing a run, the stores side by side, and a pilot on vex's runners against Turso's hosted service, where a plan took 5 to 10 s against 44 s for git-meta's cache and plan steps, and storing about 810 attestations 22 to 42 s, with no push after it, against 43 to 81 s for git-meta's local store and push on the same host class. The agent skill (skills/vci) gained the rules for stores: how to tell which one a repository uses, to check a checkout's [store] before any vci command reads the store (reading it runs token_command and sends its output to [store] url), never to print or commit a store credential, never to run a token command or any other command that reads a secret, even with its output discarded, to run turso db tokens create only piped straight into gh secret set or com secrets set --from-stdin, that SQLite's attestations stay on the machine, when to push, and how to set up Turso when asked.
2.8.0
26 October 2026. vci kept every attestation in git-meta, on refs/meta/main of a git remote, and every command was written against it. The store is now an interface (the vci-store crate: one trait, its records and errors, and a conformance suite of 34 scenarios that every backend passes), every command reaches the store through it, and three more stores ship with it. SQLite (VCI_STORE=sqlite) keeps a repository's attestations in one file, .git/vci-store.sqlite, shared by its worktrees, with nothing to fetch or push: for laptops and single-runner CI with a cache. PostgreSQL (VCI_STORE_URL=postgres://...) keeps them on a server every machine reads and writes at once, one namespace per repository, TLS with verified certificates, every wait bounded and a server that is down costing one connect timeout per command; a plan reads it in about a dozen statements whatever the number of units.
libSQL (VCI_STORE_URL=libsql://... and VCI_STORE_TOKEN) keeps them in a Turso database or a sqld of your own, one set of tables per repository: create a database, set the two variables as CI secrets, run. A plan reads it in a handful of HTTP requests and a project's units are written in one, with nothing to cache, fetch or push; with VCI_STORE_SYNC=replica it reads a local replica synced as each command reads it (only what changed since the last), and a server that cannot be reached leaves the replica readable by the plan and the run. A server that accepts and never answers costs the connect timeout, an error page the client cannot read fails the store instead of the command, a record of 5,000,000 bytes or more (a server's row limit) is refused for its unit alone, and a database that takes no more writes (over its quota, or full) is read on while vci run stores nothing and keeps the tests' exit code. VCI_STORE=libsql without a URL keeps the SQLite store's file under libSQL's name. A self-hosted sqld 0.24.32 can stall for good under several concurrent writers, a bug of its write lock and not of vci (STORE-BACKENDS.md, section 21, item 17).
Choosing a store, and its secrets. The store is chosen from VCI_STORE, else the scheme of VCI_STORE_URL, else [store] in the working tree's vci.toml, which may name a kind, tune it and say where a server is, but never hold a credential, and is never an input; a vci before 2.8.0 rejects a vci.toml with [store], so select a store through the environment until every runner and developer has 2.8.0. VCI_STORE_URL and VCI_STORE_TOKEN are in no environment vci builds for a test: vci removes them from its own environment as it starts, and never prints a password or a token. A test that goes looking can still read them in the environment vci, or the CI step's shell, started with, so a job that runs untrusted pull-request code should get a read-only token or none. Developers export nothing: [store] url names the server (without a credential) and [store] token_command the command that prints its token (["com", "secrets", "get", "vci-store-token", "--org", "vex"], op read …, or a sh -c pipeline), run only when a command first reads the store, once per process, in the repository's top level and without the store's variables; a command that fails makes the store fail open, saying what happened and never what it printed (standard error included: run it yourself to see why), and vci store info names it, never the token. In CI the two secrets win and the command never runs, and a token from the environment is never sent to a URL from vci.toml, which a pull request could change; on a developer's machine the command's token goes to vci.toml's url, so a change to either needs review (VCI_STORE=git-meta ignores both in a checkout not yet reviewed). vci store info says which store a repository uses, every setting and where it came from, what the store can do and how many attestations it holds; the metrics log names the store. A store that cannot be used fails open: the plan runs everything, and vci run refuses to attest with the reason and keeps the tests' exit code.
The publication gate. A shared store is written at once, so on PostgreSQL and a libSQL server vci ci without --push signs but stores nothing: as with git-meta, nothing a CI job attests leaves it unless the job pushes. vci run reports a unit as attested only once the batch holding it is written, and a batch that fails refuses every unit it held, keeping what they would have superseded.
git-meta stays the default, and its output is unchanged, byte for byte, on every end-to-end suite. It is the store 2.7.33 made one per repository: through the trait, too, git-meta opens <common git dir>/git-meta.sqlite, which every worktree shares, and vci store info in a linked worktree names that file. One fix reaches git-meta as well: before its first verdicts a plan reads only a few candidates of each unit and sets the others aside, and a second round reads them for the units it could not skip; that round ended when its fetch found nothing to download, so on a store that already held every value (a fully fetched clone) a unit whose two newest attestations failed while an older one passed ran. The plan now re-reads the candidates it set aside whenever they are here (0.02 to 0.03 s more to read them, 0.16 to 0.21 s for the whole round, on a synthetic store of 600 units that all ran).
Signing and storing a run's attestations got several times faster. An unencrypted ed25519 or ECDSA key file is read once and signs in process, with the signature ssh-keygen -Y sign makes (for ed25519 the same bytes), and a .pub signs through ssh-agent directly; RSA, encrypted and security keys still sign with ssh-keygen, one process per unit, and a signature counts only when it is by the key the signer id names (a private key whose .pub holds another key is refused, as ssh-keygen always refused it). git-meta and SQLite now write a project's units in one transaction, its journal in memory, not a commit per write, and their vci: attested … lines come out once it has committed, in the same order as before. On a synthetic module of 1,200 Go packages, on a 14-core Mac busy with other work, vci run's check, sign and store step took 0.35 s instead of 1.57 s on git-meta, 0.32 s instead of 1.44 s on SQLite, and 0.27 s instead of 0.98 s on PostgreSQL (medians of three runs); the units are still checked one per CPU at a time, since twice as many threads were not faster.
The PostgreSQL support adds about 2.8 MiB to the static Linux binary, and the libSQL support about 4.3 MiB (measured on macOS). See Where attestations live and STORE-BACKENDS.md.
2.7.36
26 October 2026. The Rails collector records everything a test's process reads, and Rails reads its routes, initializers, environment files, migrations and translations while it boots, and Zeitwerk lists every autoload directory: each of them was an input of every test file, so on vex (2,200 test files) a pull request that added a route, an initializer, a migration or any new constant ran the whole suite again. By default those are no longer inputs (rails_inputs = "loose", for Minitest and RSpec projects alike): config/routes.rb, config/routes/**, config/initializers/**, config/environments/**, config/application.rb, config/boot.rb, config/environment.rb, config/puma.rb, config/spring.rb, config.ru, db/migrate/**, config/locales/**, and the autoload lookup records (the collector records lookups instead of autoload directory listings, as policy.rails_autoload_lookups does, and leaves them out), so a new file in app/models runs nothing again. Gemfile.lock, db/schema.rb and db/structure.sql, config/database.yml, test_helper.rb, test/support and every file a test reads after boot (models, services, fixtures, templates, the test file) stay inputs. The trade-off: a change to a boot file no longer runs any test again on a pull request; the default branch's full run (policy.no_skip_refs) runs them all against it. rails_inputs = "strict" records everything, as before, and ignore_inputs = ["glob", ...] adds globs to leave out (in strict mode, the only ones); both go next to adapter or in a project's table, and either, when not the default, is part of the vci:config external, so attestations from the other mode are not mixed in. The filter applies when a unit is recorded: the predicate says how (railsInputs and ignoredInputs), vci run says once per project what it left out, and vci explain shows the base's mode and each candidate's. An attestation made before 2.7.36 records every input and stays usable; it keeps running again on a boot file change until the default branch's next full run attests the unit in loose mode.
2.7.35
2.7.34's merge did not merge on git 2.43, the stock git of Ubuntu 24.04 and of vex's self-hosted runners, so every merge of a fetched tip there still took git-meta's merge of three listed trees. Two reasons, both found by running the store's tests in an Ubuntu 24.04 container: that git's merge-tree takes commits only ("expected commit type"), and it reads the base's blob of a path both sides deleted whenever both sides also added a path, whatever -X no-renames says, and a blobless clone has not fetched it. The tip's tree is now put back to the base's state at every path both sides changed, so each of them is a change on ours' side only (the result is what git-meta's merge gives: ours), through git's index (read-tree, update-index --index-info, write-tree --missing-ok, which take any object id; the gix tree editor refuses an object the clone lacks), and committed on the tip, never pushed, before git merge-tree runs. The store's tests run under git 2.43 as well as the current git.
2.7.34
On vex's runners, 57 of 143 Rails jobs on 25 October spent 8 to 55 s in a git merge-tree that failed with "lazy fetching disabled" and then 10 to 67 s in git-meta's own merge of three listed trees, every time the run's other job had pushed first. Both jobs delete the same superseded attestations and stamp the deletion records differently, so one path differs on both sides; git merges such a path by content, which reads both blobs, and a blobless clone has not fetched the tip's. The merge now writes ours into the tip's tree before git merges, so no path differs on both sides and git merges trees alone, with -X no-renames so it never looks for where a deleted path went (that reads blobs too). A Rails batch opens one slot per piece of work, a file or each shard of a sharded file, instead of one per file: a job that ran 8 files, four of them as 3 to 5 shards, ran its 20 pieces on 8 of its 16 slots. The Rails collector gained a per-process profile (VCI_RAILS_PROFILE=1: requires, routes, GC, process time, one line per process) and measurement switches (VCI_RAILS_EXPERIMENT: eager, routes, warmup, iseq), with which the application's eager loading, a routes preload, Process.warmup and a run-private compile cache were measured on vex's runners and found not to move a file's process beyond noise; they are off and stay as switches. docs/notes-rails-cost.md has the numbers.
2.7.33
25 October 2026. git-meta keeps its store in the git directory of the repository it opens, and a linked worktree (git worktree add) has its own, .git/worktrees/<name>/git-meta.sqlite, while the refs/meta/* refs are shared. So every worktree held a full copy of the metadata and paid its own fetch and merge: on one laptop, 33 of a repository's 136 worktrees had one, 11.4 GB in all (10 MB to 1.1 GB each, median 384 MB), next to the main checkout's 146 MB, and an attestation made in one worktree reached another only through a fetch. A repository now has one store, <common git dir>/git-meta.sqlite: vci opens git-meta on the common git directory, so the main worktree keeps .git/git-meta.sqlite, the path it always had, and every linked worktree uses that file. What vci run stores in one worktree, vci plan in another reads at once, with nothing pushed or fetched; one vci fetch serves them all; vci push from any of them publishes everything. The lock taken while git-meta opens the store moved next to it, in the common git directory, where the exchange lock already was. A worktree's own store from an earlier vci is never deleted: when the repository has no shared store yet and nothing has it open, the first command run there moves it to the common git directory and says so; otherwise it is left unused and vci says once that it can be deleted. On that laptop that is the 11.4 GB.
Keeping vci current meant knowing how it was installed: brew upgrade, the install script again with the right VCI_REF, or a tarball fetched by hand. vci update now does it (vci upgrade too): it reads the latest release from where GitHub's releases/latest redirects (one request, no token), says vci: 2.7.33 is the latest release when there is nothing to do, and otherwise looks at where the running binary is. Under Homebrew's Cellar/vci/ it runs brew update and brew upgrade pandelisz/tap/vci; for the install script's cargo install it runs the clone's scripts/install.sh at the release's tag; a release binary is replaced in place: vci-<target>.tar.gz is downloaded, its SHA-256 checked against SHA256SUMS-<target>, the binary run once to see that it is the release, and renamed over the old one from a temporary file beside it, so a mismatch, a failed download or a directory it cannot write to (it prints the sudo command) leaves the old binary as it was. vci update --check only compares, exiting 0 when current and 1 when there is an update; --version X.Y.Z installs a given release and --keep-old keeps the replaced binary as vci.old.
2.7.32
25 October 2026. Fetches and pushes of one repository take turns (a lock in its common git directory, shared by its worktrees), and a vci that found the lock taken waited in silence, as long as it took: an agent pushing from one worktree waited minutes on another's push without being told. It now says what it is waiting for (vci: waiting for another vci in this repository to finish its fetch or push (pid 1234 vci push ... since ...)), repeats every 30 s, and gives up after VCI_LOCK_TIMEOUT_SECS (600 by default; 0 waits forever) with an error naming the holder. The kept tree listing (2.7.30) was 50,000 rows and was brought up to date by a diff-tree however far behind it was: 8.7 s on a pull request job whose cache came from an evening earlier. It is one blob now, brought up to date only when within 40 commits, else listed anew (0.9 s). A push also marks the metadata commit it writes as complete, so the next plan in that clone has no completeness walk to do. 2.7.31 also added a guard on value fetches: a transfer under 4 KB/s for 30 s is given up and retried once (one fetch of 2,055 manifests took 218 s).
2.7.31
25 October 2026. The store records the metadata commit it is known to cover (2.7.5) and, since 2.7.20, notes every deleted row and deletion record to check that each still has one or the other. Dropping deletion records older than a day (also 2.7.20) deletes records on purpose, and the tree drops them too; the note then found those keys with neither, and the store forgot its coverage: every fetch after a drop checked the whole store again (1.6 s on vex's runners). A record dropped on purpose is no longer noted as lost.
2.7.30
25 October 2026. Four cuts. A vex unit's input manifest is about 2,700 entries shared with the other units of its run (the base manifest, stored once since 2.1.8) and 150 of its own, and the verdicts walked all of them for each of 4,000 candidates (rehashing 36 thread-seconds a plan, case collisions 35, ignored children 32): the base's part of each check is now made once per base and combined with the unit's own entries, the same differences, with the full case-collision check only when an own path could collide with a base path; about 30 thread-seconds a plan now. Every fetch that found master moved listed the fetched tip's tree (git ls-tree -r, 50,000 entries, 0.7-1.8 s): the store keeps the last listing and brings it up to date with git diff-tree (0.2 s). After its first verdicts a plan fetched the values of every other candidate of the units it could not skip (3,000-6,000 values, 5-17 s on a pull request that changes an input of most units): a unit whose remembered attestation (2.7.29) was read and failed on its inputs is not fetched for. And a run's warnings asked git whether each unit's input paths are ignored, one git check-ignore per unit (36 thread-seconds of a full vex run): paths git tracks are never ignored and are no longer asked about. With VCI_TIMING the store phase also reports where storing a manifest spends its time.
2.7.29
24 October 2026. 2.7.27 reads the attestations made on a pull request's base commit's tree first, but master's own runs skip every unit and so attest nothing on master's tree: the attestations that pass for a pull request are the ones master's plan found passing, made on earlier trees, and a plan had no way to know which. A plan now remembers, in the store, which attestation passed for each unit on its tree (vci_memo, a week of trees), and a plan whose base commit (or own commit) has that tree reads those first. Master's plan runs on every push and its cache carries the store, so a pull request's plan finds the hints and the attestations' values already fetched: no download of other branches' newest attestations (6 s), no second round. The hints only order the reading; every candidate is verified as before.
2.7.28
24 October 2026. After a full vex Rails run, publishing took about 80 s. Serializing the run's 8,000 changed entries wrote each blob and each tree on git-meta's deep paths as a loose object through gix, tens of thousands of files (10.5 s on the runner); one git fast-import now writes them as a single pack, gix remaining the fallback. The two jobs of a run delete the same superseded attestations, each stamping the deletion record with its own time, so every push found 2,000 paths changed differently on both sides and fell back to git-meta's merge of three listed trees (6.3 s); deletion records of one key that differ now take ours, as git-meta's merge does, and the merge stays on the fast path. With VCI_TIMING the store phase reports the time of storing manifests and removing superseded attestations separately (16 s of the 24 s check-sign-store phase is that serial loop).
2.7.27
24 October 2026. Pull request plans on vex took 10-62 s where master's took 6-9 s. One of 61 s: the check that the fetched tip's trees are all here diffed it against a commit recorded complete hours earlier, across hundreds of pushes (14 s); it now walks only the objects the tip reaches that a complete ancestor does not. The merge's commit of 15,000 store rows waited 6 s for the runner's disk; on CI (CI set, or VCI_STORE_SYNC=off) the store no longer syncs to disk, which it need not, being a cache there. And the plan fetched each unit's newest attestations first, made by other branches on their own trees (2,000 envelopes and manifests, 8 s), found them failing, and fetched 6,000 more values in a second round (17 s): a plan now reads the attestations made on its base commit's tree first, master's for a pull request, and only then the newest.
2.7.26
24 October 2026. With 2.7.25 a master plan on vex took 8.5 s, and the boot of the application for the native-library check (3.2 s, started beside the fetch since 2.7.22) had become its floor: the plan waited for it before reading its candidates (1.5 s) and starting the verdicts (2.3 s). The boot is now started as a child process and not waited for; the first verdict that compares native libraries waits for what is left of it, while the other verdict threads hash inputs. On a laptop the plan went from 6.8-7.2 s to 5.3 s with master unchanged, and from 11-13 s to 9.5 s after master moved.
2.7.25
24 October 2026. On vex's runners each first scan of the store file took about a second (listing the manifests 1.2 s, the manifest put-back 1.0 s) where a laptop takes 0.06 s: a CI job restores the 150 MB file from its cache and the operating system has none of it in memory yet. A plan now reads the file through in a background thread as it starts, during the fetch's network round trips, so the scans find it in memory.
2.7.24
24 October 2026. Two master plans on 2.7.23 took 9.3 s (master unchanged) and 13.4 s (master moved). Of the latter, listing the store's input manifests took 1.2 s with a substr over every row; a key range on the store's index lists them in 0.06 s. Every CI plan listed the fetched tip's 50,000 keys (0.85 s) although the tip was the commit the job that saved its cache had pushed: a tip equal to the local metadata commit is not listed. A plan reads two of each unit's newest candidates before its first verdict instead of three (6,300 blobs instead of 8,400 on vex); the second round, now one batch, covers the rest.
2.7.23
24 October 2026. With 2.7.22 a master plan on vex took 10.6 s, 3.1 s of it reading the candidates: every fetched attestation of every unit, 18,700 of them (340 MB), when the verdicts check about two per unit, and the scan that found them returned the input manifests' rows too, with their values inline. A plan now reads only the candidates it checks first (the ones chosen for fetching, plus the three newest of each unit, so a unit whose newest attestation was made on another branch still passes on the one before it without a second round), from the attestation rows alone; the second round reads what the units not skipped still need in one batch instead of one unit at a time (9 s for 2,000 units on a laptop). On a laptop the plan takes 4.8-7 s after master moved.
2.7.22
24 October 2026. Two master plans on 2.7.21, everything skipped, took 20 and 28 s. 5.8 s of each was the verdicts, nearly all of it one boot of the Rails application for the native libraries that vex's attestations record (libvips), with every verdict thread waiting on it. A plan that compared native libraries last time now boots the application beside the fetch (the store remembers that it did, so a project whose attestations record none still never boots), and the verdicts find it booted.
The plan read its 4,100 candidates one by one through git cat-file (about 3 s, unlabelled until now); they are listed by key and read in one batch. A fetch read every deletion record of the store (0.4-2.5 s) and listed the store's attestation keys before and after the merge (1.1-1.9 s each): only a push needs the former, and the merge from paths knows what it dropped, so a plan skips both. Putting back manifests read what every attestation names from two scans of the store (0.7-1.4 s); one indexed join answers it now. The metadata commits vci writes itself (a serialization, a merge of complete trees) are recorded complete, so the next fetch does not check them (0.7-2 s). With VCI_TIMING the plan now times reading the base commit's policy, the candidate lookup, the second verdict round and each statement of applying deletion records.
2.7.21
24 October 2026. vex's lockfile moved to Bundler 4.0.23 on 23 October. CI installs that version in the bundle path only, and Bundler switches a process to the locked version by restarting it with the same arguments, which it never does for ruby -e. vci's toolchain probe was a ruby -e script and saw the default Bundler 2.6.9 while the tests (bin/rails test) ran under 4.0.23, so every file was refused ("toolchain seen by the collector differs from the project's"): master attested nothing and no pull request skipped anything. The probes and vci's RSpec runner now run from a script file (the collector is told its path, VCI_SCRIPT, and does not count it as an input), so they resolve Bundler as the tests do. 2.7.21 also carries 2.7.20, which was built but not published.
2.7.20
23 October 2026. vex's metadata tree held 465,000 entries, 431,000 of them deletion records written in the week before (each run supersedes the last run's attestations and manifests), and every fetch, merge and push listed, fetched and checked all of them. A push now drops the deletion records older than a day once there are 20,000 of them (VCI_DELETION_RECORDS_KEEP_SECS, VCI_DELETION_RECORDS_DROP_AT), and another clone's fetch drops them from its store rather than stamping them as new deletions. A record only stops a clone that never saw the deletion from bringing the value back; git's history already deletes it in a merge with any clone that saw the value, and a value that comes back anyway is an expired or superseded attestation or a manifest, which is harmless.
Putting back the manifests a merge deleted although an attestation references them read each manifest with its own git cat-file and opened the store each time (about 44 s on vex's CI plans when master's prune deleted many). They are now read in one batch, and only attestations this clone holds are read for what they reference; one it never fetched did not decide any of its plans. On a laptop a vex plan took 13-15 s after master moved and 3 s when it had not; CI ran 48-72 s on 2.7.18.
A store records the metadata commit it is known to hold (2.7.5), so a fetch can skip checking every entry of it. Any deleted row cleared that record, including the attestations a run supersedes, which git-meta deletes and records as deleted in one step: every store vex's master job cached arrived without it, and each pull request job spent 25 s on the check. A deleted key now voids the record only when it has neither a row nor a deletion record.
2.7.19
23 October 2026. A vex Rails plan took 48 s on CI, most of it re-learning the same things every time: it read 2,000 attestations to find the manifests they name, checked the whole 490,000-object metadata tree for missing subtrees, and listed the fetched tip's 200,000 keys even when that tip had been merged before. The store now remembers what each blob names (a blob never changes), which metadata commits it found complete (a new tip is checked by its difference from one of them, about 0.1 s instead of 1.3-4.9 s), and skips listing a tip it already merged. Putting back manifests a merge deleted reads only the attestations it has not seen. On a laptop the plan went from 10-28 s to about 4 s when the remote has not moved and 2 s without --fetch. With VCI_TIMING, every step of the fetch is now timed.
2.7.18
23 October 2026. 2.7.17 asks whether the remote moved before its pre-push checks; those checks and the fetch of what the pack needs then take about 18 s on vex, long enough for the run's other job to push, and the next probe's push was rejected after sending its pack (26 s). vci now asks again right before git push (one more ls-remote, under a second).
2.7.17
23 October 2026. The two jobs of a vex Rails run finish within seconds of each other, so one of them usually finds that the other has pushed and merges again before its own push. 2.7.1 checks for that with one ls-remote, but only after the round had checked what the push deletes and sends (8 s) and fetched what the pack needs (10 s), all wasted when the tip had moved. The check now comes first.
2.7.16
23 October 2026. 2.7.15 took storing a full vex Rails run's attestations from 42 s to 23 s. The batch itself still takes about 270 s against about 170 s for Rails' own parallel workers. With VCI_TIMING, the batch's log now gives the time a file's tests took by the test framework's own clock next to its process's time, so the time spent outside the tests (the process's start, the application's per-process setup, recording) is visible.
2.7.15
23 October 2026. A full vex Rails run spent 33 s storing its 1,018 attestations, one unit at a time: each unit's manifest, its attestation and the removal of the ones it supersedes each opened a git-meta session (the repository and the SQLite store, under a file lock) and a second store connection to stamp the write, and storing the attestation read every earlier attestation of the unit through a new git cat-file process, though only their keys were used. A run's writes now share one session and one connection, and the earlier attestations are found by their keys alone.
2.7.14
23 October 2026. 2.7.13 showed that building the lines takes 0.37 s of the 0.40 s a vex file's process spends on its output. With VCI_TIMING, the batch's log now splits that into its parts: the recorded paths, folding the load path's absent paths, the database, the gems' files, externals and environment, the checks and the result.
2.7.13
23 October 2026. A full vex Rails run's batch takes about 270 s against about 180 s for the same files in Rails' own parallel workers; writing each file's recorded inputs takes 0.41 s of a file's 3.8 s. With VCI_TIMING, the batch's log now splits that into building the lines, splitting off what the boot shares, and writing them.
2.7.12
23 October 2026. 2.7.11's fast merge took a full vex Rails run's merge from 18 s to 2 s. Before each push vci also checks that the push deletes no value of the remote's tip and that every value it sends is here; both checks listed every key of the local metadata commit, about 3.5 s each on vex's tree. The keys are now listed once for both.
2.7.11
23 October 2026. On a full vex Rails run, 2.7.10's fast merge still fell back to git-meta's (18 s): the run's two jobs had both deleted some of the same superseded entries, writing identical deletion records, so the same paths changed on both sides. A path both sides changed to the same blob is no conflict, and git merge-tree merges it cleanly; only a path left different on the two sides now takes git-meta's merge. With VCI_TIMING, vci says why it fell back.
2.7.10
23 October 2026. Before a push, vci merges what other jobs published into its own metadata commit. That merge read every key of three trees (the merge base, this commit and the remote's tip, about 180,000 entries each on vex), merged them in memory and wrote the whole tree again: 18 s each push round of a full Rails run, though only a few thousand keys differed. When no path changed on both sides since the merge base, git now builds the merged tree (git merge-tree --write-tree), touching only the changed subtrees. git-meta's merge gives the same result there: a key only one side touched takes that side's state. When any path changed on both sides, or git cannot merge, git-meta's merge runs as before.
2.7.9
23 October 2026. 2.7.8's timings showed a vex plan spending 11.6 s finding the base manifests of the 2,035 manifests it reads, and its verdicts about 20 thread-seconds reading manifests. Each lookup of a manifest asked the store for the project row with that key without giving the project target's (empty) value, so SQLite could use only the first column of its index and scanned every project row, about 13,000 on vex, for each lookup. The lookup now names the empty target value, so the index finds the row directly (the old query still runs if that finds nothing). A merge of fetched metadata applies its deletion records in one transaction: git-meta applies each in a commit of its own, and the same plan's merge spent 8.4 s on 6,314 records and 2,212 deletions. vci runs git-meta's own statements, in the same order, on its connection to the store.
2.7.8
23 October 2026. A vex plan spent 23 s fetching the stored values it reads, about 5 of them downloading. To choose which attestations of each unit to fetch, it read the stored value of every candidate it had (through git cat-file), though the choice needs only the keys; then, to find the chosen ones' manifests, it read them all again. Choosing now reads no values, and finding the manifests reads only the chosen attestations. With VCI_TIMING, that step and a merge of fetched metadata (applying deletion records, recording the merge) report each of their parts.
2.7.7
23 October 2026. 2.7.5's record of the commit a store covers was lost at every merge of fetched metadata (which moves the local commit), so the push after a fetch still ran the full coverage check (4.6 s on vex). A merge of the tree's paths writes every entry the merged commit has that the local one did not into the store, as a row or a deletion record, and shares the rest with the local commit: a store that covered the local commit is recorded as covering the merged one.
2.7.6
23 October 2026. A vex pull request job's plan spent 24.5 s of its 109 s completing the fetched tip's tree: vci checked the tree with git ls-tree -r -d, which on git before 2.44 asks the promisor remote for any subtree that is not here, a request that fails (vci's promisor has no URL) after about 11 s, before and again after fetching the missing subtrees. The check now lists the missing trees with git rev-list --missing=print, which never asks, and fetches exactly those by id until none is missing.
2.7.5
23 October 2026. Every fetch checks that the store holds every value of the local metadata commit, or a deletion record for it, before git-meta serializes the store onto that commit (a new, emptied or linked worktree's store would otherwise delete values for everyone). The check reads every row of the store and every key of the commit: 2 to 8 s a fetch on vex's runners, 26 s in one job, every time finding nothing to restore. The store now records the commit it is known to cover, in its own SQLite file: after a full check, and after a serialization of a store that covered the previous commit. A fetch whose local commit is that one skips the check. Triggers on the store clear the record whenever a row or a deletion record is deleted, the only ways a store stops covering a commit, and a new or replaced store has no record, so those still get the full check.
2.7.4
23 October 2026. A vex pull request job that ran 50 of 2,051 Rails files (54 s of tests) took 334 s in all, about 170 of them in three push rounds of which each spent about 23 s merging the remote's metadata, whether 27 entries had changed or thousands. With VCI_TIMING, that merge now reports each part: the merge commit, listing the merged tree's keys, comparing the trees, reading the stored deletion records, fetching changed ones and marking the changed values.
2.7.3
23 October 2026. 2.7.1 skips a push onto a tip the remote has moved past and merges again instead. On a busy evening a vex job did that eight times in ten attempts: each round's fetch and merge takes 40 to 75 s, other jobs pushed more often than that, and after ten rounds, with two pushes in all, the job gave up (still rejected after 10 attempts). A round that merged again without pushing is no longer an attempt; after 30 of them vci pushes regardless. Ten attempts still means ten pushes.
2.7.2
22 October 2026. vex's master attest of its 2,033 Rails files printed "running 2033 test file(s)" and then nothing for 75 minutes, until the step's timeout ended it: a test process that never finished holds its slot, and the batch waits for every slot. The pull request jobs, about 1,000 files each, run in about 5 minutes. A file's process now has a limit, VCI_RAILS_FILE_TIMEOUT seconds (default 600, 0: none), in a batch's slot (a watchdog thread in the slot names a file still running after two minutes and stops it at the limit) and when it runs on its own (vci stops its process group). A stopped file fails, is not attested and is not run again on its own, where it would hang the same way; the batch's other files finish and are attested.
2.7.1
22 October 2026. Two costs in vex's Rails jobs, after a week in which a job with vci took a median 6.7 min against 4.2 without. A push the remote was going to reject took about 70 s, one that landed 7 s: when another job pushed after this one's fetch, git does not have the remote's new tip, cannot use it as the common base, and sends the whole metadata history before GitHub refuses the update. With two partitions and master's attest job finishing together, the jobs spent minutes in such attempts, and one ran out its time. Now vci asks the remote for its tip (one ls-remote) right before pushing and, when it moved, merges onto the new one at once. A job repacked the whole repository before pushing (17 to 19 s on git 2.43): it starts with a restored store's packs and adds one per value fetch, so it crossed the 16 packs at which vci merges them. That limit is now 24, still clear of the 32 pack slots gix opens a repository with.
2.7.0
22 October 2026. Three causes of vex's Rails jobs running everything. vci.toml was a file input of every unit, so any edit to it (a comment, a policy line, a new project) ran every unit of every project again: vex's two policy changes and their revert each did. A unit now records its project's settings as one external, vci:config: the project's directory, adapter and adapter settings, effective [env] and [[inputs]]. The policy is not part of it (it is read from the base commit when planning), nor are comments or other projects. Attestations made before 2.7.0 still verify as before (they hash the file); the ones made from now on stop depending on it. A project whose directory the checkout lacks (a branch from before the base's config added it) stopped vci plan, ci and run with "does not exist" and ran everything of every project; it is now left out with a warning. A store restored from a CI cache whose local metadata commit names trees the cache lacks failed every fetch and push (vex, Oct 21-22: 40 jobs in a row, could not fetch <tree> from promisor remote, so no pull request attestation landed for 18 hours and every run ran everything): a fetch now first fetches the local commit's missing trees by id from the remote, which has them once that commit was pushed.
2.6.5
20 October 2026. The cause of the push failures 2.6.3 and 2.6.4 chased. vex's CI pushes over https, and git's https helper always builds a thin pack: it does not accept the thin option, so git push --no-thin is ignored there (it applies over ssh and to local remotes, which is why no copy of the failure reproduced). A thin pack's delta search takes, for each path the push modifies, the remote tip's object at that path as a base, and pack-objects first fetches every such base the clone lacks, from the promisor, which vci's has no URL for, and stops on the first. A full run rewrites a few thousand deletion records the tip already held, records other jobs wrote and this blobless clone never downloaded: each attempt died on the next one in path order, the fallback fetched that one, and ten attempts went by. A copy of a failing CI job's object store reproduces it on git 2.43 with pack-objects --thin and passes without it. Now the tip's blobs at every path the push modifies are fetched with what the push sends, in one batch, before git builds the pack.
2.6.4
20 October 2026. Two findings from vex's first runs with rails_autoload_lookups. Checking a unit's inputs took 1,061 thread-seconds a job (22 to 28 before): every name a test looked up was compared with every child of every autoload directory, for each unit. The children are now listed and normalized once per manifest into one index, and each name is one lookup; the hashes are the same as before, so attestations made with 2.6.x still match. 2.6.3 did not make the pushes go through: git's own listing of what the pack needs (rev-list --not <tip>) found nothing missing, yet git push still asked for a deletion record the tip holds, as in 2.1.17's sparse-walk failures (that walk is still off). The same steps on git 2.43, against a copy of vex's metadata and with full code clones, push fine. A push that fails this way now also reports what the remote says its tip is, whether that commit is here, whether it is the tip this round merged onto and an ancestor of the pushed commit, and how many objects git would send against it; and with VCI_TIMING every failed attempt's diagnosis is printed (it was kept only for the last).
2.6.3
20 October 2026. The first vex pull request run after its policy changed (every Rails test ran again, as the environment lists changed) could not publish: both jobs failed ten push attempts, about ten minutes each, every attempt on another deletion record of an input manifest (could not fetch <blob> from promisor remote), records the remote's tip already held and this clone had never downloaded. On the first such failure vci fetched the deletion records of the pushed tree it lacked (2.2.9); that did not make the push go through, and every later attempt replaced one record with a new one. Now the first such failure fetches every object the pack needs that the clone lacks, as git itself lists them (git rev-list --objects --missing=print <pushed> --not <tip>, which never fetches), by id, in batches, until none is missing, and tries again. The diagnosis of an unreadable object no longer runs git cat-file -t, which on git before 2.44 asked the promisor for it: 18 s each attempt.
2.6.2
20 October 2026. A bug report from a laptop: vci plan printed its first timings and then nothing for 20 minutes, starting a new git cat-file --batch every few seconds, each living about 3 s; on 2.1.18 a vci push did the same for 46 minutes while holding .git/git-meta.vci-exchange.lock, so every other worktree's vci queued behind it. The repository had 1.88 million objects in 590 MB of packs, about 255 worktrees on one .git, git 2.39, and some interrupted fetches and a git fetch --refetch of refs/meta/main behind it. The store had values whose blobs the object database no longer held. vci reads stored values through one git cat-file --batch and started a new one after any error, also after git had only answered "missing". Git before 2.44 is worse: asked about an object the clone lacks, it tries the promisor remote, which vci's has no URL for, and exits. Every start reads the pack indexes (about 3 s there), once per missing value. Now "missing" keeps the process, and once a process has died, vci finds which stored values the object database holds with one git rev-list --missing=print (which never fetches, on any git) and fails the others at once, without starting git cat-file. Their units run (a value that cannot be read never skips anything). Reads of several values at once ask only for those present. 2.6.1 was built but never published (GitHub cancelled its macOS x86 build while it waited for a runner); 2.6.2 carries its change.
2.6.1
20 October 2026. 2.6.0's rails_autoload_lookups left one way of asking for a constant unseen: defined?(Constant), which Ruby answers without any method vci can hook. vex's own code asks that 26 times, among them defined?(::Agent::Swarms::LandingHolds), a check for a class a later change may add: with 2.6.0, adding it would not have run the tests whose behaviour it changes. The constant paths after defined? in every repository Ruby file a test loaded now count as names it looked up (each file read once per run). What remains unseen is a gem's own code asking that about an application constant.
2.6.0
20 October 2026. Of 38 recent vex pull request runs, 27 ran every Rails test. In 22 of them the first input that differed was the listing of an autoload directory (app/filters/api/v1, app/resources, app/models): Zeitwerk lists those directories while the application boots, so every test's attestation names them, and any file added there (by the change, or on master since the attestations were made) runs everything again. With rails_autoload_lookups = true in the base commit's [policy], such a listing is no longer an input. vci records instead the constant names each test's process looked up without loading them: a miss (const_missing, which a reference, const_get and constantize reach), and const_defined? answering false, or true for an autoload not loaded yet. A test then runs again only when an autoload directory gains, loses or renames a file or directory of one of those names. Names compare without case or underscores, in every autoload directory, whatever namespace asked, so a lookup from inside a controller that would find a new top-level model counts. A namespace whose constants are enumerated keeps its listings. In the test app, a new model no test asked for runs nothing again, and one a test asked const_defined? about runs that test, alone. It stays opt-in because Ruby answers defined?(Constant) without any method vci can hook: a test that asks that way may pass without having seen a constant a new file defines. The base commit's policy must allow it for a plan to accept such attestations.
2.5.0
20 October 2026. Until now, how long vci's steps take was printed only when VCI_TIMING was set, and only to that run's terminal: a slow run on a laptop (an agent's vci run "validating collected inputs before signing") left nothing to look at afterwards, and comparing runs meant reading CI logs one by one. Every vci command now appends one JSON line to <git common dir>/vci-metrics.jsonl: its version, command, flag names (never their values), exit code, duration, the machine (OS, architecture, CPUs, CI or not) and every step it times (the lines VCI_TIMING prints: the plan's fetches and verdicts, the Rails batch, checking, signing and storing, supersession, each part of a push, any git command over 2 s). vci metrics gives each step's median, 95th percentile and maximum over the last 50 commands (--last N, --command run, --format json). In GitHub Actions, vci ci also adds its slowest steps to the job's summary page. VCI_METRICS=<file> writes the log elsewhere and VCI_METRICS=0 writes nothing; the log keeps about its last 4 MB.
2.4.1
20 October 2026. 2.4.0 on a full vex run: the two jobs' test batches took 218 and 187 s instead of 251 and 194. On the same run, storing 1,008 units one at a time took 35.8 s on one runner and 9.6 s on the other. git-meta opens the store with synchronous=NORMAL, safe with its write-ahead log; vci's own connection, which stamps each write after it is committed, used SQLite's default, which syncs the disk on every commit. It now uses the same setting. On a laptop this changes nothing; it is for runners whose disks are busy.
2.4.0
20 October 2026. 2.3.9 on a full vex run: the plan took 31 s instead of 38 to 42, with no repack, and the jobs took 5.7 and 7.0 min. What separates the two jobs is their share of the tests: 194 s of batch against 251 s. --partition gave each unit the partition its test id hashes to, and with the durations vex's attestations record, that split gave the two Rails jobs 2,762 and 3,231 s of tests. A project whose units are all files is now split by size: every unit it lists, skipped or not, largest file first, goes to the partition with the fewest bytes so far. Ties go to the lowest partition, then the smaller test id. On the same files that gives 3,047 and 2,946 s; the recorded durations themselves would give 2,996 each. A split by recorded duration would not be safe: a job run again after the others pushed their attestations could see other durations and leave files to no job. File sizes and the listing come from the checkout, so every job agrees, whatever its plan skips. A project with a unit that is not a file (a cargo unit) is split by test id as before, and VCI_PARTITION_BY=hash keeps the test id split for every project.
2.3.9
20 October 2026. 2.3.8 on vex: supersession's questions about commits took 2.5 thread-seconds instead of 330 to 450, and checking, signing and storing a job's units took 20 s instead of 34 to 67. The jobs took 5.8 and 7.2 min. The plan still spent 6.7 s in git repack -A -d. A fetch of more than 500 values was split into four git fetch processes, each leaving a pack, so a store restored from the cache passed 16 packs, and git 2.43 can only repack a partial clone whole. One git fetch of 2,000 values from GitHub takes about as long as four (1.6 s against 1.4 s on a laptop). Up to 4,000 values now come in one request, and the pack count is checked after every fetch, not only after a split one.
2.3.8
20 October 2026. 2.3.7's timing found supersession's 25 s on vex: slow git: cat-file -e f1c14bcf…^{commit}: 12.67s and slow git: merge-base --is-ancestor f1c14bcf… c78386b7…: 12.70s, for an earlier pull request commit the checkout does not have. Asked about an object it lacks, git asks the promisor remote for it, and git before 2.44 ignores GIT_NO_LAZY_FETCH: Ubuntu's 2.43 went to GitHub several times for each, and every thread checking units waited for the answer. vci now checks that the commit is present with git rev-list --no-walk --missing=print, which never fetches on any version (vci's store already checked values that way), before any other question about it. A commit the clone lacks is answered as before, without asking git about its history.
2.3.7
20 October 2026. 2.3.6 on vex: writing the commit-graph took 0.12 s, finding the commits only the run's commit holds took 0.01 s, and one or two earlier commits were asked about. Yet the threads waiting for those answers spent 330 to 450 thread-seconds, so one answer took about 25 s. The same git commands on a copy of vex take 0.03 s each. With VCI_TIMING, vci now reports any git command that took 2 s or more, and how long each question about an earlier commit took, so the next run says where those 25 s go. Nothing else changes. vex's store cache was also fixed this release cycle (the attest job now keeps loose objects; vex pull request 1862): since 2.3.2 every restore had found the cached store incomplete and rebuilt it.
2.3.6
20 October 2026. 2.3.5 did not shorten supersession on vex: its probe ran on a slow runner (checkout alone took 140 s) and spent 400 to 440 thread-seconds asking about commits. vex has 137 branches, not thousands as 2.3.5's notes said, and many earlier attestations were made on other pull requests' commits, which are not ancestors of this one and are still asked about one at a time. What makes each question slow is a fresh checkout with no commit-graph: git then parses every commit it walks. On vex's history, git for-each-ref --contains took 0.35 s without one and 0.04 s with one. Supersession now writes a commit-graph (git commit-graph write --reachable) when the repository has none, before its first question; git maintenance and git gc keep one anyway. A failure (a shallow clone, an old git) only leaves the questions as slow as before. With VCI_TIMING, vci prints how long that took, how many commits only this run's commit holds, and how many earlier commits were asked about.
2.3.5
20 October 2026. 2.3.4's timing showed where supersession spent its time on a full vex run: supersession thread-seconds: list 11.6, read payloads 0.4, moved on 162.6, manifests 22.8. Asking whether an earlier commit was moved on from walked the history of every branch, remote-tracking branch and tag: vex has thousands. Even asked once per commit, that took most of a job's 200 thread-seconds. One walk now answers it for every earlier commit. git for-each-ref --no-contains <commit> lists the refs that do not hold this run's commit, and git rev-list <commit> --not <those refs> lists the commits only this run's line of history holds. An earlier commit that is an ancestor of this one was moved on from exactly when it is among them. Anything else (a commit rebased away, a sibling's) is asked as before. A test checks the two answers agree for every case moved_on's tests cover.
2.3.4
20 October 2026. 2.3.3 on a full vex run did not shorten supersession: 188 and 233 thread-seconds, so asking git about commits was not where its time went. With VCI_TIMING, vci now prints supersession's own parts: listing a unit's attestations, reading their payloads, asking git about their commits, and decoding their manifests. A plan reads each base manifest once. A base holds the entries a run's units share; every candidate's manifest names one, and the plan read the same few bases again for each of the 10,283 candidates of a full vex plan, half of its 150 to 270 thread-seconds of manifest reads.
2.3.3
20 October 2026. 2.3.2 on a full vex run: serializing the local metadata took 2.2 s and 3.9 s instead of 14 to 18 s, the first job's push needed 6 s before sending instead of 22 s, and the plan's first fetch of 2,018 values took 1.9 s instead of 10 s. The slower job's Rails step took 373 s, down from 436 s. Supersession still took about 210 thread-seconds a job, and not to read manifests. Whether an earlier attestation's commit was moved on from is answered with git for-each-ref --contains, which walks the history of every branch: vex has thousands of remote branches. It ran twice per commit, and the 16 threads that check units at once each asked about the same commits before any answer was cached. Each commit is now asked about once (a thread that finds the question being asked waits for the answer), and the cheap ancestry test runs first. When the new commit descends from the old one, the refs holding the old commit but not the new one decide alone, so one walk of the refs replaces two. The answers are the same.
2.3.2
20 October 2026. Two costs of a full vex job. Supersession (deleting a unit's earlier attestations by the same signer once a new one is stored) read and decoded each earlier attestation's input manifest before asking whether its commit was moved on from (made on a commit this one descends from that no other branch or tag contains). That question is answered once per commit and settles most of them: find superseded 217.4 thread-seconds a job. It is now asked first, and an earlier attestation with the same input root as the new one is kept without reading its manifest. The result is the same. A batched fetch of values (four git fetch processes at once) then merged its packs with a geometric repack. Ubuntu's git 2.43 cannot do that in a partial clone, so its fallback repacked the whole repository each time: a plan's two fetches of 2,018 values took 10 s each on vex's runners, where the same fetch from GitHub takes 1.4 s on a laptop. Packs are now merged only from 16 on (gix, which git-meta reads them with, has 32 slots at least). 2.3.1 was built while these edits were uncommitted, so the action moved to it only after a vex probe had run on 2.3.0; this release carries both.
2.3.1
20 October 2026. 2.3.0 on a full vex run: the longest file process went from 258 s to 49 s, every shard passed, and each slot's files then added up to the same 231 s, so the slots were full. The batch took as long as before: on that run the test work itself, not one file, set its length. The rest of a full job is vci's own work, and pushing was the largest part: push: fetch and materialize meta's metadata: 59.31s on the second job, 22 s on the first. Of that, about 18 s per job was serializing the local metadata. git-meta rebuilds every target with a changed row from all the rows it holds, and every input manifest is on one target, project: one new manifest made it read and write all of them. vci now writes only the changed entries onto the local metadata commit's tree, through gix, so only the subtrees that change are written. Each entry is written as git-meta writes it, and the store is marked serialized as of before it was read, as git-meta does. A test checks the tree against git-meta's full serialization of the same store after puts, deletions, values stored again over deletions, large values and merges. git-meta still serializes when there are filter or prune rules, a set or list value, a value not fetched, or a row gone without a deletion record (VCI_SERIALIZE=git-meta forces it). On a laptop, with 4,000 manifests stored and 1,000 new, the first job's push took 16 s instead of 24 s. A push also no longer merges a fetched tip that the local metadata commit already descends from (this clone pushed it, or merged it, and stored more since); that merge took 5 s to change nothing.
2.3.0
20 October 2026. On a full vex run, the slower job's batch ended with one file: a file's process took 3.7 s on average, 258.1 s at most, while its 1,032 other files had ended well before in 16 slots. Rails' own parallel testing splits a file's tests by method across its workers; vci ran each file in one process. A long file now runs as shards: when slots run a Minitest file whose last attested run took at least twice VCI_RAILS_SHARD_SECONDS (default 30), it runs as ceil(duration / that) processes, at most one per slot, each forked as a file's process is and running every k-th of each class's sorted test methods, so each method runs once between them. The file is attested from its shards together: the union of what they read, and one result, passed only when every shard passed (a shard whose share was empty counts for nothing). A shard that fails, or that no slot ran, makes the file run again whole on its own, as a failed file in a slot always has. VCI_RAILS_SHARD_SECONDS=0 turns it off; RSpec files are not sharded. 2.2.13 on the same run: the plan took 54 s instead of 66 s, storing the units 13.4 s instead of 21.5 s.
2.2.13
20 October 2026. 2.2.12 on a full vex run: the slower job's Ruby process took 248 s instead of 350 s, the job 8.3 min instead of 9.7. Its plan showed round 2: nothing more to fetch for 2018 unit(s) not skipped: 13.19s: for the units it could not skip, the second round read every candidate's input manifest again (megabytes each) to find bases not fetched. A second round now fetches only what the first found missing: the candidates of units whose attestations were not all fetched, and the manifests and bases the checks found not fetched, by root. With nothing missing there is no second round. A Rails batch whose groups ran at once now says so, with each group's own time (2.2.12 still said one after the other
and timed each group to when the driver got to it). Storing attestations is about three times faster: vci asked git for the repository's git directory (a git rev-parse) each time it opened the store, about eight times per unit stored; it now asks once. On a laptop, storing a unit with a 200 KB manifest went from 42 ms to 14 ms; a vex job stored 1,033 units in 21.5 s.
2.2.12
20 October 2026. 2.2.11's timing found the slower vex job's 90 s: the batch ran 2 group(s) of files one after the other: test_helper (1032 file(s), 255.3 s), queue_result_reuse_test (1 file(s), 94.0 s). One test file starts with require_relative "queue_result_reuse_test", so it boots on its own, and its group ran after the main group's. The groups of a batch now run at once, each in a temp directory of its own under vci's, so no group sees another's files (they share only an empty parent); the main group's slots each have their own database, so the base test database is free for the others. VCI_RAILS_GROUPS_IN_TURN=1 runs them one after the other as before. Reads anywhere under vci's temp dir stay scratch space in every group, and RSpec's example status file moves to the group's own directory, so no group reads what another wrote there. The collector's tests cover groups that must not see each other's temp files, now running at once.
2.2.11
20 October 2026. 2.2.10's run showed the temp files were not it either (0.00 s to remove). The slots' timing covers only the group of files that share the Rails boot; files whose first statement differs (on vex, 17 of 2,034 start with require "minitest/autorun", require "json" or a require_relative) form groups of their own, run one after the other, one file at a time. A batch with more than one group now prints the batch ran N group(s) of files one after the other with each group's first statement, file count and time.
2.2.10
20 October 2026. The last place left for the slower vex job's 80 s, after 2.2.3 to 2.2.7 ruled out the boot, the slots, their cleanup and the slot parent's own work, was the batch driver's work after a group: it walked vci's temp directory, stat-ing every entry, and removed what the group's tests had left there one entry at a time, so that the next group started clean. A vex Rails run has one group, and vci removes that whole temp directory when the batch ends anyway. After the last group the driver no longer cleans up; vci removes the directory in one call, timed under VCI_TIMING=1 as rails batch: remove what the tests left in the temp directory; a group followed by another still cleans up, and its time is reported.
2.2.9
20 October 2026. On 2.2.8's first full vex run one job's push failed ten times again, on a deletion record GitHub serves (another job's, merged into this job's tree), while the pushed commit had 31,734 objects this clone never fetched. Each attempt fixed the one object git named, and there are thousands. The first time a push fails on an unreadable object, vci now fetches every deletion record of the pushed tree it lacks, in one batch (they are tiny), and tries again; the per-object repairs follow as before.
2.2.8
20 October 2026. A full vex run on 2.2.7 lost one job's push again, on a deletion record stamped during that job; 2.2.4's write-back did not apply because the store no longer held the record's row. A deletion record carries only when and by whom; any record for the same key deletes the same value. When a push fails on a deletion record this clone cannot read and cannot write back, vci replaces it in the commit being pushed with a new record for the same keys, stamped now, and pushes again; it does this only when every path holding that object is a deletion record, and only while the local metadata ref is still at that commit. A store test removes a deletion record's blob and every row of the store's deletion records right before git push: the push goes through and a fresh clone no longer sees the value. 2.2.7's timing narrowed the 90 s of the slower vex job further: the process reaches its slots 2.7 s after vci starts it and its slots have all exited at 241.6 s, so the time is spent after the batch's last status line.
2.2.7
20 October 2026. 2.2.6 was not the cause of vex's 90 s (its first full run on vex still showed a Ruby process of 333 s whose slots had all exited 240.5 s after starting). The boot line counts from when the collector is loaded; what runs before that was not measured. vci now passes the time it starts the process and the batch prints the batch's process reached its slots N s after vci started it. No behaviour changes.
2.2.6
20 October 2026. 2.2.5's timing closed the case of the 90 s: in a full vex job the boot took 2.9 s, the slots 264.6 s and the batch's own work after them 0 s, yet vci measured the Ruby process at 357 s. vci ran the batch with output(), which returns when the process's stdout and stderr close, not when it exits; a test that leaves a process running in the background (a server, a helper) holds an inherited copy of those pipes, and vci waited for it to end. The batch's output now goes to files and vci waits for the Ruby process itself. An end-to-end test runs a Rails file that leaves sleep 40 running: the run took 44 s before, and now finishes without it.
2.2.5
20 October 2026. 2.2.3's timing showed the boot (3 s) and the slots' cleanup (0 s) do not explain why one full vex job's Ruby process ran about 90 s longer than its slots, twice. What is left is the batch's own process after its slots: waiting for them to exit, reading their lines, checking the shared temp directory. A Rails batch now prints every slot had exited N s after the slots started; then reading the slots' lines took N s, checking the shared temp directory N s. No behaviour changes. 2.2.4's write-back was not needed on its first full vex run: both jobs published on the first attempt.
2.2.4
20 October 2026. A full vex Rails run on 2.2.3 lost one job's push again, ten attempts, on 61c2d62f...: a deletion record that job's own merge wrote minutes earlier (git-meta records a deletion when the remote no longer has a value), held by no remote and no longer in the clone's object database. Why the blob goes missing is still not pinned (2.2.2's repack change removed one way). What is certain is that a deletion record's blob is exactly its row in the store, {"timestamp":..,"email":..}. Before git push, and again when a push fails on an unreadable object, vci writes back the blobs of the store's deletion records that the push needs and this clone lacks, checked against their object ids, then pushes. A failed push is now recognised whichever way git words it (could not fetch or unable to read). A store test removes a deletion record's blob right before git push: without this the push fails with unable to read, with it the record is written back and the push goes through.
2.2.3
20 October 2026. In a full vex Rails run, one job's Ruby process took 334 s while its slots' last file ended 242 s after the slots started; the other job's took 199 s for 195 s. The 92 s are either the boot before the slots or the slots' cleanup after their last file (Rails' parallelize_teardown, such as dropping each slot's database), and nothing said which. A Rails batch now prints the batch booted in N s before the slots started; a slot's cleanup took N s at most, the last ending N s after the slots started. No behaviour changes.
2.2.2
20 October 2026. 2.2.1 did not stop the lost pushes, and its diagnosis showed why: the object a failing push could not read was a value the job itself had written minutes earlier (its own deletion record, stamped during the run, or a manifest), absent from the remote and from the job's own object database, with no loose file left. The cause is 2.1.15's pack consolidation. After a batched value fetch vci runs git repack --geometric=2 -d, which on git before 2.45 always fails in a partial clone (cannot use internal rev list with --stdin-packs; GitHub's Ubuntu runners have 2.43), so vci fell back to git repack -a -d on every such fetch in CI. That packs a value while vci's local metadata commit reaches it, and the next one deletes it once vci has rewritten that commit (a merge, an undo) but not yet pushed the commit that still holds it. Reproduced on git 2.43 in a partial clone of vex's store: the value is gone after the second repack. The fallback is now git repack -A -d, which turns such objects loose instead of deleting them; nothing prunes them, since vci runs git with gc.auto=0. A unit test packs a referenced value, moves the ref away and repacks again: it fails with the old fallback and passes with the new one. Git 2.45 and later (laptops) never took this path.
2.2.1
19 October 2026. The failing object behind every lost push since 2.1.13 is now identified. Two vex pull request runs on 2.2.0 re-ran the same 2,013 Rails files a minute apart and superseded the same older attestations; the first published, and both jobs of the second failed ten times on could not fetch e00dd449... from promisor remote. That object is a deletion record the first run wrote (its stamp is that run's supersession time), held by the remote's tip, in a target the second run's own attestations changed, and never downloaded by the second run. Every master attest failure had the same shape: a full run superseding what a pull request job had just superseded. 2.1.18's retry downloaded the one object git named and tried again, which fixed one record per attempt out of about a thousand. A push now downloads, before git push, every deletion record in each target (or key directory) where it differs from the remote's tip, unchanged ones included; they are tiny and few per target, so this costs seconds and only when a push changes something, and other attestations' envelopes stay unread. A store test races two blobless clones deleting the same value in both orders of their stamps; it passes with and without the change at that scale, so the change is a defence of the observed failure rather than a reproduced fix, and the push's diagnosis (2.1.17) stays in place should it recur.
2.2.0
19 October 2026. A vex Rails job's plan downloaded about 10,500 attestation envelopes to check 2,000 units: the store holds about five attestations per file (two signers, several branches), and which is the newest was only knowable from inside the envelope. An attestation's key now ends with a hint: when it was issued and the first 16 hex digits of the tree it was made on. A plan picks from the keys the candidate of each unit to read first, one made on this checkout's tree, else the newest, and downloads that one's envelope and manifest only; the other candidates are fetched, in one batch, only for the units the first pass could not skip, which are then checked again. The verdict checks a same-tree candidate before a newer one. The same inputs re-attested replace the key whatever its hint, as before. A vci before 2.2 does not read hinted keys: a unit whose attestations all carry hints runs there, and a push from such a clone never deletes them, so every writer should move to 2.2 together; vex's workflows and laptops take the v2 tag. vci prune --keep N (default 2) keeps per unit and signer the N attestations issued last and any made on a tree some ref still has at its tip, and deletes the rest with the manifests only they referenced; --superseded is now --keep 1. vex's hourly master attest prunes, so the store shrinks from about five attestations per file to two or three within a day, which also shortens the metadata fetch every job starts with. Tests: hinted keys parse and plain ones still do; the store fetches the chosen envelope and manifest only (2 blobs) and a rewrite replaces the key; three attestations of a unit on one commit become two, then one.
2.1.18
19 October 2026. 2.1.17's full pack walk did not help: the next full master attest on vex failed ten times on a manifest again, and its diagnosis said the object was unreadable, the tip's tree complete, and 1,203 objects of the pushed commit absent (the values this clone never fetched, as expected). That manifest is in no recent metadata commit, yet GitHub serves it by id, as it served each of the day's six failing objects. A push that fails on an object this clone cannot read now fetches that object by id from the metadata remote and tries again immediately, naming what it did; only when the remote does not serve it does the push wait and re-fetch as before. The diagnosis now also says whether a loose object file exists for the object (a corrupt or half-written one) and what git said when reading it, which separates a value never written from one written badly. Two store tests rule out one suspected cause: storing the bytes of a manifest the store holds as a promised row (small or blob-sized) leaves it readable and pushable after the remote dropped it.
2.1.17
19 October 2026. Both vex master attest jobs after 2.1.15 failed to publish their 2,012 attestations: ten push attempts each, every one failing on the same object, a manifest the tip's tree held (a pull request job had just pushed the identical manifest) and the clone had never fetched. The pre-push check of 2.1.10, which walks the objects of the pushed commit minus the tip's with git rev-list, found nothing missing each time, and 2.1.16's checks of the tip's tree and the changed blobs passed. The difference is in git: a push builds its pack with pack-objects --sparse (the default since git 2.27), a walk along the paths that introduce new objects which git documents as able to pack extra objects for some tree shapes, while rev-list walks fully. On a 100,000-entry metadata tree rebuilt around 2,000 new manifests and 2,000 deletion records, the sparse walk missed that the tip reached that manifest and tried to pack it. A push now packs with pack.useSparse=false, the same walk as the check, which takes about a second on vex's tree; and a push that still fails on an unreadable object puts its diagnosis (the object, whether it is readable here, whether the tip's tree is complete, how many objects are missing) in the error, where 2.1.16 had it only in notes that a failed push never printed. Reproductions that pushed fine throughout, for the record: two blobless clones superseding the same attestation; the same over a full local mirror of vex's store, with the pull request key, on macOS git 2.56 and Ubuntu 24.04 git 2.43 with vci prune between attest and push; identical manifests under two signers.
2.1.16
19 October 2026. The three pushes of 2.1.13 that failed on could not fetch ... from promisor remote now have a timeline. Each unreadable object was a deletion record a concurrent job had written (its stamp is the other job's supersession time), pushed a minute before the failing job fetched: two jobs of two branches had re-attested the same files and each superseded the same older attestations. The merge put the other job's deletion record into the failing job's tree, which is correct, and that record is reachable from the tip, which git excludes from the pack by walking the tip's tree. Git stops that walk silently under a subtree it cannot read, so anything under it is packed, and the never-fetched record fails the push; the pre-push check of 2.1.10 uses the same walk and is blind the same way. The tip's tree was complete when fetched, so what made a subtree unreadable between fetch and push is still unproven (2.1.15 removed the gc --auto that ran there). A push now completes the tip's tree again right before git push, and finds the blobs the pack must send by comparing the two trees (git diff-tree) as well as by the walk, fetching what is missing. When a push still fails on an unreadable object, its note names the object, whether this clone can read it now, whether the tip's tree is complete, and how many objects of the pushed commit are missing with nothing excluded, so the next such failure is explained by its log.
2.1.15
19 October 2026. In the first hour of 2.1.13 on vex, two jobs of two runs could not publish the 1,000 attestations each had made, and the next run of each branch ran those files again. The first job's push failed in its fetch: the tree of metadata commit ... is incomplete after fetching it. The metadata tip's tree has 108,000 subtrees, and GitHub served the fetch of its id without 12 of them, on the runner and on a laptop alike, while serving each of the 12 when asked for by id. A tree fetch now asks git which subtrees are missing and fetches those by id, up to five rounds. The second job's push failed while git built the pack: could not fetch 42b438a7... from promisor remote, a deletion record the job itself had just written, which the pre-push check (2.1.10, the same traversal) had found present seconds earlier. The cause is not pinned (the fetch's gc --auto, detached and repacking the object database while the push read it, is the suspect); a push that fails this way now fetches and tries again, like one rejected for a moved tip, saying in its notes whether the tip had moved, and vci's metadata fetches no longer trigger gc --auto. A third failure showed on a laptop clone once the tree fetch was fixed: after a fetch of 10,000 values in parallel batches, every git-meta read failed with The slotmap turned out to be too small, because gix sizes its pack slots when a repository is opened (32 at least) and each git fetch had left a pack of its own (74 in that clone). A batched fetch now consolidates its packs (git repack --geometric=2 -d, as git-meta does after its own), 74 packs became 3, and the fetch went through. The store's tests push from a blobless clone while another writer lands in between.
2.1.14
19 October 2026. A vex Rails job that attested 33 files spent 31.6 s in push: fetch and materialize meta's metadata, of which the materialize was 3.5 s; the plan's own fetch of the same remote, minutes earlier, had taken 17 s with a 14 s materialize inside it. Nothing in the log says where the other 28 s went. VCI_TIMING=1 now prints each step of a metadata fetch: fetch: git fetch (commits and trees), complete the tip's tree, list the tip's keys, cover the local ref, serialize the local metadata (the run's attestations written as a metadata commit, the likely cost), then the materialize line as before. No behaviour changes.
2.1.13
19 October 2026. On vex's Rails jobs, with every file attested, the plan still took 41 s: 24 s fetching values, 12,308 attestation envelopes and 12,153 manifests (about six attestations per file are stored, from branches still alive) to check 2,036 candidates, one per file. Two causes. The three batches (envelopes, manifests, bases) each listed the store's promised rows and the whole local metadata tree, 12 s of the 24. And the plan fetched every candidate's manifest though it checks a unit's newest attestation first and reads another's only when that fails. The store and tree are now listed once per plan, and the first batch fetches the manifests of each unit's newest attestation only; after the first pass the plan fetches, in one more batch, the manifests of the other attestations of the units it could not skip, and checks those units again, so a unit whose older attestation matches (an edit attested and reverted, a branch rebased onto what was attested) is skipped as before. VCI_TIMING=1 prints round 2: fetch N more manifest(s), check the units not skipped again. On a laptop against vex's store the fetch phase of a whole-repository plan went from 21 s to 4 s with the same verdicts; the store's tests fetch a chosen manifest first and the other in a later round, and the cargo end-to-end test attests an edit, reverts it, and checks a fresh clone's plan skips the unit through the second round.
2.1.12
19 October 2026. Minutes after a vex pull request was squash-merged, master's attest run skipped all 2,023 files on the pull request's attestations; thirteen minutes later, on a commit that changed only a workflow file, it ran all 2,006 again (11 minutes). In between, another pull request's run had re-attested the same units with the shared pull request key and, finding the merged pull request's attestations made on a commit no ref contained (its merge commit, gone with the squash), had superseded 2,011 of them, though master's tip had exactly their tree. An attestation now records the checkout's git tree (when clean), and supersession keeps one whose tree some branch, remote-tracking branch or tag still has at its tip, whatever its commit; without the tree (older attestations, a dirty checkout) the rule is as before. An end-to-end test makes a detached commit with a branch's tree, attests there, and checks the attestation survives until the branch goes.
2.1.11
19 October 2026. With 2.1.9's shared bases a job's manifests weigh a tenth, and the plan's fetch did not get faster: 30 to 34 s for about 16,000 values on vex's runners, because git serves each want list of 4,000 blobs at about 1.5 ms a blob whatever its size. The values are now fetched by several git fetch processes at once, VCI_FETCH_JOBS of them (default 4, 1 as before): on one laptop against GitHub the same 16,400 values came in 20 s instead of 37 (eight processes: 14 s). Each process fetches its own batch by object id, writes its own pack, and updates no ref, so they do not interfere; the store's tests fetch 1,200 values both ways.
2.1.10
19 October 2026. One of two jobs of a vex run lost its 987 attestations: its push failed with could not fetch 71a69c57... from promisor remote, an attestation's deletion record the clone held as a key only (a blobless fetch) which git push had to send and could not read, since vci disables git's lazy fetch; the job printed a warning and the next run re-attested the files. Before pushing, vci now asks git which objects of the commit the remote's tip does not reach are missing here, fetches them from the metadata remote, and pushes; what the remote cannot serve is a clear error naming the first object, as a value never fetched already was. VCI_TIMING=1 prints push: fetch N object(s) the pack needs when it happens.
2.1.9
19 October 2026. 2.1.8's first run on vex stored a base of 9 and 17 entries a job: the entries every one of a job's 1,000 files has are few, because what a Rails file shares is the observations of its own slot's boot and setup, and the files it loads lazily differ. The base is now per group: the Rails adapter says which files shared their slot's lines, and the run stores one base per slot (the entries all of that slot's files have), each file's manifest on top of its slot's. Units outside any group share the run's base as before. VCI_TIMING=1 prints shared input manifests: N group(s) of units, A to B entries each stored once.
2.1.8
19 October 2026. On vex's runners a job's plan fetched about 10,000 values in 20 to 31 seconds: 5,000 attestation envelopes in 6 to 8 s and 5,000 manifests in 19 to 21 s, each manifest about 60 KB (449 entries, 9 KB packed) of which almost all is the Rails boot's observations, repeated in every file's manifest. A run now stores the entries every unit it attests shares once, as a plain manifest under its own root, and each unit's manifest on top of it ("base" plus the entries the unit adds). Reading a manifest puts the base's entries back, so the decoded manifest and its signed root are exactly what they were; a blobless clone's plan fetches the attestations, their manifests, then the bases; vci prune keeps a base while a manifest names it and deletes it with the other unreferenced manifests after. The run prints shared input manifest: N entries every attestable unit has, stored once under VCI_TIMING=1. vci before 2.1.8 cannot read a manifest on a base (the unit runs there), and a vci prune before 2.1.8 would delete the bases: every clone that prunes must be on 2.1.8 or later.
2.1.7
19 October 2026. On vex, 13 of 2,021 Rails files could never be attested, so every job of every push booted Rails to run them (about 25 s a job) and planned "13 to run" forever: nine with a test skipped under CI, two benchmark files with no test outside a benchmark run, two that probe public/ under two spellings of an organization's slug. All three are now a policy decision instead of a refusal:
- Minitest and RSpec report a skipped or pending test as a passed result with skips, as pytest does, so
policy.ignore_taints = ["result:skipped"]waives it for them too (before it was a failed result, which nothing waived; without the waiver it is refused as before). result:no-testsis waivable: a file in which no test ran (every test gated by an environment variable) attests with the waiver recorded, and the plan accepts it while the base policy still waives it. A run with tests filtered out never is.- Two absent probes do not collide under case folding: there is no file to be the same one, and should either come to exist, at least one probe finds it on any filesystem and the unit runs again. A present file still collides with anything.
And the slot retry of 2.1.5 is proven on a real collision by an end-to-end test: two files leave rows in their slots' databases, a third fails in whichever slot takes it, runs again on its own, passes, and is attested with exit 0.
2.1.6
19 October 2026. On vex's runners one job's slots were done 470 s after they started while its files added up to 240 s a slot; to say whether the rest is the slots' or vci's after them, VCI_TIMING=1 now prints rails batch: the Ruby process (boot, slots, files) and rails batch: read the files' outputs, beside 2.1.4's line on the slots.
2.1.5
19 October 2026. Master's first per-push Rails run on vex (vci 2.1.3, one job, all 2,019 files) attested 2,005 and failed one: RunStopTest, Validation failed: Request key has already been taken, a test that had passed in every pull request run. By decision a slot's files share its database as a Rails worker's do, so a file can meet rows an earlier file of the slot left, and which files share a slot depends on the run. A file whose process fails in a slot now runs again on its own, from a fresh boot and database, like the files a batch cannot run; the retry's result is the file's, and a failure on its own fails the run as before. vci run prints its process failed in slot N (exit 1): it runs again on its own with the other files that run again.
2.1.4
19 October 2026. The first run with 2.1.3 on vex superseded 2,424 and 2,519 attestations a job (every earlier push of the pull request, rebased away) and then spent 65 and 60 seconds deleting their manifests one session each. The manifests nothing references are now deleted in one session. The Rails batch prints one more line, vci: the slots' last file ended N s after the slots started; a slot's files add up to X s at most, Y s at least: on that run a job's files added up to about 240 s a slot, and its slots were done 470 s after they started, which the next run will explain.
Rails on vex after 2.1.3, for the record: a push whose files are attested runs two jobs of 1.6 to 1.8 minutes (2006 to skip, 13 to run; the plan 19 to 23 s, of which the values' fetch 6 to 7 s once the superseded manifests were gone), against the plain runner's two of 4.8. The remaining cost is the runner's own setup, a Rails boot for the files vci can never attest, and master moving under the pull request, which re-runs every file until master is attested again (vex now attests master's Rails files on every push).
2.1.3
19 October 2026. The first Rails run with 2.1.2 on vex's runners: the plan's verdicts phase 9.5 s (1,124 to 1,247 s before), the four jobs 5 to 9 minutes for all 2,005 files (28 to 30 before, cancelled). Where the rest went, and what this release does about it:
- Files run longest first. The jobs' Ruby phases were 142, 202, 329 and 377 s for about 500 files each at 16 slots, where the files' process times add up to 94 to 117 s a slot: the slots waited on a last file of 78, 106, 141 and 212 s taken near the end of the listing.
vci runnow hands the runner the units longest first, by the duration their newest stored attestation records (result.durationMs, any signer; units without one first, in the listing's order), so a batch, Vitest, pytest or node:test run that takes several at once ends with its short files. The lines, the summary and the store keep the listing's order. - A rebased pull request's earlier attestations are superseded. Nothing was superseded on the runners: the earlier pushes' commits were rebased away, and an attestation whose commit this clone did not have was kept ("nothing is concluded from a history that is not there"). A commit on no branch, remote-tracking branch or tag at all, or one a full (not shallow) clone does not have, can be checked out by no name: an attestation of that commit by the same signer with other inputs is now superseded, as one on an earlier commit of the line was. A shallow clone still concludes nothing. Without this, every push of a pull request added its 2,000 attestations and their manifests to the store for good.
- The superseded ones are found in parallel. Finding them (reading and decoding the unit's earlier manifests) ran one unit at a time with the store writes: 26 to 46 s of a 36 to 58 s check-and-sign phase. It now runs on the checking threads; only the removal stays with the store writes.
VCI_TIMING=1shows the push's parts (fetch and materialize the remote's metadata, check what the push sends, put the local metadata on top,git push): 31 to 58 s a job on the runners, with no line saying why.
2.1.2
19 October 2026. The third Rails run on vex's 16-core runners, with 2.1.1's per-part timing, said where the plan's 10 to 16 minutes went: decoding a candidate's manifest took about 2 seconds of thread time and checking it for case collisions about 2.5, for each of 2,000 candidates, 250 times what the same binary's work costs on a laptop; the rehash itself was cheap. The Linux releases are static musl binaries, and musl's allocator takes one lock for every allocation, so sixteen verifying threads decoding 1.9 MB manifests spent their time waiting on each other. A benchmark that rehashes one real CI manifest 160 times on 8 threads, in an Ubuntu container on this Mac, reproduces it:
| build | per pass |
|---|---|
| musl (the 2.1.1 release) | 13.6 ms |
| glibc | 0.7 ms |
| musl with mimalloc (2.1.2) | 0.6 ms |
Every build of vci now uses mimalloc as its global allocator, which keeps a heap per thread. The releases stay static musl binaries; nothing else changes. On the Mac, where the system allocator already scales, the suites run as before. The benchmark is crates/vci-core/tests/bench_rehash.rs (ignored; VCI_BENCH_MANIFEST, VCI_BENCH_ROOT, VCI_BENCH_N, VCI_BENCH_THREADS).
2.1.1
19 October 2026. The first Rails pull request runs on vex's 16-core runners (vci 2.1.0) showed two costs that this Mac had not: the second run's plan spent 19 to 20 minutes in its verdicts phase for 2,004 candidates (5 seconds here), and the first run's check-and-sign phase ran at one unit a second (25 a second here). Both come from the shape of a Rails attestation on that application: 13,800 inputs a file, 10,000 of them Ruby's load-path probes (vex-api/lib/<feature>.rb, vex-api/vendor/<feature>.rb, .so, .bundle, absent, one per require per load-path directory ahead of the gem's), 1.9 MB a manifest.
- The plan reads manifests in parallel. It read each candidate's manifest from the store behind one lock, with one
git cat-fileprocess, so sixteen verifying threads waited on one reader (a sampler showed the plan inmutexwait). Each verifying thread now has a reader of its own. On this Mac the same plan went from 44 s to 9.4 s. - Load-path probes fold into one listing. When fifty or more absent probes fall under one existing directory (the deepest one on the way to each probe), the collector records that directory's listing instead of the probes: a folded probe can only come to exist by a new name appearing in that listing, so this re-runs more, never less. Probes under a directory that holds one of the test files, that is git-ignored, written during the run, under a symlink or under vci's temp dirs stay as they are; without an answer from
git check-ignorenothing is folded. On five vex-api model tests a manifest went from about 2,300 entries and 310 KB to about 460 and 60 KB (locally, lazy loading); the CI sample, 13,848 entries and 1.9 MB, folds to 3,875 and 517 KB, which projects vex's 5 GB store to about 1.4 GB. The plan skips the five files, re-runs them when a file is added underlib/orvendor/, and skips them again once it is removed. VCI_TIMING=1shows every part. After the verdicts phase, the plan prints per project how many candidates were checked and rehashed and the thread-seconds spent on signatures, global inputs, manifest reads, decoding, case-collision checks, ignored children, rehashing, externals and env;vci runprints the same for its check, sign and store phase. These are what will say where a slow runner's time goes.
2.1.0
18 October 2026. What it took for CI to attest a Rails application the size of vex-api (2,000 test files, a seeded MySQL database, 16-core runners that run the suite in 5 minutes with Rails' parallel workers), measured on it throughout. Three costs, each removed; the arithmetic is in docs/notes-rails-rollout.md.
Rails: slots
A batch of Rails files now runs W at a time when the project runs its tests in Rails' parallel worker processes (parallelize in test_helper.rb). Each slot is set up exactly as Rails sets up a worker: the before-fork hooks once, then in each slot parallel_worker_id = i and every after-fork hook (Active Record creates and loads <database>_<i>, parallelize_setup runs, Oaken loads its seeds), and parallelize_teardown at the end. Each file still runs in a process of its own, forked from its slot, so its inputs, result and exit status are its own; it does not seed again, because the slot answers Minitest's should_parallelize? as a worker does. By decision, this is not deterministic at the database level: a file starts from its slot's database as the slot's earlier files left it, as in a Rails worker; the database's contents were never an input. W is VCI_RAILS_JOBS, else PARALLEL_WORKERS above 1, else VCI_JOBS or the CPU count, capped by parallelize(workers: n); workers: 1 or PARALLEL_WORKERS=1 keeps one file at a time, and so does a project without parallelize. Fail open throughout: a slot whose setup raises takes no file; a write to vci's temp directory outside a slot's own, even one undone before the batch ends, sends the group back to one process per file; a slot killed mid-batch hands its file back; each group starts from a clean temp directory. The boot's observations, identical for every file of a slot, are written once per slot instead of once per file: a file's output went from 3.5 s to 0.17 s, and the merged result is byte for byte what a one-file run writes (VCI_RAILS_SHARED_OUTPUT=0 turns it off).
Measured on vex-api: 50 files 1,433 s one at a time, 168 s in 8 slots, the same 47 attested; the 939-file application group in 8 slots, 32 minutes, where one process per file was about ten hours. An adversarial review of the first version found five ways a slot could leak into another's file; all are fixed with tests, the serious one being a temporary file one slot's test wrote and removed that another slot's test saw.
The plan reads the checkout once
vci plan rehashed every entry of every candidate's manifest and ran one git check-ignore per manifest per candidate: on vex-api's 1,992 files and 2,784 attestations, 513 to 613 seconds, growing with the store, paid twice per job. Now a path is observed once per plan and shared by every candidate of every file (vci_core::CheckoutCache), git is asked once per directory, the global manifest is captured once, files are verified in parallel (VCI_JOBS) in the listing's order, and each file's candidates are checked newest first. Measured: the verdicts phase 4.6 to 5.7 s warm, 10 s with the 8 GB store out of the page cache; the same verdict, reason and signer for all 1,992 files. The checkout must not change while a plan runs.
Units checked and signed in parallel
After the tests, vci run checked and signed units one after another, 1.23 s each on vex-api; sampling showed 95% of it in git check-ignore over every child of every listed directory, per unit, not hashing or ssh-keygen. Units are now checked and signed VCI_JOBS at a time, the checkout read once per run, git asked once per path; lines, summary, exit code and the store's keys are those of a one-at-a-time run. Measured: 939 units in 37.8 s (0.04 s a unit) instead of 1,154 s. Keyless signing is parallel too, its certificate renewal behind a lock.
Also
- A test that captures
ENV.method(:[])and then stubsENV[]no longer recurses into the collector's hook (SystemStackError): a redefinition of a hooked method in the hooked class is watched, and a hook re-entered from the stub throughMethod#callruns the original. - A child Ruby process a batch's test starts runs with no collector and prints nothing (it used to print
VCI_OUT or VCI_TEST_ID not setto stderr, failing a test that expects it empty); the parent records the spawn as before. A child no longer ends the batch for every file at its exit, and a runner that execs Ruby again stays the batch. - A test file whose first statement requires another test file of the batch no longer makes its group drive the batch again.
- The plan's "best failed" reason and a failed
git check-ignore(a path in a submodule or beyond a symlink) give exactly the answers 2.0 gave, whatever the thread order.
2.0.4
18 October 2026. vci run still warned the listing of vex-web includes .DS_Store (not in a fresh checkout, e.g. in CI): this attestation will not match there after 2.0.3 had left what git ignores out of the listing, so the attestation did match (the pull request skipped the file on the laptop's signature: attested by PandelisZ). The warning now names only junk git does not ignore, which does stay in the listing.
2.0.3
18 October 2026. The last thing between a test run on a laptop and a skip in CI, found by trying exactly that on vex after 2.0.2: the pull request job ran the file with best failed inputs: entry:vex-web. The attestation records a listing of the directory the test lists (vex-web), and on the laptop that listing had dist/, build/, .tanstack/ and .DS_Store, which CI's checkout does not have; CI's own listings had the same problem the other way round. A listing now records the directory's entries without the ones git ignores (build outputs, caches, editor files: what a fresh checkout does not have and every machine has differently), as Excluded entries of the manifest, and a verification leaves what git ignores in its checkout out of the listing too, so a listing says what the repository holds. A child a test reads or lists itself stays an input of its own; an ignored file a test reads directly is still an input, with the usual warning that a fresh checkout will not have it. Attestations made before 2.0.3 whose listings held ignored entries (CI's node_modules) are re-made once. This also ends the refusal of a unit whose test writes a cache into the source tree during the run (vex-roder's __pycache__): an ignored entry appearing is no change to the listing.
2.0.2
18 October 2026. Two things that kept attestations from being reused, found by asking why vex's pull requests still ran what CI had attested.
Reads of built-in ignore variables are no longer inputs. PATH, HOME, TERM, vci's own VCI_* and the rest of the built-in ignore list reach every test so that tooling works, and until now a test seen reading one got its value hashed into the attestation. A test that hands its environment to a child process (spawn(cmd, {env: process.env}), a Rails system call) reads every one of them, so its attestation matched only the machine, the runner directory and the job that made it: on vex, 93 of the 115 units that ran again on the last twelve pull requests failed on exactly this (best failed env: env:PATH (built-in ignore)), and no laptop attestation could ever match CI's HOME. Now a read of a built-in ignore variable is not hashed, as a read of an ignore one never was; hash = ["HOME"] in [env] makes it an input again. At verification a difference in such a variable recorded by an earlier attestation is no difference, so existing attestations start matching without being made again. Stripped variables are still recorded as absent, and the built-in hash list (RUSTFLAGS, GOFLAGS, ...) is unchanged.
A value put beside a push is no longer lost. Two writers in one clone at once (a vci run storing an attestation while another pushes) could leave the first's value out of the push on Linux; the two concurrent-writer store tests now pass there and the stock-git CI job runs them again.
A private key file signs without ssh-agent. ssh-keygen -Y sign asks ssh-agent first whenever SSH_AUTH_SOCK is set, even for a key given as a file, and an agent that wants a person to approve each signature (1Password's) refuses an unattended run: agent refused operation, communication with agent failed, and the unit ran but was not attested, which is how a coding agent's test runs on a laptop silently attested nothing. vci now removes SSH_AUTH_SOCK for ssh-keygen when the key path is a private key on disk; a .pub still goes through the agent.
2.0.1
17 October 2026. On a GitHub Actions runner (Ubuntu 24.04, git 2.43) every 2.0.0 plan printed could not fetch attestations from origin: `git cat-file --batch-check=…` failed (exit status: 128): warning: lazy fetching disabled; some objects may not be available / fatal: could not fetch <oid> from promisor remote, used no attestations, ran everything, and the push failed the same way: on vex, 291 of 291 web units ran where 2.0.0's own measurements had 261 skipped. 2.0 fetches the metadata without its values and asks git which of the values a plan needs are already here before downloading the rest in one batch; it asked with git cat-file --batch-check under GIT_NO_LAZY_FETCH, which on git before 2.45 does not answer missing for an object the promisor remote could hold but dies. The check now lists what is present with git rev-list --objects --no-walk --missing=allow-any --ignore-missing, which never fetches and leaves an absent object out on every git since 2.22, and asks cat-file only about those. Reproduced and verified in an Ubuntu 24.04 container against a copy of vex's metadata (filters enabled on the remote: a local path remote ignores them, which is why no test saw it): 2.0.0 dies at the first hydration, 2.0.1 fetches, plans and prunes. Nothing else changes; the action's v2 tag moves here.
2.0.0
17 October 2026. Four changes: what a fetch downloads, how CI can sign, one suite split over N CI jobs, and Rails runs that boot once. The major version is for the first: once 2.0 has fetched into a clone, vci 1.x cannot fetch or push there without a refetch (see upgrading).
A fetch downloads keys, not values
Every CI job's fetch downloaded the whole metadata ref, every value of every project, although a plan reads a few hundred of them. On vex that was 1.4 GB per job until vci prune --superseded && vci push --rewrite-history (1.8.2) dropped the old versions; what was left, about 40 MB, grew again with every attestation. vci fetch (and the action, and --fetch) now fetches refs/meta/main blobless: the commits and the tip's trees, whose paths are the keys, and none of the values. The store gets a row per key, a value not downloaded being a git-meta promised row, and the values are fetched when something reads them, in one git fetch of blob ids per batch: vci plan, vci ci and vci explain fetch the attestations of the units they verify, then the input manifests those reference; vci verify fetches the one manifest it reads, vci run the attestations of the units it supersedes, and vci prune every attestation. Merges work from paths and blob ids, with git-meta's results, and a push sends only what the clone wrote. A value that cannot be fetched (offline) makes its unit run, and vci explain says value not fetched: run `vci fetch` online.
Measured on a local copy of vex's metadata remote (4,540 entries, 1,257 values, 38.6 MiB packed, 24 commits), fetched into a fresh depth-1 clone of vex. 1.9.2's vci fetch downloaded 40.6 MB in 5.5 s; 2.0's downloads 1.8 MB in 1.0 s. A plan of one project (vex-web) then fetched 571 attestations and 50 manifests, 1.4 MB in two requests taking 0.5 s, and one of another project (rust) 157 attestations and 17 manifests, 1.5 MB. Fetch and both plans came to 4.7 MB instead of 40.6 MB, with 462 values never downloaded. 1.9.2's plan of vex-web took 1.1 s with everything already downloaded; 2.0's takes 1.0 to 1.4 s, the downloads included. Both versions printed identical plans, file for file. Every unit ran in both: that machine's toolchains differ from CI's, so this measures bytes and time, not skips. On a later copy of the same store (6,006 entries, 85 commits) the fetch is 2.1 MB, of which the 84 earlier commits are 36 KB: a fresh clone's download now grows with the number of keys, not with the size of the values or the length of the history.
What it changes in a repository: git accepts objects left out of a fetch only with a promisor remote configured, and asks that remote for anything it misses, code included. vci never makes the metadata remote one. It configures remote.vci-no-lazy-fetch, a promisor remote without a URL (it shows in git remote), so git gc and git fsck accept the values left out and a lookup of a missing object fails at once, as before, instead of downloading it with its history. The repository is never made shallow. Pushes send packs that are not thin, and vci push --rewrite-history fetches every value first. The remote must serve partial clones (uploadpack.allowFilter, as GitHub does); one that ignores the filter sends every value, as before. Details: the reference's "What a blobless metadata fetch changes in the repository".
Keyless attestation with the job's OIDC token
For CI to attest its own runs it needed an SSH private key, stored as a secret only main's job can read, rotated by hand, and readable by every process of that job. vci ci --oidc (the action's oidc: true, in a job with permissions: id-token: write) signs without one, the way npm publish --provenance and actions/attest do: an ephemeral P-256 key held in memory, a ten-minute certificate from Sigstore's Fulcio for the job's OIDC token, and an RFC 3161 timestamp per attestation. It uses GitHub's Sigstore instance (fulcio.githubapp.com, timestamp.githubapp.com) for public and private repositories alike, so no public log names every test unit; --oidc=public-good uses sigstore.dev's. Trust is .vci/identities.toml on the base branch: each [[identity]] names a principal and the claims a certificate must carry (issuer, repository, workflow file, ref, and optionally event, environment and runner type, as globs). A matching principal counts exactly like an allowed_signers one in no_skip_refs_except and attest_on_any_ref, and vci ci --oidc refuses to attest off main on the same terms as --key. Verification is offline, against the Sigstore trust root committed as .vci/sigstore-trusted-root.json (what gh attestation trusted-root prints): the certificate chains to it, a trusted timestamp puts the signature inside the certificate's ten minutes, its repository and workflow are on the issuer's host, and its identity matches an entry. No transparency log is checked. Any failure makes the unit run, and vci explain names the check. Main's runs and pull requests' runs of one workflow are separate signers in the store, so neither replaces the other's attestations. vci init --oidc writes both files; vci verify takes --identities and --trusted-root. Laptops keep SSH keys, and both kinds of attestation share the store and the policy.
First run on this repository's CI, on a pull request: a job with only permissions: id-token: write and no secret attested the 21 demo units (pytest, Go, Cargo, Rails, RSpec) with vci run --oidc, about 0.1 s per signature, and vci plan then skipped all 21 on those attestations (with the pull request's own commit as the base, for the demonstration; the workflow now checks the trust files from the base branch, and that version has not run yet). An envelope from that run is a test fixture, verified offline against GitHub's trust root.
One suite over N CI jobs
vci ci --partition I/N (the action's partition: I/N) runs only partition I's share of what the plan runs, so a suite that a runner used to split (cargo nextest run --partition hash:I/N) can run through vci ci --key --push and be attested. A unit belongs to the partition the first 8 bytes of its storage key (BLAKE3 of the test id) fall in, modulo N: the same in every job and on every machine, whatever the plan's order or what the other jobs do, so every unit runs in exactly one partition, and what the plan skips is skipped in all of them. Each job attests and publishes its own share; the next plan skips every partition's units. A project a job cannot list (cargo missing on one runner, a transient cargo metadata failure) runs whole in that job with a ::warning, since the job cannot know whether another listed it: such units may run in more than one job, never in none. The exit code is the partition's tests'; the audit log's ran holds its units and partition is {index, count, units, total, unlisted}. --partition 1/1 is vci ci without the flag. Partitions balance unit counts, not durations, and a cargo unit is a whole test target.
Proven by three clones running vci ci --fetch origin --partition I/3 --key --push at once on the node-test and cargo fixtures: the attested sets are disjoint, their union is the plan's run set, a fresh clone's plan then skips everything; and by a review's six-job run on a three-project repository (24 of 25 units attested, the deliberately failing file the one left). The review found that a job that could not list a project skipped it with exit 0 while partition 1 ran only its own share, so those units ran nowhere; fixed as above.
Rails: several files, one boot, attested per file
vci run and vci ci --key of several Rails or RSpec files used to start one process per file: on vex-api, 2,400 boots, hours, so CI never attested Rails. Now one process per runner boots the application once, then forks one process per file, one at a time; each runs only its own file and writes its own output, so every file's attestation records what a run of that file on its own records, with its own result and exit status. Before each fork the boot's database connections are closed (as Rails' parallelize does); what the batch cannot run that way (a group whose helper fails in the boot, a file's process without output, a runner vci does not recognise) runs again one process per file, and vci run says so. VCI_RAILS_PER_FILE=1 keeps one process per file; the collector's output and version (0.5.0) are unchanged, so existing Rails attestations stay valid. The collector also no longer breaks when a test stubs ENV[] or File.read by alias, as vex-api's Object#stub does (every later call recursed until the stack overflowed, so such a file was never attested).
Measured on vex-api (macOS, Ruby 3.4.9, MySQL 8): 50 files of the application group took 1,110 s instead of 2,035 s one process per file, with the same 47 attested and the same 3 refused, and input roots equal to the files' own runs. What remains per file is vex-api's own reseeding of its database in each process's first test (Oaken's replant_seed, about 17 s a file): with that moved into the test helper (an experiment, a trade-off for vex to make), the same 50 files took 265 s. A first version ran every file in one process and attributed observations to files; review reproduced eight ways a file's attestation then differed from its own run (an inherited failing test counted for the parent's file, a monkey patch or shared examples at one file's top level making another pass, parallelize_me! threads mixing files, a memoized helper serving later files without an input, and more). Forking per file removes all of them by construction, and an e2e test runs twenty files covering each finding both ways and compares attestations entry for entry. Findings: docs/spike-rails.md.
Upgrading
- Move the action to
@v2.@v1stays on 1.9.2. - Attestations made by 1.x verify under 2.0 as before; nothing re-runs.
- Moving a clone to 2.0 is one way. In a clone 2.0 has fetched into, vci 1.x's fetch and push fail (
An object with id ... could not be found: it reads every value) and its plan runs every unit whose attestation 2.0 has not fetched. Fresh CI clones are not affected; upgrade local installs along with CI. To go back:git fetch --refetch <metadata remote> '+refs/meta/main:refs/meta/remotes/main', thenrm -f .git/git-meta.sqlite* .git/vci-materialized-*(the reference's "Known gaps" has the details). vci prunereads every attestation, so on a large store it downloads about what a fetch used to.- Keyless: vci 1.x cannot verify a keyless attestation and runs its unit. GitHub renews its Sigstore intermediate yearly (the current one until 12 June 2027); keyless attestations under a new one fail until
.vci/sigstore-trusted-root.jsonis refreshed on the default branch. Any step of the attesting job can request its token, so pin the identity main skips on to main (ref,event, best anenvironmentlimited to the default branch).
1.9.2
17 October 2026. Until now a key whose principal is in no_skip_refs_except could attest only on no_skip_refs (main): a pull request's run with it attested nothing, so its tests ran again in the merge queue and a third time on main. policy.attest_on_any_ref lists the principals that may attest anywhere: give the repository's own pull requests a second CI key as a repository secret (forks never receive it), name its principal in both lists, and run the suites with vci ci --key ... --push on pull requests; what a pull request ran is then skipped in the merge queue and on main. The trade-off is explicit: a pull request from the repository lets main skip tests only it ran, so use it where those authors are trusted; the default stays refusing.
1.9.1
16 October 2026. A cargo target whose harness reports no tests (0 passed; 0 failed; 0 filtered out: a library without unit tests, a binary, a crate with no doctests) was refused as result no-tests, so every master push built and ran it again: on vex, 17 of 59 units, about 4 of the attest job's 6.7 minutes. It is now attested as a pass with 0 tests: cargo filters nothing, so the target stays empty wherever the same sources build, and the attestation saves its build. A custom harness that prints no summary, and a target whose every test is #[ignore]d, still refuse.
1.9.0
16 October 2026. An attestation embedded its unit's whole input manifest (every path and hash the tests read, every external crate, every hashed variable). On the 242-unit workspace of 1.8.2's entry, a median large attestation was 211 KB stored, its manifest 147 KB of a 158 KB payload, and the 84 units of one package stored the same two manifests 84 times. An attestation now carries manifestRef, its input root, and each distinct manifest is stored once as a git-meta value of its own (target project, key vci:manifest:<input root>, compact JSON rather than base64), shared by every unit and signer with that root. The manifest is not signed on its own: vci plan uses a stored manifest only when its recomputed root is the input root the attestation signed, so a forged manifest, like a missing one (manifest <root> not in the store: run `vci fetch` in vci explain), makes the unit run. Measured on 10 units of that workspace attested into a scratch remote: the attestations went from 667,512 bytes to 92,104 (6 to 20 KB each instead of 14 to 241 KB), the whole tree, with the 7 manifests they reference, from 667,512 to 395,759 bytes (-41%), and the pack a fresh clone downloads from 184,800 to 127,055 bytes (-31%). On a store that small, fetch and plan take 0.2 to 0.3 s with either version. What is left in an attestation is mostly the toolchain, the global manifest, waived taints and the signature.
When vci run (or vci ci --key) stores an attestation, it now deletes the same signer's earlier attestations of that unit on the same OS and architecture whose files differ and that were made on an earlier commit of the same line of history, one no other branch, remote-tracking branch or tag still contains. Attestations other branches still use, ones made on the same commit and anything it cannot read are kept, so the published tree holds about one attestation per unit and signer without a separate prune. The manifests only the deleted attestations referenced go with them. vci prune also deletes the stored manifests nothing references, and a fetch or push puts back a manifest another clone deleted while an attestation here still references it.
A fetch whose tip changed no longer has git-meta parse both metadata trees. When the local metadata commit is an ancestor of the fetched tip, vci applies only the entries git diff-tree reports, with the result git-meta's merge gives; the first fetch of a clone, side remotes, unpushed local metadata commits and any entry vci does not read exactly as git-meta does still go through git-meta's merge. On a copy of the 3805-attestation store with one attestation added, one deleted and one deletion record, such a fetch went from 31 s to 3.4 s. Not fixed yet: once the local metadata tree holds a deletion record, every fetch and push still parses the whole tree to check that the store covers it (11.8 s on that store, as with 1.8.2).
Upgrading. Attestations made before 1.9.0 verify as before. vci before 1.9.0 cannot read an attestation made by 1.9.0 (best failed statement: predicate), so its units run there: upgrade local installs along with CI, where @v1 follows. Published attestations keep their inline manifest until their unit is re-attested; vci prune --superseded && vci push --rewrite-history then drops the old versions from the published history. vci verify reads a referenced manifest from --manifest FILE, or else from the repository's store.
1.8.2
16 October 2026. On a store of 3800 attestations (1.8 GB of metadata, after 1.8.0's first CI run on a large workspace), vci push with nothing new took 206 s of CPU on a laptop and 14 minutes on a 4-core runner, and vci fetch 56 s: every pass over a metadata tree parsed it the way git-meta does, reading and verifying every attestation blob, and a push made several such passes. The keys of a tree now come from its paths alone, and a fetched tree is merged into the store only when its tip changed since the last time. Measured on that store against a local copy of the remote: a fetch with nothing new went from 51 s to 3 s, a push with nothing new from 206 s to 1.4 s. A fetch whose tip did change still parses the whole tree once (about 35 s there); making attestations small is the next release.
vci prune --superseded deletes, per unit and signer, every attestation but the one issued last (the older ones vouch for inputs the tree no longer has). vci push --rewrite-history publishes the metadata as one commit without parents, so a fetch downloads the attestations the tree holds rather than every version ever published; other clones merge onto the new history at their next fetch. Together they are how to shed a store's dead weight: vci prune --superseded && vci push --rewrite-history.
1.8.1
16 October 2026. CI runners often keep $CARGO_HOME under the checkout (.cargo-home/, git-ignored, mounted as a cache). The cargo adapter took the registry's extracted crates and git checkouts there for crates of the repository: it cleaned and rebuilt them at every warm-up (each package's warm-up ran twice), hashed every file of theirs as an input (git-ignored paths an attestation then depended on), and refused units whose build touched one of them during the run (input changed during the run: .cargo-home/registry/src/.../rustix-0.38.44). A $CARGO_HOME inside the repository is now external everywhere: its crates are verified against Cargo.lock like any registry crate, never walked, never cleaned, and the pre-run snapshot leaves it out. First seen on a 242-unit workspace's first CI attest run: 11 units refused for it, and 14 of 26 warm-ups doubled.
1.8.0
16 October 2026. The cargo adapter no longer runs one cargo test per unit, one unit at a time. vci run now makes one warm-up build per package with a requested unit (cargo test --no-run, with cargo's full parallelism), then a single cargo lane builds each unit alone with --no-run (so its features, artifacts and executable are its own, as before, and after the warm-up that is mostly a freshness check) while a pool of VCI_CARGO_JOBS workers (default: the CPUs) runs the test binaries directly, with the environment and working directory cargo test gives them. Doctest units still run through cargo, in the lane. Analysis shares directory walks, dep-info and source scans across the units of a run. vci ci runs the remaining units the same way. Attestations are unchanged: the same unit ids, inputs, argv and refusals, so existing attestations stay valid.
Measured on a 26-member, 242-unit workspace on a 14-CPU Mac with the compiler cache warm: all 242 units attested in one run in 653 s with the dependencies built (1304 s with nothing built), against 440 s for cargo nextest run --workspace on the same machine, which runs the tests but attests nothing. Six units: 39.6 s against 109.4 s for 1.7.1. The same workspace went from 13 of 242 units attested with 1.7.1 to 172, the rest being units with no tests (64) and failures on that machine (6).
Two refusals that kept most of a large workspace from attesting are gone. Integration tests of a package that has a binary target were never attested (cargo: no dep-info file for ... (artifacts ["target/vci/debug/<bin>"])): cargo copies the binary next to the build directory without its dep-info, which is now found through the deps/<name>-<hash> build it copies. A glob pattern in a string ("/*/\n" fed to an ignore-file parser) is no longer taken for a path the test reads. A file rustc read through a symlinked directory (jj/cli/docs -> ../docs) is recorded as the link plus the file at its real path, instead of a spelling the pre-run snapshot does not hold (input changed during the run: ... not found under this spelling before the run). And its source refers to <path>, which is not an input can now be waived: policy.ignore_taints matching cargo:undeclared-reads attests the unit with the path recorded as waived (declaring it under [[inputs]] still works and still records it as an input).
The repository snapshot vci run takes before and after a run (to refuse inputs that changed meanwhile) now walks in parallel and does not descend into nested repositories or git worktrees (agents' worktrees under a checkout: 5.4 M of one checkout's 5.7 M entries), only recording their top-level entry; submodules are still walked. On that checkout the snapshot went from 33 s to under half a second. Upgrade notes. Output of a cargo run is printed per unit as each finishes, not streamed; a failed test binary exits 101 as cargo test does. An input inside a nested repository is reported as input cannot be checked for changes rather than as changed.
1.7.1
16 October 2026. On a real monorepo with ignore_taints = ["*"], one unit in ten was still refused, by three of vci's own refusals that were not taints. They are now, waived by key and recorded in the attestation's waived like any other: outside-repository (input outside the repository: /proc/sys/net/core/somaxconn, /proc/self/exe, /etc/lsb-release; waived, the path is recorded as outside-repository:<path> and not hashed), wrote-inside-repository (waived, the written paths are neither inputs nor a refusal) and result:skipped (result passed (0 failed, 1 skipped or xfailed of 4; ...), recorded as result:skipped:1 of 4; vci plan accepts the skips only while the base policy waives result:skipped). A failed result is never waived, nor are vci: taints. The "why not attested" lines print these keys instead of input outside the repository, wrote inside the repository and result has skipped tests.
Writes under node_modules/.vite, node_modules/.vite-temp and node_modules/.cache (Vite's dependency optimizer writing during tests, babel-loader and similar caches) are no longer writes, with no policy needed.
The trade-off is 1.7.0's: a kernel parameter, the OS release file or a skipped test could change a result elsewhere, and the attestation records that it was waived. Upgrade notes. A pre-1.7.1 vci plan refuses attestations with a waived skip.
1.7.0
16 October 2026. A unit whose collector saw something it cannot record, a child process, a socket, the whole environment, a raw syscall, a dependency without a version, used to be refused. policy.ignore_taints waives those refusals by pattern over the taint key the "why not attested" summary prints: ["*"] for everything, or a narrower list such as ["child_process.*", "process.env.enumerate", "go:syscall"]. The unit is attested, the waived taints are recorded in the attestation and shown in the vci run output, and vci plan accepts the attestation only while the base commit's policy still waives every recorded taint; otherwise the new waived check fails and vci explain names the taint. vci: taints, which mean vci's own record is broken, are never waived. go_allow_net = true is now the same as ignore_taints = ["go:net"].
The trade-off, stated by the user who asked for it: it is fine if it is not 100% deterministic, the important thing is optimising for CI cost. The attestation says what was observed and could not be recorded, and the policy that accepts it lives on the base branch.
Upgrade notes. A pre-1.7.0 vci plan refuses attestations made with ignore_taints. The verification check for waived taints is now named waived.
1.6.2
16 October 2026. vci ci and vci plan take --project NAME (repeatable): only the named [[projects]] entries are listed, probed, verified and run; the others are not touched, so a job that owns one suite needs only that suite's toolchain. The GitHub Action gets a matching project input. This is what lets a monorepo gate each existing test job through vci: vex's node --test (vex-web) job runs vci ci --project vex-web, which skips what a trusted signer already ran and runs the rest.
node:test leftovers now run in one node --test process per project, with the test script's own options, hooks, env assignments and reporter, instead of one process per file; when nothing was skipped the script runs as is, when everything was skipped nothing starts. The recording runs are unchanged.
1.6.1
16 October 2026. vci run <files...> no longer aborts when a project that holds none of the requested units fails to list. In a monorepo whose root project is a Cargo workspace every path is under its directory, so a cargo probe that could not run on a CI runner killed a run that only asked for node:test and Go units. Such a project now produces one warning and the run continues; a requested path that resolves to no unit still fails, with the listing errors appended as the likely cause.
1.6.0
16 October 2026. The environment policy becomes three short lists, and nobody exports anything.
[env]
set = { TZ = "UTC", LANG = "C.UTF-8", CI = "true" } # vci sets these in every test process, locally and in CI
hash = ["FEATURE_*"] # taken from the machine; a different value re-runs the test
ignore = ["HOME", "SCCACHE_*"] # visible to the tests, never a reason to re-run
- Everything else is removed from the test process.
setwins overhash,hashoverignore, the user's lists over the built-ins, which shrink to one short table per runner in the environment docs: the variables a runtime reads natively where the collector cannot see the read, and the tooling variables each runner needs. The implicit "everyRUST*,GO*,PYTHON*,RAILS_*is hashed whenever present" families are gone. - With
setthe values live invci.toml, already an input of every attestation, and apply on every machine: a monorepo with node:test and Rails projects needs no shell profile block. vci envprints the effective policy per project, with each variable's class and source, and where a base policy differs.vci explainnames the variable and its class in env failures.global,pass_throughandenvin[[env.files]]still work with one deprecation warning;mode = "loose"still behaves as before, deprecated.
Upgrade notes. The env-config digest changed, so every attestation made before 1.6.0 re-runs once. The @v1 action before this version cannot parse set; an older vci reading such a base policy runs everything until @v1 resolves to 1.6.0.
1.5.0
16 October 2026. On the demo benchmark a job where nothing changed used to spend 12 seconds in vci before deciding there was nothing to run: a download with the gh CLI, a fetch, a plan, then vci ci planning again from scratch, every project probed one after another and each Rails project booted. Measured now on the same benchmark: vci ci --fetch with nothing changed takes 0.96 s, plus the binary download.
- The action plans once: for
command: ciit runs a singlevci ci --fetch, which prints the plan for the step summary and runs what is left; the counts come from the audit log. --fetch [REMOTE]onvci ciandvci planfetches attestations while the projects are probed, and the fetch itself is one git round trip instead of two. A failed fetch is still a warning and fails open.- Projects are probed concurrently: test listing, toolchain and externals per project on their own threads, verdicts too. Output order is unchanged.
- The Rails toolchain is read without booting the application: one short Ruby process with the bundle set up reports Ruby, Bundler's resolved gems, the test frameworks, the libraries and
config/database.yml, in the same format as before, so existing attestations still verify. The plan boots the app only when a candidate attestation records a native library, once per project. The attested run still boots and checks everything. What the plan no longer detects: an app that cannot boot on the verifier for a reason outside its recorded inputs and toolchain; the attested run already booted it, and full runs onno_skip_refsstill do. - pytest test files are listed without starting Python, following pytest's own rules for the config file,
testpaths,python_files,norecursedirs, virtualenvs and the collection options inaddopts; an end-to-end test proves the list equalspytest --collect-only. Files thatcollect_ignoreor a plugin would exclude are listed and run on their own, and a file that collects no tests counts as passing, as in a whole-suite run. - Lighter
go listandcargo metadata --no-deps --offlineon the plan side; the action downloads the binary with curl first. VCI_TIMING=1prints per-phase timings of every plan on stderr; the benchmark workflow uses it.
Upgrade notes. Nothing changes in what is recorded, so existing attestations verify under 1.5.0. Pin @v1 or @v1.5.0.
1.4.1
15 October 2026.
The action never compiles vci in your CI
The GitHub Action downloads the release binary recorded in its release/manifest.txt, verifies its checksum and puts it on the path, in about a second. That was already what @v1 did; what changes is the fallback. A commit of the action with no published binary used to compile vci from source, about two minutes on ubuntu-latest. It now stops in its first step with an error that says which release to use instead. Compiling is opt-in with build-from-source: true (prebuilt: false is a deprecated alias) and, when asked for, uses a cargo cache. Jobs without the gh CLI download with curl or wget; the checksum check is the same.
Version tags now carry their own binaries
Every version tag before this one, v1.0.0 to v1.4.0, points at the commit the binaries were built from, whose manifest still names the previous release, so @v1.4.0 would compile while @v1 downloads. Use @v1, or @v1.4.1 and later: releases are now built as drafts and published by scripts/publish-release.sh, which creates the version tag on the commit that records the manifest, checks that the commit's sources match the binaries it names, and moves v1 there. The example workflows no longer cargo install vci either; they download the binary v1 records.
Measured
Measured on this repository: a benchmark workflow times every stage of vci on the demo projects as its own step, from the plain suite to attesting every unit to a commit with nothing to run.
1.4.0
15 October 2026. vci stops controlling the Rails test database. It is the test engine's business, as it is when you run bin/rails test or rspec: maintain_test_schema!, fixtures and transactional tests give each test its starting state.
- The databases
config/database.ymlconfigures for the test environment are used as they are. vci no longer redirects SQLite files to fresh temp files or loads the schema itself. Thebin/rails db:test:preparechild process Active Record starts when the schema is not current is accepted; a test's own child processes are still refused. - What the database holds is never an input. Reads, checks and writes of the test environment's SQLite files and their
-wal,-shmand-journalfiles are not recorded, and a listing ofdb/leaves them out, so rows written behind the tests' back, or a fresh checkout without any database, change no verdict. Another file an entry ofconfig/database.ymlnames that is not a SQLite database is still recorded like any file. - The schema file of every test database (
db/schema.rb,db/cache_schema.rb, ...) is an input of every test that loads Active Record, even on a fresh database where only thedb:test:preparechild reads it. - MySQL, Trilogy and PostgreSQL test databases work with no policy. The toolchain's database is the adapter name only (
sqlite3,mysql2,postgresql), compared exactly; no server version. - Still refused: another SQLite file (including a name that merely contains
mode=memory),ATTACH, SQLite extensions, a database client a test opens itself, and Active Record connecting to a server or database that is not one of the test environment's (establish_connectionelsewhere, an adapter'snew_client). - Rails files of a project run one at a time in
vci run, andvci ciruns the rest in onebin/rails testorrspecprocess per project.
What you give up. An attestation no longer promises that the test was independent of leftover database state. It promises what CI promises: the test passed on a database the engine set up.
Upgrade notes. policy.rails_allow_db is removed. A vci.toml that still sets it parses, the key has no effect, and vci run and vci plan warn once; remove it. The Ruby collector is now 0.5.0, so existing Rails and RSpec attestations run once more in CI.
1.3.0
15 October 2026. Two things people asked for after the first deployments: CI should not re-run on commit B what it already ran on commit A, and an attestation made on a Mac should count on a Linux runner.
CI attests its own runs
vci ci can now sign what it runs. With --key, the units the plan says to run go through the same path as vci run: inputs recorded, attestation signed and stored. --push publishes them, --ttl sets their lifetime and --remote the destination. On a ref where policy runs everything, such as main, every unit is attested, so the next commit skips each unit whose inputs did not change. A commit that only touches the docs plans everything as SKIP and vci ci finishes without starting a test runner.
The new policy key no_skip_refs_except = ["github-actions"] names the allowed_signers principals whose attestations may still skip on a no_skip_refs ref. Any other signer is refused there by name, so a laptop attestation never skips on main; only the CI key does. Off those refs a key listed there attests nothing, so a pull request run cannot sign with the main key even if a workflow passes it.
The GitHub Action gains signing-key, ttl and push inputs and an attested output. The key must come from a GitHub Environment restricted to the default branch, never a repository secret: every process of the job can read it, and a same-repository pull request runs its own copy of the workflow. The job needs permissions: contents: write to push. Attesting in CI has the setup and the two-job workflow.
The audit log gains attested, pushed, notAttested and attestErrors. A project that cannot run with the collectors runs alone without attesting; the others are still attested, nothing runs twice, and the exit code covers every test.
Proven on this repository's own main branch: the first push attested 13 units, the next push, which changed one pytest module, ran only that test, and a docs-only push ran nothing for pytest, Go and Rust.
Attest on macOS, skip on Linux
policy.libraries = "any" (default "exact"; per project, or per glob in [[policy.platform_overrides]]) makes an attestation made on one platform count on another when the libraries differ only by build. Native libraries opened through FFI, such as Active Storage's libvips, match by library name instead of file name and hash: libvips.42.dylib on a Mac matches libvips.so.42 on Ubuntu, provided libvips is loaded on both sides. The SQLite, libyaml and time-zone data versions of the Rails toolchain, and SQLite and OpenSSL of the pytest toolchain, are not compared. Whether each is loaded still is, so a Python built without _sqlite3 never satisfies an attestation that used it. Ruby and Python versions, the bundle, the environment and every input are compared exactly as before. vci explain prints the policy and each difference it accepted. Nothing changes on the attestation side, so existing attestations verify under the new policy.
Fixed
- A bundle installed inside the repository, as ruby/setup-ruby's
bundler-cache: truedoes atvendor/bundle, refused every Rails and RSpec file withruby:read-outside-bundle. Bundler's install directory is now a gem root like any other: nothing under it is a repository input, its gems are externals by version, and RubyGems' and Bundler's own bookkeeping there is not recorded. Still refused, now for bundles anywhere: loading, reading, listing or evaluating anything under a gem root that is not a gem of the bundle, including throughGem.read_binary,Bundler.read_file,Gem::Util.glob_files_in_dirandGem::Specification.load, and a test's own check ofvendor/bundleor of a gem's real path. Require shadowing probes skip gem directories, which makes a Rails file about a third faster to attest. - Under
libraries = "exact", a native library loaded under another file name is reported as such, with a note that"any"would match it by name.
Upgrade notes. The Ruby collector is now 0.4.2, so existing Rails and RSpec attestations run once more in CI. The @v1 action before this version ignores the signing-key input and rejects no_skip_refs_except and libraries in a base policy, so everything runs; move to 1.3.0 before adding either key.
1.2.0
14 October 2026. Everything in this release came out of setting vci up in a large Rails + Rust + Go + TypeScript monorepo and measuring what refused to attest.
Rails apps with Active Storage can attest
Rails 8.1 loads ruby-vips at boot through Active Storage, and its FFI load tainted every test file, so a whole app attested nothing. Now a native library load is recorded as a hashed external (native:libvips) instead of a refusal; only calls into attached functions refuse. ruby-vips gets Ruby-level hooks: image loaders, savers and thumbnails record the image file as an input, while unknown vips operations, descriptors, text rendering and file-typed options still refuse. In the new fixture a model test attests, a test that attaches and analyses an image attests with the PNG as an input (editing the PNG re-runs only that test), and an arbitrary FFI call is refused.
Also fixed
- Collector files reached through a symlinked install prefix (Homebrew's
opt/) were recorded as inputs outside the repository, which blocked everynode-testfile on Homebrew installs. - Minitest files that never load Rails were refused with a spurious toolchain mismatch (
rails ""). - A failed
ffi_libload no longer taints. - OpenSSL certificate files and
SSL_CERT_FILE/SSL_CERT_DIR, read in C, are now inputs. - Found by the adversarial pass over the new rules and closed before release: paths starting with
~or|, case-insensitive vips operation names, directory symlinks into exempt locations,Method#super_methodaround FFI wrappers, and uid/identity reads.
New
policy.allow_superuser_mismatch(default off) lets CI runners that run as root accept attestations made as a normal user.vci explainnames the key when the check fails. Read the warning in the configuration page before turning it on.vci rungroups refusals by a stable reason with full counts instead of capping at twelve groups, andvci run --summary FILEwrites a JSON summary of every unit and its reasons for scripted coverage measurement.vci initin a linked git worktree says that the metadata remote went into the shared.git/config.
Upgrade note. The Ruby collector is now 0.4.1, so existing Rails attestations run once more in CI.
1.1.0
14 October 2026.
New adapter: node-test
For projects that run Node's built-in test runner (node --test). It parses the package.json test script, including leading env assignments, --import hooks, --experimental-strip-types, quoted globs and ${VAR:+…} expansions, and supports pnpm workspaces. Each file runs in its own process with a collector preloaded ahead of the user's hooks; a reporter counts results so skipped, todo, only-filtered, load-error and early-exit files are never attested. Works on Node 22, 24 and 26.
From adversarial testing of the new adapter
pretestandposttestscripts and.npmrcnode-optionsare inputs or refusals.NODE_OPTIONSset in an--env-filefollows the environment policy.- The
node --testorchestrator process is observed, not only the per-file children. - pnpm
file:dependencies are hashed by content, not identified by version. - The host time zone is an input when
TZis unset.
1.0.0
13 October 2026. First stable release.
- Six runners: Vitest, pytest (uv), Go, Cargo, Rails (Minitest) and RSpec, on macOS and Linux.
- Attestations signed with your SSH key and stored as git-meta metadata on
refs/meta/main. - The GitHub Action, used as
uses: PandelisZ/cryptographically-verifiable-ci-runner@v1, downloads a checksum-verified prebuilt binary and falls back to a source build. - An agent skill (
npx skills add PandelisZ/cryptographically-verifiable-ci-runner) and a Claude Code plugin. - Licensed MIT; installable with
brew install pandelisz/tap/vci.
0.3.0
2 October 2026. RSpec support in the Rails adapter: spec files run one per process through rspec --options .rspec, with the repository's .rspec as an input and ~/.rspec, the XDG options file and .rspec-local never read. Pending, skipped and focus-filtered examples are refused. Fifteen issues from the adversarial pass were fixed, including a non-zero exit after RSpec finished being ignored and a stub of File.directory? blinding the collector.
0.2.0
1 October 2026. Attestation storage moved from custom refs/attest/* refs to git-meta metadata on refs/meta/main, attached to the path of the test unit and readable with git meta get; the git-meta library is built into vci, so CI needs nothing extra. The Rails (Minitest) adapter arrived with a Ruby collector, a fresh SQLite database per file, and the rails_allow_db waiver for database servers.
0.1.0 and 0.1.1
28 September 2026. The first releases: Vitest, pytest, Go and Cargo adapters, each adversarially tested; the composite GitHub Action with prebuilt, checksum-pinned binaries for Linux and macOS on x86_64 and arm64; Turborepo-style environment policy. 0.1.1 relicensed the project and bumped the binaries.