Choosing a store

Where vci keeps attestations, which store to pick, and how to set up each one. Since 2.8.0 there are four; git-meta stays the default, and a repository that configures nothing works as before.

vci run signs one attestation per unit that passed and stores it with the input manifest it refers to. vci plan reads them back, verifies them, and skips a unit when one proves its inputs unchanged. The attestation store is where those records live.

The store is never trusted. vci plan checks every attestation's signature against the base commit's .vci/allowed_signers and identities, and recomputes every input manifest's root, whichever store held it. A record that is forged, altered, replayed or deleted can only make a unit run. Write access to a store therefore matters for availability only: a writer can make CI slower by deleting records, and cannot make it skip a test it should run. Read access shows test ids, signer ids and input manifests (paths and content hashes), so give it to whoever may read the repository.

The four stores

StoreWhere the records areServerPick it when
git-meta
(the default)
.git/git-meta.sqlite, shared by the clone's worktrees, exchanged on refs/meta/main of a git remotenone: git is the transportYou want nothing new to run or pay for.
SQLiteone file, .git/vci-store.sqlitenoneOne machine: a laptop, a single long-lived runner, trying vci out. Nothing is shared unless you copy the file.
PostgreSQLtables in schema vci, one namespace per repositorya PostgreSQL server you runYou already run PostgreSQL and want every machine to see every attestation the moment it is stored.
libSQL (Turso)tables per repository in a libSQL database: Turso's hosted service or a sqld of your own; optionally read through a local replicaTurso runs it, or your own sqldThe same, without running a database server yourself.

What fetch and push mean

On a shared store, vci ci stores nothing without --push. With git-meta nothing a CI job attests leaves it unless the job pushes, and the shared stores keep that rule. Without --push it signs, then prints:

vci: not storing 2 attestation(s): the libsql store is shared and vci ci was not given --push
vci ci: 2 unit(s) attested (not stored: the libsql store is shared and --push was not given)

The action passes --push only with signing-key or oidc (and push: true, the default), so in practice only the job you give CI's key stores anything. A developer's vci run always stores.

What each costs

Measured on synthetic stores on a laptop in October 2026, and on vex's Rails suite against Turso's hosted service on 27 October 2026; the benchmarks page has the tables and their caveats.

Which store a repository uses

vci picks the store once per command, from the first of:

  1. VCI_STORE: git-meta, sqlite, postgres or libsql. Never a URL: a value with :// is refused and not printed.
  2. The scheme of VCI_STORE_URL: postgres:// or postgresql://, sqlite:, libsql://. An https:// URL needs VCI_STORE=libsql beside it.
  3. [store] backend in the working tree's vci.toml.
  4. The scheme of [store] url there.
  5. git-meta.

vci store info says which store was picked, where each setting came from, whether the store can be read, and how many attestations it holds. It never prints a password or a token, so its output is safe to paste into an issue or a chat:

$ vci store info
store: git-meta
location: .git/git-meta.sqlite (exchanged on refs/meta/main)
settings:
  backend: git-meta (the default)
  connect_timeout: 5s (the default)
  timeout: 30s (the default)
capabilities: shared writes no, lazy values yes, keeps hints yes
exchange: vci fetch and vci push, on refs/meta/main
status: ready
attestations: 4

It exits 0 when the store can be read, also before anything is stored, and 2 otherwise, after printing what it knows and the error. --format json gives the same as JSON. Run it first whenever a plan says reading attestations from ….

Keeping the store's credentials safe

