kache 0.26.0

Zero-copy, content-addressed build cache for Rust, C/C++ and more, with S3 and shared-filesystem remotes.
---
title: Configuration
description: Configure Kache with TOML files and environment variables
---

Kache resolves settings in this order:

1. environment variable
2. config file
3. [host config](#host-config)
4. built-in default

`KACHE_DISABLED`, `KACHE_CONFIG`, `KACHE_HOST_CONFIG`, `KACHE_SOCKET_PATH`, logging controls, progress controls, and credentials are operational settings. They do not all have TOML equivalents.

## Config file

Open the editor:

```bash
kache config
```

Or edit TOML directly. Kache chooses the first file in this order:

1. `KACHE_CONFIG`
2. the nearest `.kache.toml`, walking up from the current directory
3. `$XDG_CONFIG_HOME/kache/config.toml` or `~/.config/kache/config.toml`

The editor covers common fields and preserves advanced tables it does not expose.

```toml title="~/.config/kache/config.toml"
[cache]
cache_executables = true

[cache.remote]
type = "s3"
bucket = "my-build-cache"
region = "us-east-1"
```

Use `type = "filesystem"` with `path = "/mnt/kache"` for a shared filesystem remote. Only `s3` and `filesystem` are compiled remote backends.

### Host config

A machine owner can set keys for every build on the machine in `/etc/kache/config.toml`. Kache reads it underneath the chosen file and merges the two key by key: a key the chosen file sets wins, and a key it leaves unset comes from the host file. Environment variables still win over both.

This reaches CI jobs too. kache-action points `KACHE_CONFIG` at a file of its own that only sets `[cache.remote]`, so a host file like this applies to every job on the runner:

```toml title="/etc/kache/config.toml"
[cache]
input_predictions = true
```

- `[cache.remote]`, `[cache.planner]` and `[cache.volumes]` are replaced whole. If the chosen file declares one, none of the host file's keys for it apply.
- A remote set only through `KACHE_S3_*` variables also replaces the host remote, as long as the chosen file declares no `[cache.remote]`. The same goes for `KACHE_PLANNER_*` and `[cache.planner]`, so a host planner token is never sent to an endpoint the environment picked.
- The host file never contributes `[workspace]` declarations. Those belong to a project's `.kache.toml`.
- The host file cannot set `ignore_env`. Kache ignores it with a warning, so environment variables always win over both files.
- A host file that cannot be read or parsed is ignored with a warning. `kache doctor` reports it, and lists each host key with whatever overrides it.
- `KACHE_HOST_CONFIG` reads another path instead. An empty value turns the host layer off.
- `kache config` edits the chosen file, never the host file.

## Core settings

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_CACHE_DIR` | `cache.local_store` | OS cache directory | Persistent store |
| `KACHE_TRUST_DOMAIN` | none | unset | Isolate the local store under `$KACHE_CACHE_DIR/<label>/`. One path component (`[A-Za-z0-9._-]`). Invalid labels are ignored. Do not set this on public or fork jobs. |
| `KACHE_RUNTIME_DIR` | `cache.runtime_dir` | local store | Socket, locks, logs, state, and session markers |
| none | `cache.volumes` | unset | Volume-local store shards, e.g. `[cache.volumes] "D:" = "D:/kache-store"`. Builds on a mapped volume share inodes there instead of copying across mounts. Unmapped volumes use the main store. |
| `KACHE_SOCKET_PATH` | none | `<runtime_dir>/daemon.sock` | Absolute daemon endpoint override |
| `KACHE_MAX_SIZE` | `cache.local_max_size` | 5% of the cache disk, floored at 5GiB and capped at 100GiB | Registered blob bytes allowed before GC. An explicit size always wins. `none` is not allowed. |
| `KACHE_AUTO_GC` | `cache.auto_gc` | `true` | Spawn throttled background GC under size pressure |
| `KACHE_GC_MAX_AGE_HOURS` | `cache.gc_max_age_hours` | `0` | Automatic age retention; `0` disables it |
| `KACHE_GC_EVICT_SHARED` | `cache.gc_evict_shared` | `false` | Restore namespace-first GC for entries retained by build outputs |
| `KACHE_INDEX_AUTO_COMPACT` | `cache.index_auto_compact` | `true` | Let the daemon heal the blob index and compact `index.db` when the machine is quiet. See [Daemon index maintenance](#daemon-index-maintenance). |
| `KACHE_MIN_STORE_COMPILE_MS` | `cache.min_store_compile_ms` | `0` | Skip local retention for faster compiles; `0` stores all eligible results |
| `KACHE_COMPRESSION_LEVEL` | `cache.compression_level` | `3` | Zstd level, clamped to 1 through 22 |
| none | `cache.event_log_max_size` | `10MiB` | Rotate the event log after this size |
| none | `cache.event_log_keep_lines` | `1000` | Lines retained when the event log rotates |
| `KACHE_DISABLED` | none | `false` | Pass all compiler work through when set to `1` or `true` |
| `KACHE_LOCAL_ONLY` | `cache.local_only` | `false` | Ignore all remote and planner configuration while keeping local caching |
| `KACHE_REMOTE_READONLY` | `cache.remote_readonly` | `false` | Allow remote reads but suppress remote writes. Untrusted CI (pull requests, tags, unprotected branches) also forces this on; `=0` does not turn that off. |
| `KACHE_SCHEDULER` | `cache.scheduler` | `true` | Pace real compiler processes across concurrent builds after a miss. Hits and passthroughs never wait. `0` or `false` turns it off. |

On macOS the default store is `~/Library/Caches/kache`; on Linux it is `$XDG_CACHE_HOME/kache` or `~/.cache/kache`; on Windows it is under `%LOCALAPPDATA%`.

## GC and shared build outputs

`cache.local_max_size` measures blob bytes registered in Kache's store. A Cargo target directory can hardlink or block-clone those blobs. In that case the store name and the build output refer to the same disk blocks.

By default, GC keeps an entry when removing its last store name would reclaim zero bytes. This preserves a usable cache hit. It also means the registered store size can remain above `local_max_size` until the target output is removed. `kache gc` reports these entries as unreclaimable and separates store bytes removed from disk bytes reclaimed.

Automatic eviction has one trigger. A sweep starts when the store is above 110% of `local_max_size` and evicts down to 90%. Three things can ask for one: a build that has just stored an entry (checked at most every 5 minutes), the daemon after an upload, and the daemon's 6-hour sweep. All three use the same trigger. `kache gc` still evicts from 100%.

While a daemon is running, it does the sweeping. A build that finds the store over the trigger sends the daemon a hint and continues; it does not wait for the sweep, and it never starts a daemon for this. Hints that arrive during a sweep are dropped. With no daemon, or a daemon too old to know the hint, the build starts a detached `kache gc` as before. That worker sweeps a second time two minutes later only if the first sweep had to skip entries a running build was using.

Every automatic sweep records where it left the store, whichever of the three started it. When an automatic sweep leaves the store over budget, the next automatic sweep waits 10 minutes. Each further sweep that ends over budget doubles the wait, up to 2 hours. The wait ends early when the store grows by more than 10% of `local_max_size`, and resets once a sweep brings the store back under budget. The wait applies to all three, so one sweep that cannot free enough holds the others back too. The 6-hour sweep still expires entries by `cache.gc_max_age_hours` and removes duplicates during a wait; only its size eviction is skipped. The state lives in `auto-gc-backoff.json` in the cache dir. `kache gc` ignores it.

A sweep pauses for 150 ms after every 50 ms of index writes, and after each removal that finds a build holding the index write lock, so builds storing entries are not held up by it.

To reclaim the blocks, remove the retaining build output. For known stale targets, preview Kache's machine-local registry before deleting anything:

```bash
kache clean --tracked --stale 14d --dry-run
kache clean --tracked --stale 14d --yes
```

If enforcing the store namespace limit matters more than retaining hits, restore the older policy:

```toml title="~/.config/kache/config.toml"
[cache]
gc_evict_shared = true
```

The equivalent environment setting is `KACHE_GC_EVICT_SHARED=1`. With this option, GC can drop an entry even though target files still hold all its blocks. The registered store size falls, but disk usage does not. A later build must compile or download that entry again. Explicit `purge` and repair operations remain unconditional regardless of this setting.

### Daemon index maintenance

The daemon looks after `index.db` on its own. It checks 60 seconds after it starts, every 5 minutes after that, and once at shutdown. Each check runs two steps in order: it heals the blob index, then compacts the file. Both steps hold the index write lock while they work, so both wait for a quiet machine: no compile holds a scheduler permit and the daemon has received no build request for 60 seconds. Stats polling and an open `kache monitor` do not count as build requests. Neither step overlaps a GC sweep.

Set `cache.index_auto_compact = false` or `KACHE_INDEX_AUTO_COMPACT=0` to turn both steps off. `kache doctor --repair` does the same work on demand either way.

#### Blob index heal

The index keeps two tables about blobs: one row per blob with its size and a reference count, and one row per entry and blob it uses. Both are derived from the entries themselves. They can drift apart: a blob row keeps a reference count that no entry accounts for. Such a blob still counts toward `max_size`, but eviction only removes blobs through entries, so GC cannot free it. A store with enough of them stays over its budget and sweeps again and again without getting under it.

Every check compares the two tables with one read-only query. When they disagree and the machine is quiet, the daemon rebuilds both tables from the `meta.json` of every committed entry, in one transaction. Blob files that no row references after that are deleted once they are an hour old, the same grace GC uses, so a store in the middle of a write is not disturbed. The compaction step that follows returns the freed pages to the disk.

The heal has no fallback for a machine that is never quiet. It reads every entry's metadata under the write lock, so its duration grows with the store, and drift costs disk space, never a wrong cache hit. At shutdown it runs only when the store has at most 25,000 entries. If another process is using the index when it starts, it stops at once and a later check retries.

The rebuild refuses to run if it cannot trust an entry: a `meta.json` that is missing or does not parse, or a blob that is missing or has the wrong size. It then changes nothing, logs one warning that names the entry, and waits 6 hours before it tries again. The wait is kept across daemon restarts, in `blob-heal.json` in the cache dir. To fix it, run `kache doctor --repair` when no build is running: it removes the corrupt entry and rebuilds the blob index.

#### Index compaction

Deleting rows from `index.db` frees pages inside the file but does not shrink it. After a large prune or a schema migration, most of the file can be free pages. The daemon compacts the index with a SQLite `VACUUM` once free pages reach 64 MiB and a quarter of the file.

A `VACUUM` blocks other index users while it runs, so the daemon picks its moment:

- On a quiet machine, when the live part of the index is at most 8 GiB. If anything else is using the index at that moment, the attempt stops at once and a later check retries.
- At daemon shutdown, under the same permit rule, when the live part of the index is at most 1 GiB.
- On a machine that is never quiet: after the index has stayed over the threshold for 6 hours, and only when the live part is at most 1 GiB. Index reads continue during the rewrite. Index writes wait for it, which at that size is a few seconds, inside the wrappers' normal index timeout. The 6 hours are counted across daemon restarts, in `index-compact.json` in the cache dir.

Compaction needs free disk of about twice the live index size, not the file size: a 45 GB index with 1 GB live needs about 2 GB. Without that room the attempt is skipped and retried later. The free pages stay reusable in the meantime, so a skipped compaction costs disk space only.

A live index above 8 GiB is never compacted automatically, because the rewrite would hold the index for too long. The daemon logs that it skipped it; run `kache doctor --repair` when no build is running.

## Compiler policy

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_CACHE_EXECUTABLES` | `cache.cache_executables` | Linux/macOS: `true`; Windows: `false` | Cache eligible Rust bins and test executables |
| `KACHE_CACHE_CC_LINKS` | `cache.cache_cc_links` | `false` | Cache C/C++ whole-program links and their sidecars. Off until path and reproducibility gates pass. |
| `KACHE_TRUST_CODEGEN_BACKENDS` | `cache.trust_codegen_backends` | `false` | Cache rustc compiles that load a codegen backend dylib (`-Zcodegen-backend=<path>`), keyed by the dylib's contents. The backend must be passed as a path, and libraries it loads at run time are not part of the key. Off because a backend can write files rustc does not report, and a cache hit would not restore them. |
| `KACHE_CLEAN_INCREMENTAL` | `cache.clean_incremental` | `true` | Remove tracked incremental directories during cleanup |
| `KACHE_ADAPTIVE_INCREMENTAL` | `cache.adaptive_incremental` | `true` | Learn rapidly changing Cargo units and give them isolated incremental state |
| `KACHE_PRESERVE_INCREMENTAL` | `cache.preserve_incremental` | `false` | Force eligible Cargo units onto isolated incremental state |
| `KACHE_INCREMENTAL_CRATES` | `cache.incremental_crates` | `[]` | Force listed rustc crate names onto that incremental path |
| `KACHE_MODIFIED_INPUT_GUARD` | `cache.modified_input_guard` | `false` | Do not store a result when a keyed input changed at or after build start |
| `KACHE_INPUT_PREDICTIONS` | `cache.input_predictions` | `false` | Remember each crate's source closure and derive the next key from it, skipping the dep-info pre-pass when every recorded input still checks out |
| `KACHE_BUILD_SCRIPT_CACHE` | none | `true` (Unix) | Cache build-script runs: restore `OUT_DIR` and the script's output instead of running it when its binary, Cargo environment and declared `rerun-if-*` inputs match a recorded run. See [Build scripts](/how-it-works/build-scripts). |
| `KACHE_FALLBACK` | `cache.fallback` | none | Wrapper used for ordinary Kache passthroughs |
| none | `cache.exclude` | `[]` | Source-path globs that bypass Kache |
| none | `cache.bypass_crates` | `[]` | Exact rustc crate names that bypass Kache |
| none | `cache.bypass_argv` | `[]` | Argument substrings that bypass Kache |
| none | `cache.bypass_env` | `[]` | `NAME` or `NAME=VALUE` rules that bypass Kache |
| `KACHE_CC_EXTRA_ALLOWLIST_FLAGS` | `cc.extra_allowlist_flags` | `[]` | Exact C/C++ flag spellings accepted in addition to the built-in model |

Bypass rules can only reduce caching:

```toml title=".kache.toml"
[cache]
exclude = ["vendor/problematic/**"]
bypass_crates = ["generated_bindings"]
bypass_argv = ["--cfg", "uncacheable_mode"]
bypass_env = ["SPECIAL_BUILD=1"]
```

## Cache-key controls

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_KEY_SALT` | `cache.key_salt` | none | Opaque value folded into every key |
| `KACHE_BASE_DIR` | none | none | One extra path prefix normalized as `<BASE_DIR>` |
| none | `paths.base_dirs` | `[]` | Additional absolute prefixes with distinct stable sentinels |
| `KACHE_PATH_ONLY_ENV_VARS` | `cache.path_only_env_vars` | `[]` | Path locator variables eligible for safe normalization |
| `KACHE_KEY_ENV_VARS` | `cache.key_env_vars` | `[]` | Environment read by proc macros but not reported by rustc |
| `KACHE_VERIFY_RESTORES` | none | `off` | Re-hash `sampled` or `always` before restoring |
| `KACHE_VERIFY` | none | `off` | Recompile each rustc cache hit and compare it to the restored files |

`KACHE_VERIFY` is qualification for cache-key changes. Set it to `1` or `true` to recompile every rustc cache hit into a staging directory and diff those artifacts against the files just restored. Leave it unset (or `0` / `false`) in ordinary builds: each hit pays a full compile. There is no `kache build` command for this.

Path and debug-info bytes that differ only as embedded absolute paths are reported as `path-debug` and do not fail the build. Remaining byte differences are `content`. In both cases the restored files stay in place so the build continues; the wrapper records the class on the hit event as `verify_compare` (and in tracing when logging is on).

`KACHE_VERIFY_RESTORES` still only re-hashes stored blobs before restore. It does not recompile.

Use a key salt when an unobserved toolchain component changes output:

```toml
[cache]
key_salt = "sysroot-2026-08"
```

Keep `paths.base_dirs` narrow. Normalizing unrelated roots to stable sentinels can make different inputs appear equal.

`path_only_env_vars` widens the built-in `OUT_DIR` rule to other variables
whose value only locates a generated file. A plain `VAR` entry still has to
pass Kache's checks: some source must show `env!("VAR")` (or `option_env!`)
inside an `include!`, `include_str!` or `include_bytes!` argument, and no
source may read the value anywhere else. Kache also needs dep-info to list a
file under the value, and it reads the proof only from `.rs` sources. When a
listed variable is not normalized, `KACHE_LOG=trace` prints which of those
conditions failed next to the `env_dep:` line.

A crate that includes generated code through another crate's macro, such as
`tonic::include_proto!` or `lalrpop_mod!`, never spells `env!("OUT_DIR")`
itself, so the check finds no proof and the absolute path stays in the key.
That crate then rebuilds in every checkout. Assert it per crate, using rustc's
crate name (underscores):

```toml
[cache]
path_only_env_vars = ["my_proto_crate:OUT_DIR"]
```

A `crate:VAR` entry skips the source checks for that crate and variable only,
so use it when you have read the crate and know it never bakes the value into
its artifacts. `CARGO_MANIFEST_DIR` is never accepted, in either form: rustc
embeds it in crate metadata, and every crate's sources live under it, so the
checks above would pass for a value that does change the artifact. Kache warns
when a list names it and ignores the entry.

`key_env_vars` accepts exact names and trailing-star prefix patterns:

```toml
[cache]
key_env_vars = ["BOLTFFI_*"]
```

Values are hashed exactly and are not written to event logs. A changing value such as a job ID will destroy hit rate.

## Hidden compile-time inputs

Some macros read files that rustc does not report. Put `kache.toml` without a leading dot next to the crate manifest:

```toml title="crates/db/kache.toml"
extra_inputs = [
  ".sqlx/**/*.json",
  "migrations/**/*.sql",
]
```

Matches are relative to that crate. Kache folds their paths and contents into its key and adds bounded watches to Cargo dep-info. Invalid or unreadable declarations fail closed.

For a workspace-root input used by a provider package and its direct consumers:

```toml title=".kache.toml"
[[workspace.extra_inputs]]
crates = ["query-macros"]
inputs = [".sqlx/**/*.json"]
propagate_to_dependents = true
```

Workspace rules are accepted only from the `.kache.toml` beside the active Cargo workspace manifest. After adding a declaration to a target Cargo already considers fresh, rebuild that package once.

## Remote settings

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| none | `cache.remote.type` | inferred as S3 for legacy config | `s3` or `filesystem` |
| `KACHE_S3_BUCKET` | `cache.remote.bucket` | none | S3 bucket |
| `KACHE_S3_ENDPOINT` | `cache.remote.endpoint` | AWS | Custom S3-compatible endpoint |
| `KACHE_S3_REGION` | `cache.remote.region` | `us-east-1` | Region |
| `KACHE_S3_PREFIX` | `cache.remote.prefix` | `artifacts` | Object prefix |
| `KACHE_S3_PROFILE` | `cache.remote.profile` | credential-chain default | AWS profile |
| `KACHE_S3_USER_AGENT` | `cache.remote.user_agent` | none | Custom HTTP User-Agent |
| none | `cache.remote.path` | none | Filesystem remote root |
| none | `cache.remote.atomic_write_dir` | `<path>/.kache-tmp` | Same-filesystem staging directory |
| `KACHE_S3_CONCURRENCY` | `cache.s3_concurrency` | `16` | Concurrent operations for either backend |
| `KACHE_S3_POOL_IDLE_SECS` | `cache.s3_pool_idle_secs` | `300` | S3 connection pool idle time |
| `KACHE_REMOTE_RESTORE_TIMEOUT_SECS` | `cache.remote_restore_timeout_secs` | `300` | Daemon operation deadline; wrapper demand remains capped at three seconds |
| `KACHE_REMOTE_NEGATIVE_TTL_SECS` | `cache.remote_negative_ttl_secs` | `60` | Cache definitive remote 404 results |

Credentials resolve from explicit `KACHE_S3_ACCESS_KEY` and `KACHE_S3_SECRET_KEY`, standard AWS variables, the selected AWS profile, web identity, container credentials, then instance credentials.

See [S3 setup](/docs/remote-cache/s3-setup) or [filesystem setup](/docs/remote-cache/filesystem-setup).

## Prefetch and planner

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_PREFETCH_ENABLED` | `cache.prefetch_enabled` | `true` | Enable speculative manifest and planner prefetch |
| `KACHE_REMOTE_KEY_CACHE_REFRESH_SECS` | `cache.remote_key_cache_refresh_secs` | `60` | Refresh the fallback planner's remote key index; `0` means initial load only |
| `KACHE_PREFETCH_MAX_KEYS` | `cache.prefetch_max_keys` | `2000` | Entries allowed per plan; `0` is unlimited |
| `KACHE_PREFETCH_MAX_BYTES` | `cache.prefetch_max_bytes` | `2GiB` | Compressed bytes allowed per plan; in-flight downloads may finish |
| `KACHE_PREFETCH_DEADLINE_SECS` | `cache.prefetch_deadline_secs` | `300` | Time during which a plan may start downloads; `0` disables the deadline |
| `KACHE_PLANNER_ENDPOINT` | `cache.planner.endpoint` | none | Planner service URL |
| `KACHE_PLANNER_TIMEOUT_MS` | `cache.planner.timeout_ms` | `750` | Planner request timeout |
| `KACHE_PLANNER_TOKEN` | `cache.planner.token` | none | Bearer token |
| `KACHE_NAMESPACE` | none | none | Shard namespace used by manifest upload and prefetch |

On a long-lived runner with a warm local store, speculative prefetch may be unnecessary:

```toml
[cache]
prefetch_enabled = false
```

Exact remote lookup and background upload continue to work.

### Planner authentication

A planner that requires authentication gets a bearer from the first of these that applies:

1. `KACHE_PLANNER_TOKEN` / `cache.planner.token`, when set.
2. In GitHub Actions, the job's OIDC ID token. Its audience is the planner's base URL, so no other endpoint can replay it. The job needs `permissions: id-token: write`, and the planner must trust the repository's owner.
3. Your Kunobi session. Run `kache login` once: it trusts this planner and stores a session that the daemon refreshes quietly from then on. The session is kept per planner client, so it never replaces another Kunobi tool's login.

The second and third are sent only to a planner reached over HTTPS (or on loopback), and a session only to a planner you logged in to, so a planner URL picked up from a project's config cannot collect your credentials. Use `kache login --device` on a machine without a browser, and `kache logout` to revoke it.

Nothing here prompts during a build. Without a usable bearer the planner answers 401 and kache plans locally, the same as when the planner is unreachable. If the planner's login issuer changes after you first trusted it, `kache login` refuses; run `kache login --retrust` when the change is expected.

## Telemetry

Kache does not push metrics to a collector. It writes OTLP JSON files that Kartero imports from CI artifacts, the same way nightly benches already work.

Two signals, never mixed:

- `kache.bench.*` — one-shot bench gauges (duration, speedup, verdict) from `kache-scenario`
- `kache.cache.*` — cache counters (uploads, prefetch, store size) from `kache telemetry write`

```bash
kache telemetry write ./cache-otlp --scenario bench-firefox --phase warm
```

That writes `metrics.otlp.json` and `schema_version` in the directory. `--scenario` must match `kache.bench.project`. `--phase` distinguishes cold from warm: the bench stops the daemon between phases, so counters are per daemon lifetime. The rustc wrapper does not write these files. The planner service still exposes Prometheus `/metrics` for cluster scrape.

### Machine telemetry

On a host where several jobs share one cache, the payload reports `kache.cache.store.physical_size`: registered blob bytes, the figure GC compares with `kache.cache.store.max`. `kache.cache.store.size` is the sum of logical entry sizes and may differ. `kache.cache.store.unowned.size` is the part of the physical size held by blobs no entry maps to, which GC cannot free, and `kache.cache.store.refcount_drift` counts the blobs whose reference count disagrees with the entries mapped to them, unowned ones included. Both are zero on a healthy store and return to zero after a [blob index heal](#blob-index-heal). It also reports `kache.cache.index.size` (`index.db` plus `-wal`), `kache.cache.index.wal.size`, `kache.cache.index.free.size` (the space `index.db` holds in free pages, which compaction returns to the disk), and `kache.cache.index.rowid_high_water` per table. Last-run GC gauges include duration, pinned and unreclaimable entries, failed evictions, stale WAL snapshot failures, and recently accessed entries skipped before reading their metadata. The shipper adds host identity. The index probe is read-only with a 25 ms lock timeout and omits unavailable figures. `kache stats --json` carries the index figures and GC summary as `index_bytes`, `index_wal_bytes`, `index_rowid_high_water` and `gc`.

`rowid_high_water` reports the largest rowid in each table. Tables written with `INSERT OR REPLACE` (`file_hashes`, `input_predictions`, `eviction_tombstones`) allocate new rowids on replacement, so this value can grow without adding rows. C/C++ memos keep their IDs on update; their high-water value does not count refreshes. Use `kache.cache.index.size` to measure index bytes.

To keep per-build numbers at machine level, add `--record` to `kache report`:

```bash
kache report --format github --since 24h --record
```

After rendering the report as usual, it appends one JSON line to `telemetry/sessions.jsonl` in the cache dir: the report's summary and timing breakdown (hit overhead, key, dep-info pre-pass, restore, wait, prediction mismatches), plus load average, CPUs and index size at that moment. Use it at the end of a CI job: the report reads the runtime dir, which the job's cleanup deletes. The root is recorded as a hash, never as a path.

To record every report on a host without passing the flag, set `KACHE_RECORD_SESSIONS=1` or `[cache] record_sessions = true`. It is off by default, and with neither setting nor `--record`, `kache report` writes nothing.

Every GC driver (the daemon's sweep and its eviction after an upload, the auto-GC worker, and local `kache gc` modes) records its run in `gc_stats.json`. With `record_sessions` on, each run also appends a version 2 line to `telemetry/gc-runs.jsonl` with the driver, outcome, duration, lock failures, stale WAL snapshot failures, and recently accessed entries skipped before metadata reads. `gc_stats.json` keeps only the latest run; the history is what a backend adds up. Skip counts are attempts across policy passes, not unique entries.

Session and GC history are opt-in. `kache telemetry write` reads index rows and files locally; it sends nothing over the network. `telemetry/sessions.jsonl` exists only after `kache report --record` or with `record_sessions` on, and `telemetry/gc-runs.jsonl` only with `record_sessions` on.

## Daemon, events, and diagnostics

| Environment | TOML key | Default | Purpose |
| --- | --- | --- | --- |
| `KACHE_DAEMON_IDLE_TIMEOUT` | `cache.daemon_idle_timeout_secs` | `0` | Stop after idle seconds; `0` keeps it running |
| `KACHE_LOCAL_HIT_DAEMON` | `cache.local_hit_daemon` | `false` | Experimental daemon-assisted local lookup with local fallback |
| `KACHE_HEARTBEAT_SECS` | `cache.heartbeat_secs` | `30` | Event heartbeat cadence for long miss compiles; `0` disables it |
| `KACHE_EXPLAIN_MISS` | `cache.explain_miss` | `false` | Record changed key groups on repeat misses |
| `KACHE_RECORD_SESSIONS` | `cache.record_sessions` | `false` | Make every `kache report` append its session line, as `--record` does, and every GC run append to `telemetry/gc-runs.jsonl` |
| `KACHE_STORAGE_LAYOUT_ADVICE` | `cache.storage_layout_advice` | `true` | Warn when storage layout forces copies. CLI commands print the advice; compiler wrappers log it at `warn` (see `KACHE_LOG`) so it never lands in compiler output |
| `KACHE_WINDOWS_HARDLINK` | `cache.windows_hardlink` | `false` | Unsafe opt-in hardlink restores on non-CoW Windows volumes |
| `KACHE_DEFERRED_DISCOVERY` | `cache.deferred_discovery` | `true` | On a certain miss (no closure record, no remote), compile first and key from the dep-info rustc emits instead of running the dep-info pre-pass; a C compile with no recorded read set likewise compiles first and keys from the dependency file it writes. Set to `0` to keep the pre-pass and the preprocessor probe on every miss. |
| `KACHE_DEFERRED_DURABILITY` | `cache.deferred_durability` | `true` | Store a miss without an fsync and let a background worker flush it a moment later; a hit on an entry not yet flushed verifies its bytes first. Set to `0` to flush every entry inside the compile that stored it. |
| `KACHE_SHARED_HARDLINK_RESTORES` | `cache.shared_hardlink_restores` | `false` | Let every target directory hardlink the same store blob on filesystems without reflink. Only for single-use target directories (a CI job's tree): the restore-time mtime stamp on a shared inode re-dates the artifact in every other tree that links it. |
| `KACHE_PROGRESS` | none | off | `hits` or `verbose` compiler progress |
| `KACHE_LOG` | none | CLI: `kache=warn`; wrapper: off | Stderr tracing filter |
| `KACHE_LOG_FILE` | none | CLI/daemon: `kache=info`; wrapper: off | File tracing filter and wrapper opt-in |
| `KACHE_EVENT_ROOT` | none | detected | Root attached to events for report filtering |
| `KACHE_PAGER` | none | platform pager | Pager command for `kache list` |

## Pin file-backed settings

Set `cache.ignore_env` in a controlled project config to ignore environment overrides for settings that also exist in TOML:

```toml
[cache]
ignore_env = true
```

It does not suppress `KACHE_DISABLED`, `KACHE_CONFIG`, `KACHE_HOST_CONFIG`, `KACHE_SOCKET_PATH`, credentials, logging, progress, or other environment-only controls. Kache warns when it ignores a set variable.

The daemon reads configuration at startup and watches the active config file and the host config. File changes trigger a graceful restart. Environment changes require starting a new daemon from that environment; an installed service uses its service definition, not your current shell.