A shared store has a credential: a Turso token, or a PostgreSQL password. vci handles it like this, and asks the same of you.

  • Never a credential in vci.toml. Every checkout carries it, a pull request's included. vci refuses a [store] url holding a password, a token, an authToken or a #fragment, without printing it. vci.toml may say where the server is ([store] url) and which command prints the credential ([store] token_command).
  • A token from the environment goes only to a URL from the environment. VCI_STORE_TOKEN with the URL from vci.toml is refused, because a pull request could change that URL to a server of its own and collect CI's token. CI sets both VCI_STORE_URL and VCI_STORE_TOKEN, and so does a machine given a read-only token of its own (step 6); a laptop that reads the token through token_command sets neither.
  • Tests can still find the token. vci removes VCI_STORE_URL, VCI_STORE_TOKEN and VCI_STORE from its own environment as it starts and from every environment it builds for a test, probe, hook or token command. The environment a process started with stays readable to every process of the same user, though (/proc/<pid>/environ on Linux, ps eww on macOS), and so does the CI step's shell, which set the variables. vci cannot hide them there.
  • So a job that runs untrusted code gets a read-only token, or none. Only trusted jobs hold a token that can write: the default branch's, or pull requests from the same repository, whose authors could change the workflow and read its secrets anyway.
  • A credential covers every repository in its database. Namespaces keep each repository's records apart, but a Turso token, or a PostgreSQL role granted on schema vci, reads the test ids, file paths and content hashes of every repository there, and one that can write can delete them. Use one database per repository, or per group of repositories with the same readers (on PostgreSQL, a database or a schema of its own, [store.postgres] schema, each with its own roles).
  • On your own machine, [store] url is as sensitive as token_command. The token the command prints goes to whatever [store] url names, and both come from the checkout. Review a change to either as you would a change to a test helper. In a checkout you have not reviewed, run vci with VCI_STORE=git-meta: it then ignores both and never runs the command.
  • Never print a token. Pipe it from turso db tokens create straight into gh secret set or your secrets manager, and never pass it as a command-line argument, where it lands in shell history and process listings. To see why a token command fails, run it yourself, in your own terminal, with its standard output discarded (below). That discards the token a command that works prints on standard output. Its standard error still shows, and a wrapper or a pipeline stage can log the token there, so never have an agent run it, or any other command that reads a secret: an agent tells you what failed and the command.

Set up Turso (libSQL)

Turso hosts libSQL databases, so this is the shared store with no server to run. One database can hold many repositories, each in tables of its own, but its tokens open all of them, so give a database to each repository, or to each group of repositories with the same readers (above). The steps below set up CI on GitHub Actions and the laptops and agents that attest.

  1. Install the Turso CLI and sign in

    On macOS:

    brew install tursodatabase/tap/turso

    On Linux:

    curl -sSfL https://get.tur.so/install.sh | bash

    Then sign in, adding --headless on a machine without a browser:

    turso auth login
  2. Create the database

    turso db create vci
    turso db show vci --url

    Add --group <name> to turso db create if your organization has more than one group. turso db show prints the URL, libsql://vci-<organization>.<region>.turso.io. The URL is not a secret: it says where the database is, and the token is what lets a client in. Create an ordinary database, without --tursodb: a TursoDB (MVCC) database's URL starts with turso://, which vci does not accept ($VCI_STORE_URL is not a libsql location, or, without VCI_STORE=libsql, a warning that git-meta ignores it).

  3. Decide who gets which token

    TokenMade withFor
    can writeturso db tokens create vcithe default branch's CI job, which stores what it attests; developers whose vci run should store attestations; an agent's sandbox that should store, through a token of its own (step 6)
    read-onlyturso db tokens create vci --read-onlypull-request jobs, and any machine that only plans or runs code you have not reviewed, an agent's sandbox included. Its plans read the store; its writes are refused (libsql store refused to write: … the token may not write) and the tests' exit code is kept

    Tokens do not expire unless you say so: --expiration 90d sets a lifetime in days. An expired or revoked token makes the store unavailable, and every test then runs until you replace it. turso db tokens create prints the token alone on standard output, so pipe it to where it is kept (next step) instead of copying it from the screen.

  4. Store CI's values as GitHub secrets

    Create the environment vci-attest, limited to the default branch, if CI does not sign with a key of its own yet (attesting in CI shows how). Then, from a clone of the repository:

    turso db show vci --url | gh secret set VCI_STORE_URL
    turso db tokens create vci --read-only | gh secret set VCI_STORE_TOKEN
    turso db tokens create vci | gh secret set VCI_STORE_TOKEN --env vci-attest

    gh secret set reads the value from standard input when it has no --body, so the token never appears on screen or on a command line. A secret of an environment wins over a repository secret of the same name in the jobs that use that environment: pull-request jobs get the read-only token, and the main-branch job, which runs in vci-attest, gets the one that can write. Pull requests from forks get no secrets at all, and those Dependabot opens get only Dependabot's secrets. vci treats an empty variable as unset, so their jobs fall back to vci.toml. Before [store] is committed (step 6) that is git-meta: the action fetches refs/meta/main, and the attestations already there, verified as always, can still skip units. Once it is committed, vci runs its token_command, which is neither installed nor signed in on GitHub's runners, so the plan says running everything: … the token_command (…) was not found and every test runs.

  5. Give both jobs' vci step the two variables

    The same two lines in each job, on the vci step. The main-branch job also attests (signing-key, or oidc: true), and the action then runs vci ci --key … --push, which stores what it attested. On a shared store --push writes to the database, not to git, so that job needs no contents: write.

    name: test
    
    on:
      pull_request:
      push:
        branches: [main]
    
    permissions:
      contents: read
    
    jobs:
      # Pull requests: read the store, skip what is attested, store nothing.
      test:
        runs-on: ubuntu-latest
        if: github.event_name == 'pull_request'
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0      # the base commit, and the repository id the store's tables are named after
          # ...your toolchain steps...
          - uses: PandelisZ/cryptographically-verifiable-ci-runner@v2
            env:
              VCI_STORE_URL: ${{ secrets.VCI_STORE_URL }}
              VCI_STORE_TOKEN: ${{ secrets.VCI_STORE_TOKEN }}   # the repository secret: read-only
    
      # Pushes to main: run what is not attested, attest it, store it.
      test-main:
        runs-on: ubuntu-latest
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        environment: vci-attest
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
          # ...your toolchain steps...
          - uses: PandelisZ/cryptographically-verifiable-ci-runner@v2
            env:
              VCI_STORE_URL: ${{ secrets.VCI_STORE_URL }}
              VCI_STORE_TOKEN: ${{ secrets.VCI_STORE_TOKEN }}   # vci-attest's secret: can write
            with:
              signing-key: ${{ secrets.VCI_SIGNING_KEY }}   # or oidc: true (with permissions: id-token: write)

    Every step of a job can read the job's env. On the vci step, the variables reach vci (the action is a composite action, whose steps inherit them) and no earlier step, so the toolchain and setup actions never see the token that can write. The tests run inside the vci step, though, and can read what it was given (see the box above), which is why the pull-request job's token is read-only.

    Without the action, give the two variables to the step that runs vci ci, run vci ci --base-ref "$BASE" --key "$RUNNER_TEMP/vci-key" --push in the main-branch job, with the key written to that file as the workflow vci init prints writes it ((umask 077 && printf '%s\n' "$KEY" > "$RUNNER_TEMP/vci-key")). Never pass the key itself as --key's value: vci takes one that starts with -----BEGIN for an unexpected argument and prints it whole in the error, into the job's log. Leave out --push everywhere else. Remove a vci fetch --remote origin step if the workflow has one, as the workflow vci init prints and this repository's examples do: on any store but git-meta it stops the job with vci: error: the libsql store does not support a remote (exit 2) before any test runs. vci ci --fetch origin only warns, so it can stay.

  6. Point laptops and agents at it

    Developers read a token through a command named in vci.toml, signed in to its tool on their own machines, so that nobody exports anything; an agent working in a developer's own session reads it the same way. An agent's sandbox gets a token of its own (below). First put a token that can write where that command will read it, from standard input as before. In Composal, with --from-stdin, never --value, and without --ci, which would hand the secret to CI runners as an environment variable:

    turso db tokens create vci | com secrets set vci-store-token --org acme --from-stdin --env-key VCI_STORE_TOKEN

    Or in 1Password, where op item create given - reads the item as JSON on standard input:

    turso db tokens create vci \
      | jq -R '{title: "vci-store", category: "API_CREDENTIAL", fields: [{id: "credential", label: "credential", type: "CONCEALED", value: .}]}' \
      | op item create --vault Engineering -

    Then commit a [store] table that names the database and that command:

    [store]
    backend = "libsql"
    url = "libsql://vci-acme.aws-eu-west-1.turso.io"   # never a credential
    token_command = ["com", "secrets", "get", "vci-store-token", "--org", "acme"]

    token_command is an array run directly, without a shell, or a string run with sh -c. It runs only when a command first reads the store, at most once per vci process, in the repository's top level, and must print the token alone on one line within 60 seconds. It cannot prompt, so sign in to the tool first. Some choices:

    # Composal: each read of the organization's secret is audited; admins only
    token_command = ["com", "secrets", "get", "vci-store-token", "--org", "acme"]
    
    # 1Password: anyone who can read the vault
    token_command = ["op", "read", "op://Engineering/vci-store/credential"]
    
    # a file: weaker (below). A string runs with sh -c, so ~ is expanded
    token_command = "cat ~/.config/vci/store-token"

    com secrets get reads an organization's secret for its admins only (its help says "admin only, audited"). A developer who is not an admin gets a failing command, and their plans run everything. If such developers should store attestations too, keep the token where they can all read it, such as a shared 1Password vault.

    A file is the weakest choice: the token sits on disk, readable by anything you run, never audited, and signing out does not revoke it. If you use one anyway, write it without showing it: mkdir -p ~/.config/vci && (umask 077; turso db tokens create vci > ~/.config/vci/store-token). In an array, ~ is not expanded: give the full path.

    One committed token_command gives every machine the same token, through a person's own sign-in. A machine that should only plan, or that runs code nobody has reviewed, such as an agent's sandbox, gets a token of its own through its environment instead, made for this database alone, with --read-only unless that machine should store. Never sign such a machine in to com or to a person's 1Password account: the code it runs could then read every secret that account can, the token that can write included. Set both variables where its session starts, from the sandbox's own secret settings, or read from a 1Password service account allowed only a vault that holds nothing but that token, never the Engineering vault above, which holds the token that can write. For example, with OP_SERVICE_ACCOUNT_TOKEN set: export VCI_STORE_URL=libsql://vci-acme.aws-eu-west-1.turso.io and export VCI_STORE_TOKEN="$(op read op://vci-sandbox/vci-store-read-only/credential)". Both are needed (a token from the environment is never sent to vci.toml's URL), and they win over [store]. Every process of that user can read them, its tests included, which a token that can only read allows for.

    In CI the two variables win over [store], and the command does not run while VCI_STORE_TOKEN is set and not empty. GitHub gives a secret that does not exist (a fork's pull request, a secret never set) as an empty string, which vci treats as unset: the command then runs in CI, and fails where its tool is not installed or not signed in (troubleshooting). Do not sign a CI runner in to that tool, or such a job would get the token that can write.

    A vci before 2.8.0 rejects a vci.toml with [store] and runs every test, so commit the table only once every runner and developer has 2.8.0 or later; until then, select the store through the environment.

  7. Check it

    vci store info

    An uncommitted [store] in the working tree is enough for this check, since vci reads the working tree's vci.toml. Before anything is stored it says:

    store: libsql
    location: libsql://vci-acme.aws-eu-west-1.turso.io namespace <repository id> (remote)
    settings:
      backend: libsql (vci.toml [store])
      url: libsql://vci-acme.aws-eu-west-1.turso.io (vci.toml [store])
      mode: remote (vci.toml [store])
      token: (from token_command: com secrets get vci-store-token --org acme) (vci.toml [store])
      namespace: (the repository id) (the default)
      connect_timeout: 5s (the default)
      timeout: 30s (the default)
    capabilities: shared writes yes, lazy values no, keeps hints yes
    exchange: none (vci fetch and vci push have nothing to do)
    status: absent: nothing is stored yet (the first `vci run` creates it)
    attestations: 0

    In CI the token line says token: (set) ($VCI_STORE_TOKEN). The token itself is never printed.

  8. Expect the first runs to run everything

    The store starts empty. Until something is stored, a plan warns no attestations are available in the libsql store (the first `vci run` creates it) and every unit runs. The default branch's next full run fills it: no_skip_refs makes that run run everything, and with CI's key it attests every unit that passes and stores it. Developers' and agents' vci run add theirs. The first write with a token that can write creates the repository's tables; a read-only token cannot (libsql store is not set up: the first vci run with a token allowed to create tables creates them).

    Switching store copies nothing and invalidates nothing. The attestations on refs/meta/main stay there, read only by a job that falls back to git-meta (a fork's pull request before [store] is committed), and each unit runs once more before the new store has it. There is no command that copies a store yet.

  9. Check CI

    After the first push to main, the main-branch job's log has a line per stored unit (vci: attested … as libsql …) and vci ci: N unit(s) attested. If that line ends (not stored: the libsql store is shared and --push was not given), the job attested but stored nothing: the action has push: false, or, without the action, vci ci was not given --push. A job with neither signing-key nor oidc attests nothing and prints no such line. The plan of the next pull request shows SKIP lines for what main attested, and no running everything: reading attestations from libsql.

    Nothing in CI shows that the repository secret is read-only, since pull-request jobs never write. If you are not sure it came from --read-only, make every token of the database invalid with turso db tokens invalidate vci, then set the secrets of steps 4 and 6 again.

Replica mode

A machine that keeps its clone, such as a laptop or a self-hosted runner, can read a local copy of the database instead of asking the server for every plan:

[store.libsql]
replica = true   # or VCI_STORE_SYNC=replica in the environment

The copy is .git/vci-store.libsql. Each command that reads the store first syncs what changed since the last sync, in one request; with nothing new that took 0.01 to 0.04 s against a local server. Writes go to the server, then to the copy. A sync that fails prints vci: warning: cannot sync the libsql replica …; reading the replica as it is, and plans and runs go on with the copy as it was, which can only make units run. An ephemeral runner starts with no copy, and its first sync reads the whole namespace, about a millisecond a record (1.4 to 4.4 s for 1,600 to 6,500 records from vex's runners to Turso), a cost that grows with the namespace, while a remote plan reads only the attestations it checks. On those fresh runners the replica was no faster than remote mode (the pilot): keep it for laptops and long-lived machines.

Namespaces and shallow clones

Each repository's tables are named after its namespace: by default the repository id, its first commit (git rev-list --max-parents=0 HEAD). Forks and mirrors share it; unrelated repositories never do. A shallow clone cannot know its first commit, so there vci refuses the default:

attestation store: the libsql store keeps each repository's records under a namespace, by default the repository id (its first commit); this is a shallow clone, whose first commit is where the clone was cut: set $VCI_STORE_NAMESPACE (the same value wherever the store is shared)

Keep fetch-depth: 0 in CI, which vci needs for the base commit anyway, or set VCI_STORE_NAMESPACE to the same value everywhere. [store.libsql] namespace sets it in vci.toml.

When it does not work

A store that cannot be used fails open. vci plan prints a warning and running everything: reading attestations from libsql: …, every test runs, and nothing is skipped by mistake. vci run and vci ci run the tests, refuse to store each unit with the reason, and keep the tests' exit code. vci store info, vci fetch, vci push and vci prune exit 2 with the error.

Message (shortened)What it means
the server asks for a token: set $VCI_STORE_TOKEN (or [store] token_command) (HTTP 401)No token reached vci, and vci.toml has no token_command. In CI: VCI_STORE_URL is set but VCI_STORE_TOKEN is empty (the secret was never set, or is not in the vci step's env:). Locally: VCI_STORE_URL is exported without a token.
the server refused the token in $VCI_STORE_TOKEN or the URL's authToken, or … from [store] token_command (…)A token for another database, revoked or expired. Make a new one (step 3).
libsql store refused to write: … the token may not writeA read-only token where vci stores. Expected in pull-request jobs; elsewhere use a token that can write.
$VCI_STORE_TOKEN is set but the store's URL comes from vci.toml [store] urlThe trust rule above. In CI set VCI_STORE_URL too; on a laptop unset VCI_STORE_TOKEN so the command supplies the token.
vci.toml [store] url carries a credential in its query (authToken, password, ...) (it was not printed)Take the token out of vci.toml, and make a new one: it was in a file every checkout carries.
the token_command (…) was not found in CIThe job's VCI_STORE_TOKEN is empty, so vci ran [store] token_command, whose tool CI's runners do not have. A fork's pull request gets no secrets, so it runs everything by design. A pull request Dependabot opens gets only Dependabot's secrets: if its jobs should read the store, set the two there too, the token read-only (gh secret set VCI_STORE_TOKEN --app dependabot, from standard input as in step 4). Otherwise the secret was never set (the repository's, for pull-request jobs) or is not in the vci step's env:: set it (step 4). Running the command on a laptop tells you nothing here.
the token_command (…) exited 1; vci shows none of its output, which may hold the credential: run the command yourself with its standard output discarded (> /dev/null) to see whyThe command failed on your machine. Run it yourself, in your own terminal, as the message says, from the repository's top level: com secrets get vci-store-token --org acme > /dev/null. Only standard output is discarded: the error shows on standard error, and so would the token if a wrapper or a pipeline stage logged it there, so do not run it where others can see the screen, and do not have an agent run it. Usual causes: not signed in (com login, op signin), not allowed to read the secret (com secrets get is for the organization's admins), a command that wanted to prompt, or one stopped after 60 s. This is 2.8.1's message. 2.8.0 says only "run the command yourself to see why": discard standard output all the same. 2.8.0 also leaves most characters a shell reads specially without quotes (&, ;, |, #, <, (, glob characters), so pasted as it is, the shell can end the command or start a comment before the redirect, and print the token: copy the command from vci.toml instead, with every argument in single quotes ('com' 'secrets' 'get' …). A string token_command runs with sh -c, so run it the same way, sh -c '<the string>' > /dev/null, with each ' in it written as '\'': pasted bare, a || b > /dev/null discards only what b prints.
cannot reach libsql://…, libsql store: the server's first answer timed out after 5sThe server is down or unreachable. Connecting and the first answer take at most 5 s (VCI_STORE_CONNECT_TIMEOUT_SECS), later requests 30 s (VCI_STORE_TIMEOUT_SECS), and after the first failure every later call of the command fails at once, so it costs one timeout per command.
no libsql database there (HTTP 404)The URL names no database: check turso db show vci --url.
the server takes no more writes (blocked, as a database over its quota is, or full)The database is over its plan's quota or at its size limit. Plans still read it; nothing is stored. vci prune keeps the tables small.
this is a shallow clone … set $VCI_STORE_NAMESPACESee namespaces.

Set up PostgreSQL

vci needs a database and three roles: one that creates the schema, one that writes for trusted CI, and one that reads. The roles cover every repository whose records are in schema vci, so set this up once per group of repositories with the same readers (a database each, or a schema each with [store.postgres] schema).

  1. Create the database and the roles

    -- in psql, as an administrator
    CREATE DATABASE ci;
    CREATE ROLE vci_owner  LOGIN;   -- creates the schema
    CREATE ROLE vci_writer LOGIN;   -- trusted CI: the default branch
    CREATE ROLE vci_reader LOGIN;   -- pull-request jobs, and anyone who only plans
    GRANT CREATE ON DATABASE ci TO vci_owner;
    \c ci
    REVOKE CREATE ON SCHEMA public FROM PUBLIC;
    \password vci_owner
    \password vci_writer
    \password vci_reader

    PostgreSQL 14 and earlier, and clusters upgraded from them, let every role create objects in schema public. There vci_reader could create a function that vci, run later as vci_writer or any other role, calls in place of a built-in one, with that role's rights. The REVOKE takes that away; on a cluster made by PostgreSQL 15 or later it changes nothing. \password asks for the new password without showing it and sends it to the server already encrypted, so it lands in neither psql's history nor the server's log, as PASSWORD '…' in the SQL would. Make each password in your password manager from letters and digits only, so the URLs below need no percent-encoding, and paste it at the prompts.

  2. Let the owner's first run create the schema

    The first write by a role allowed to create schema vci creates it. Until then reads find an empty store, and other roles get postgres store is not set up: the first vci run with a role allowed to create schema vci creates it. Run one vci run as vci_owner, with the URL in that command's environment only. Replace UNIT with one of your test files, paste the line, press Enter, then paste vci_owner's password at the prompt:

    printf 'vci_owner password: '; read -rs pw; echo; VCI_STORE_URL="postgres://vci_owner:$pw@db.example.com/ci" vci run UNIT; unset pw

    read -s shows nothing of what you paste, and the shell's history keeps the line with $pw in it, not the password. Keep it to one line: when several lines are pasted at once, read takes the next one as the password, and the password you then paste runs as a command and lands in the history. Do not add a comment to it: zsh, the default shell on macOS, reads # at the prompt as a word unless interactivecomments is set. The line, and those of step 3, are for bash and zsh: fish and dash reject read -rs, and ksh's -s saves what you paste to its history, so in those shells run bash first. Then grant the other roles in database ci, where the schema is, as the administrator (or as vci_owner, which owns it):

    \c ci
    GRANT USAGE ON SCHEMA vci TO vci_writer, vci_reader;
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA vci TO vci_writer;
    GRANT SELECT ON ALL TABLES IN SCHEMA vci TO vci_reader;
    GRANT INSERT, UPDATE ON vci.memo, vci.flags TO vci_reader;   -- optional: the plan's hints

    A reader's vci run refuses each unit (postgres store refused to write: permission denied for table manifests) and keeps the tests' exit code. A job that runs untrusted code should get vci_reader without the optional grant, or no URL.

  3. Give CI the URL

    For PostgreSQL the password goes in the URL, percent-encoded where it holds @, /, ? or : (letters and digits need nothing). Build each URL without showing it. Paste this line, press Enter, and paste vci_reader's password at the prompt; it sets the repository secret VCI_STORE_URL:

    printf 'vci_reader password: '; read -rs pw; echo; printf 'postgres://vci_reader:%s@db.example.com:5432/ci' "$pw" | gh secret set VCI_STORE_URL; unset pw

    Then the same with this line and vci_writer's password, for the vci-attest environment's secret:

    printf 'vci_writer password: '; read -rs pw; echo; printf 'postgres://vci_writer:%s@db.example.com:5432/ci' "$pw" | gh secret set VCI_STORE_URL --env vci-attest; unset pw

    Paste one line at a time, as in step 2. printf is built into the shell, so the password is never a program's argument. Then put VCI_STORE_URL: ${{ secrets.VCI_STORE_URL }} in each job's vci step env, as for Turso. VCI_STORE_TOKEN is not used.

  4. Point laptops at it

    [store]
    url = "postgres://vci_writer@db.example.com/ci"   # a user, never a password
    token_command = ["op", "read", "op://Engineering/vci-postgres/password"]

    The command prints the password. vci store info then shows password: (from token_command: …) and the URL with its user masked, postgres://***@db.example.com/ci.

Without sslmode, TLS is required, except for localhost, 127.0.0.1, ::1 and Unix sockets. Certificates are always verified, against the webpki roots and the CA file in ?sslrootcert=; a local server with a self-signed certificate needs sslrootcert or sslmode=disable. Back up with pg_dump -n vci. The full reference is in REFERENCE.md, PostgreSQL.

Set up SQLite

No server and no credential. Select it with the environment first:

export VCI_STORE=sqlite
vci store info
vci run

Before anything is stored, vci store info says status: absent: nothing is stored yet (the first `vci run` creates it), and the first vci run creates .git/vci-store.sqlite. Once everyone has 2.8.0, select it in vci.toml:

[store]
backend = "sqlite"

The file lives in the git common directory, so a clone's worktrees share it. Keep it off network file systems: SQLite's write-ahead log needs shared memory. To carry it between CI jobs, cache .git/vci-store.sqlite; each command checkpoints the log as it closes the store, so the one file holds everything. Jobs that run in parallel each save their own copy, and the last one saved wins.

For agents

The vci agent skill carries these rules for coding agents. In short: before the first vci command in a checkout (every one but vci --version, init, env, metrics and update reads the store, vci verify included), check on its own whether the checkout is reviewed. It is when it is a repository the user works on, on the default branch or the user's own branch, git diff origin/main -- :/vci.toml (the default branch in place of main; :/ is the top level from any directory) changes neither [store] url nor token_command unless the user made that change, and a script in the repository that token_command names is unchanged too. Otherwise (someone else's branch or pull request, a clone the user has not reviewed, a check that fails), put VCI_STORE=git-meta in front of every vci command until the user has reviewed it. Then: run vci store info to see which store a repository uses (its output is safe to paste); never print VCI_STORE_URL, VCI_STORE_TOKEN or what a token_command prints; never write a credential into vci.toml; in a sandbox of its own, read the store only with VCI_STORE_URL and a token for that database alone set in its environment, never through a person's com or 1Password session; never run a token_command or any other command that reads a secret, not even with its output discarded, but tell the user what failed and the command, for them to run in their own terminal; run turso db tokens create only with the user's go-ahead and piped straight into gh secret set or com secrets set --from-stdin; vci push on git-meta, and on a shared store let vci run store and leave vci ci --push to trusted CI jobs.