---
title: "How We Made the Cache Stop Lying"
date: 2026-05-27
type: post
tags: [build-systems, caching, correctness, rage, fingerprinting]
summary: "Weak fingerprints find candidates. Strong fingerprints verify them. The pathset is the bridge. This is how you build a cache that can't lie."
---

[Post 1](/posts/your-build-cache-is-lying-to-you) showed the lie. Every JS monorepo cache hashes what you declared and trusts you got it right. You didn't. The cache returns stale output, and nobody finds out for weeks.

This post shows the fix.

The fix is not "be more careful about declaring inputs." The fix is a two-phase fingerprinting scheme that [BuildXL](https://github.com/microsoft/BuildXL) has used at Microsoft scale for years, now ported to JS monorepos in [rage](https://github.com/kenotron-ms/rage). The algorithm separates what *could* matter from what *actually* mattered. The first phase is cheap. The second phase is correct. Together, they give you a cache that tells the truth without requiring perfect input declarations.

Here's the full algorithm.

## Phase 1: The Weak Fingerprint

The weak fingerprint (WF) captures everything statically known about a task before it runs. It's the "what could matter" hash. Here's the actual struct from [`crates/cache/src/weak_fp.rs`](https://github.com/kenotron-ms/rage/blob/main/crates/cache/src/weak_fp.rs) in rage:

```rust
pub struct WeakFpInputs<'a> {
    pub command: &'a str,
    pub tool_path: &'a Path,
    pub package_path: &'a Path,
    pub declared_input_globs: &'a [String],
    pub tracked_env: &'a [(String, String)],
    pub dep_abi_fingerprints: &'a [(String, String)],
}
```

Six fields. Each one earns its place.

**`command`** is the shell command the task runs: `tsc -b`, `jest --ci`, whatever. Different command, different output. If you change from `tsc -b` to `tsc -b --declaration`, the WF changes and the old cache entry is dead.

**`tool_path`** resolves to the actual binary on disk. Rage hashes the bytes of the resolved tool (following symlinks through `node_modules/.bin/tsc` into `typescript/bin/tsc`). A TypeScript upgrade flips the WF for every TypeScript task in the monorepo. You don't declare this. It happens automatically. This is what Turborepo misses: same `tsc -b` command, different compiler version, same cache key. Rage catches it because the tool binary hash changed.

**`package_path`** is the workspace-relative path. It differentiates `packages/api#build` from `packages/web#build` when both run the same command. Without it, two packages running `tsc -b` would collide in the cache.

**`declared_input_globs`** expand against the package directory. For TypeScript, the plugin defaults to `**/*.ts` and `tsconfig*.json`. Each matching file gets hashed with blake3. The plugin author defines these, not the developer. You don't write input globs for the common case.

**`tracked_env`** covers environment variables that affect output: `NODE_ENV=production` vs `NODE_ENV=development`. These are opt-in from `rage.json`. They're never auto-detected because auto-detecting which environment variables affect a program's output is [undecidable](https://en.wikipedia.org/wiki/Rice%27s_theorem).

**`dep_abi_fingerprints`** is the most interesting field. For each direct dependency package, rage stores the ABI hash from that dependency's last build. If `packages/utils` is a dependency of `packages/api`, then `api`'s WF includes the ABI fingerprint of `utils`. Change something in `utils` that affects its public API surface, and `api`'s WF changes. Change something that doesn't (a comment, an internal refactor), and `api`'s WF stays the same. More on this in the ABI section below.

The WF is blake3 over all six components. It's fast to compute: one pass over the declared globs (typically hundreds of kilobytes of source per package), plus a few string hashes. Sub-millisecond on warm filesystem caches.

The WF is also what Turborepo computes as its *entire* cache key. Turborepo stops here. Rage uses it as the first phase of a two-phase lookup.

## The Bridge: PathSets

Between the two phases sits the pathset. This is the data the sandbox produces when a task runs. It's the list of files the process actually read and wrote:

```rust
pub struct Pathset {
    pub reads: BTreeSet<PathBuf>,    // every file the task read
    pub writes: BTreeSet<PathBuf>,   // every file the task wrote
}
```

The pathset comes from the file-access sandbox. On macOS, rage injects a dylib via `DYLD_INSERT_LIBRARIES` that intercepts filesystem calls. On Linux, it uses eBPF tracepoints. Both record every `open`, `read`, `stat`, and `write` the task performs, then ship the log back to the cache layer.

The pathset is stored keyed by WF. A single WF can have *multiple* pathsets attached. The same `tsc -b` invocation might follow different code paths under different conditions and read different files. The strong fingerprint phase handles the disambiguation.

The pathset is the bridge between the two phases. Phase 1 asks "what could matter?" Phase 2 asks "given what the task actually read last time, has any of it changed?" The pathset is the answer to "what did the task actually read last time."

Here's why this matters: if your task reads `../../tsconfig.base.json` two directories up, and you never declared it as an input, the sandbox catches the read. The pathset includes it. The next cache lookup hashes its content. If someone changes `tsconfig.base.json`, the cache misses and re-runs the task. No human had to remember to declare that file. The mechanism caught it.

## Phase 2: The Strong Fingerprint

The strong fingerprint (SF) is the WF plus the content hashes of every file in the pathset. This is the "what actually mattered" hash. From [`crates/cache/src/strong_fp.rs`](https://github.com/kenotron-ms/rage/blob/main/crates/cache/src/strong_fp.rs):

```rust
pub fn compute_strong_fingerprint(weak_fp: &str, pathset_reads: &[PathBuf]) -> String {
    let mut hasher = blake3::Hasher::new();
    hasher.update(b"wf:");
    hasher.update(weak_fp.as_bytes());
    hasher.update(b"\n");

    let mut sorted: Vec<&Path> = pathset_reads.iter()
        .map(|p| p.as_path())
        .filter(|p| !p.components()
            .any(|c| c.as_os_str() == OsStr::new("node_modules")))
        .collect();
    sorted.sort();
    sorted.dedup();

    for p in sorted {
        hasher.update(b"read:");
        hasher.update(p.as_os_str().as_encoded_bytes());
        hasher.update(b":");
        let content = std::fs::read(p).unwrap_or_default();
        hasher.update(blake3::hash(&content).as_bytes());
        hasher.update(b"\n");
    }

    hasher.finalize().to_hex().to_string()
}
```

Three details worth calling out in this code.

First: **`node_modules` files are excluded from the SF.** They're pinned by the lockfile, which is already covered by the root install task's fingerprint. A lockfile change invalidates the install task and cascades to all downstream tasks. Excluding `node_modules` turns a TypeScript SF computation from O(thousands of files) into O(actual source files). Every `.d.ts` in the TypeScript stdlib closure would otherwise get hashed on every lookup. That's the kind of cost that makes the cache slower than running the task.

Second: **the path is included alongside the content hash.** If two files swap paths, the SF changes. Content-identity alone is not enough. The same bytes at a different path can produce different output (think `tsconfig.json` path mappings).

Third: **a missing file hashes as the empty buffer, but the path is still in the SF.** A file that existed last run and is gone this run produces a different SF. Present-vs-absent is a change, and the cache catches it.

## The Lookup Algorithm

Here's how the pieces fit together on a cache lookup. From [`crates/cache/src/lookup.rs`](https://github.com/kenotron-ms/rage/blob/main/crates/cache/src/lookup.rs):

```rust
fn lookup(task: &Task) -> Option<CachedOutput> {
    let wf = compute_weak_fingerprint(&WeakFpInputs { ... });
    let candidates = pathset_store.get_pathsets(&wf)?;          // possibly many
    for pathset in candidates {
        let sf = compute_strong_fingerprint(&wf, &pathset.reads);
        if let Some(output) = output_store.get(&sf) {
            return Some(output);                                 // hit
        }
    }
    None                                                         // miss
}
```

Step by step:

**Step 1.** Compute the weak fingerprint from the task's static inputs. This is fast: hash the command, tool binary, declared globs, env vars, and upstream ABI hashes.

**Step 2.** Look up the WF in the pathset store. This is a filesystem read of `~/.rage/cache/pathsets/{wf_prefix}/{wf_hex}.json`. If no pathsets exist for this WF, it's a cold cache miss. Run the task.

**Step 3.** For each candidate pathset stored under this WF, compute the strong fingerprint. Read every file in the pathset from disk, hash each one with blake3, combine with the WF. This is the expensive step: you're hashing the actual content of every file the task read last time. With `node_modules` excluded, this is typically hundreds of kilobytes of source. Sub-millisecond.

**Step 4.** Look up the SF in the output store. If a match exists, it's a cache hit. Return the stored outputs, stdout, and stderr. Replay them to the terminal.

**Step 5.** If no candidate pathset produces an SF match, it's a cache miss. Run the task inside the sandbox. The sandbox produces a new pathset. Compute the SF from the new pathset. Store the pathset under the WF. Store the outputs under the SF.

<div class="breakout">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 520" role="img" aria-label="Diagram: The Two-Phase Lookup Flow showing WF computation, pathset lookup, SF computation, and hit-or-miss branching">
  <rect width="840" height="520" fill="#F5F0E8" rx="4"/>
  <text x="420" y="32" text-anchor="middle" font-family="Georgia, serif" font-size="16" font-weight="bold" fill="#2B2421">The Two-Phase Lookup Flow</text>
  <rect x="290" y="52" width="260" height="48" rx="4" fill="#fff" stroke="#2B2421" stroke-width="1.5"/>
  <text x="420" y="72" text-anchor="middle" font-family="monospace" font-size="12" fill="#2B2421">1. Compute Weak Fingerprint</text>
  <text x="420" y="88" text-anchor="middle" font-family="Georgia, serif" font-size="10" fill="#A0522D">command + tool + globs + env + ABI</text>
  <line x1="420" y1="100" x2="420" y2="122" stroke="#2B2421" stroke-width="1.5" marker-end="url(#tfArrow)"/>
  <rect x="260" y="122" width="320" height="48" rx="4" fill="#fff" stroke="#2B2421" stroke-width="1.5"/>
  <text x="420" y="142" text-anchor="middle" font-family="monospace" font-size="12" fill="#2B2421">2. Look up WF in pathset_store</text>
  <text x="420" y="158" text-anchor="middle" font-family="Georgia, serif" font-size="10" fill="#A0522D">WF -> [Pathset_A, Pathset_B, ...]</text>
  <line x1="420" y1="170" x2="420" y2="192" stroke="#2B2421" stroke-width="1.5"/>
  <line x1="420" y1="192" x2="180" y2="192" stroke="#2B2421" stroke-width="1"/>
  <line x1="420" y1="192" x2="580" y2="192" stroke="#2B2421" stroke-width="1"/>
  <line x1="180" y1="192" x2="180" y2="212" stroke="#2B2421" stroke-width="1" marker-end="url(#tfArrow)"/>
  <line x1="580" y1="192" x2="580" y2="212" stroke="#2B2421" stroke-width="1" marker-end="url(#tfArrow)"/>
  <rect x="100" y="212" width="160" height="36" rx="4" fill="none" stroke="#A0522D" stroke-width="1.5"/>
  <text x="180" y="234" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">No pathsets (cold)</text>
  <rect x="480" y="212" width="200" height="36" rx="4" fill="none" stroke="#2B2421" stroke-width="1.5"/>
  <text x="580" y="234" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#2B2421">Candidate pathsets found</text>
  <line x1="580" y1="248" x2="580" y2="270" stroke="#2B2421" stroke-width="1.5" marker-end="url(#tfArrow)"/>
  <rect x="400" y="270" width="360" height="56" rx="4" fill="#fff" stroke="#2B2421" stroke-width="1.5"/>
  <text x="580" y="290" text-anchor="middle" font-family="monospace" font-size="12" fill="#2B2421">3. For each candidate Pathset:</text>
  <text x="580" y="308" text-anchor="middle" font-family="monospace" font-size="11" fill="#2B2421">SF = blake3(WF + content_hashes(reads))</text>
  <line x1="580" y1="326" x2="580" y2="348" stroke="#2B2421" stroke-width="1.5" marker-end="url(#tfArrow)"/>
  <rect x="430" y="348" width="300" height="40" rx="4" fill="#fff" stroke="#2B2421" stroke-width="1.5"/>
  <text x="580" y="372" text-anchor="middle" font-family="monospace" font-size="12" fill="#2B2421">4. Look up SF in output_store</text>
  <line x1="580" y1="388" x2="580" y2="408" stroke="#2B2421" stroke-width="1.5"/>
  <line x1="580" y1="408" x2="460" y2="408" stroke="#2B2421" stroke-width="1"/>
  <line x1="580" y1="408" x2="700" y2="408" stroke="#2B2421" stroke-width="1"/>
  <line x1="460" y1="408" x2="460" y2="428" stroke="#2B2421" stroke-width="1" marker-end="url(#tfArrow)"/>
  <line x1="700" y1="408" x2="700" y2="428" stroke="#2B2421" stroke-width="1" marker-end="url(#tfArrow)"/>
  <rect x="630" y="428" width="140" height="40" rx="4" fill="#2B2421" stroke="none"/>
  <text x="700" y="448" text-anchor="middle" font-family="Georgia, serif" font-size="13" font-weight="bold" fill="#F5F0E8">CACHE HIT</text>
  <text x="700" y="462" text-anchor="middle" font-family="Georgia, serif" font-size="10" fill="#F5F0E8">replay outputs</text>
  <rect x="380" y="428" width="160" height="40" rx="4" fill="none" stroke="#A0522D" stroke-width="1.5"/>
  <text x="460" y="448" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">No match: try</text>
  <text x="460" y="462" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">next candidate</text>
  <line x1="180" y1="248" x2="180" y2="480" stroke="#A0522D" stroke-width="1" stroke-dasharray="5,3"/>
  <line x1="460" y1="468" x2="460" y2="480" stroke="#A0522D" stroke-width="1" stroke-dasharray="5,3"/>
  <line x1="180" y1="480" x2="460" y2="480" stroke="#A0522D" stroke-width="1" stroke-dasharray="5,3"/>
  <line x1="320" y1="480" x2="320" y2="492" stroke="#A0522D" stroke-width="1.5" marker-end="url(#tfArrowR)"/>
  <rect x="140" y="492" width="360" height="24" rx="4" fill="#A0522D" stroke="none"/>
  <text x="320" y="509" text-anchor="middle" font-family="Georgia, serif" font-size="12" font-weight="bold" fill="#F5F0E8">5. MISS: run task in sandbox, store new pathset + SF</text>
  <defs>
    <marker id="tfArrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
      <polygon points="0 0, 8 3, 0 6" fill="#2B2421"/>
    </marker>
    <marker id="tfArrowR" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
      <polygon points="0 0, 8 3, 0 6" fill="#A0522D"/>
    </marker>
  </defs>
</svg>
</div>

The cost on a hit: one WF hash (sub-ms), one pathset JSON read, one SF hash over the pathset's actual files (sub-ms for typical TypeScript packages), and one output store read. The slow step is hashing the contents of the pathset's read files, and with `node_modules` excluded, that's fast.

The cost on a miss: running the task plus one pathset write plus one output write. A one-time cost that sets up the next hit.

## Why two phases instead of one

The naive design hashes all declared inputs into a single cache key. That fails in two directions.

**Over-declaration** is the Bazel approach. Every input must be declared. Miss one and the build is wrong. Correctness costs constant human effort.

**Under-declaration** is the Turborepo approach. Hash only what the developer declared. Miss one and the cache silently lies. Correctness depends on human perfection.

The two-phase scheme escapes this dilemma. The WF is cheap and runs on every lookup. It narrows the candidate set. The SF is expensive (reads files from disk) and runs only when the WF finds candidates. The pathset is *observed*, not declared. The user declares the WF inputs (and even those are mostly plugin defaults). The sandbox supplies the rest.

The cost of a false WF match is one SF computation, then a miss. Fast. The cost of a false SF match is theoretically zero: every byte that affects the output is in the SF input by construction.

Correctness is a property of the mechanism, not of declaration discipline.

## The bonus: ABI early-cutoff

The two-phase scheme gives you correctness. The ABI fingerprint gives you speed.

When a rage plugin implements `abi_fingerprint(outputs) -> Option<String>`, rage records the ABI hash alongside the task's outputs. For the TypeScript plugin, the ABI fingerprint is blake3 over all `.d.ts` files the task produced. The `.d.ts` is the public type surface of a TypeScript package. If the `.d.ts` didn't change, nothing downstream needs to care.

Here's how it plays out:

```
packages/utils#build
  outputs: dist/index.js, dist/index.d.ts
  abi_fingerprint = blake3(all .d.ts contents)

packages/api#build
  WF includes: ("packages/utils", utils.abi_fingerprint)
```

A developer changes a comment inside `utils/src/foo.ts`. The change doesn't affect the public type surface. What happens:

1. `utils#build` runs. Its WF changed because `foo.ts` changed.
2. `tsc` produces the same `.d.ts` as before. The comment is internal. The types are identical.
3. `abi_fingerprint(outputs)` returns the same hash as the previous run.
4. `api#build`'s WF is *unchanged*. The `dep_abi_fingerprints` field still contains the same hash for `utils`.
5. `api#build` is a cache hit. So is every other package that depends on `utils`.

Without ABI fingerprints, every change to `utils` invalidates every dependent. With them, only changes that affect the public surface propagate. In a monorepo with deep dependency chains, this is the difference between rebuilding 3 packages and rebuilding 80.

<div class="breakout">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 440" role="img" aria-label="Diagram: ABI Early-Cutoff showing how a comment change in utils does not cascade to downstream packages">
  <rect width="840" height="440" fill="#F5F0E8" rx="4"/>
  <text x="420" y="32" text-anchor="middle" font-family="Georgia, serif" font-size="16" font-weight="bold" fill="#2B2421">ABI Early-Cutoff</text>
  <text x="210" y="64" text-anchor="middle" font-family="Georgia, serif" font-size="13" font-weight="bold" fill="#A0522D">Without ABI fingerprint</text>
  <rect x="110" y="82" width="200" height="56" rx="4" fill="#A0522D" stroke="none"/>
  <text x="210" y="104" text-anchor="middle" font-family="monospace" font-size="11" font-weight="bold" fill="#F5F0E8">utils#build</text>
  <text x="210" y="120" text-anchor="middle" font-family="Georgia, serif" font-size="10" fill="#F5F0E8">comment changed -> REBUILD</text>
  <line x1="160" y1="138" x2="130" y2="162" stroke="#A0522D" stroke-width="1.5" marker-end="url(#abiArrowR)"/>
  <line x1="210" y1="138" x2="210" y2="162" stroke="#A0522D" stroke-width="1.5" marker-end="url(#abiArrowR)"/>
  <line x1="260" y1="138" x2="290" y2="162" stroke="#A0522D" stroke-width="1.5" marker-end="url(#abiArrowR)"/>
  <rect x="60" y="164" width="140" height="40" rx="4" fill="#A0522D" stroke="none"/>
  <text x="130" y="184" text-anchor="middle" font-family="monospace" font-size="10" font-weight="bold" fill="#F5F0E8">api#build</text>
  <text x="130" y="196" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#F5F0E8">REBUILD</text>
  <rect x="220" y="164" width="140" height="40" rx="4" fill="#A0522D" stroke="none"/>
  <text x="290" y="184" text-anchor="middle" font-family="monospace" font-size="10" font-weight="bold" fill="#F5F0E8">web#build</text>
  <text x="290" y="196" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#F5F0E8">REBUILD</text>
  <line x1="130" y1="204" x2="130" y2="224" stroke="#A0522D" stroke-width="1.5" marker-end="url(#abiArrowR)"/>
  <line x1="290" y1="204" x2="290" y2="224" stroke="#A0522D" stroke-width="1.5" marker-end="url(#abiArrowR)"/>
  <rect x="60" y="226" width="140" height="40" rx="4" fill="#A0522D" stroke="none"/>
  <text x="130" y="246" text-anchor="middle" font-family="monospace" font-size="10" font-weight="bold" fill="#F5F0E8">api#test</text>
  <text x="130" y="258" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#F5F0E8">REBUILD</text>
  <rect x="220" y="226" width="140" height="40" rx="4" fill="#A0522D" stroke="none"/>
  <text x="290" y="246" text-anchor="middle" font-family="monospace" font-size="10" font-weight="bold" fill="#F5F0E8">web#test</text>
  <text x="290" y="258" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#F5F0E8">REBUILD</text>
  <text x="210" y="296" text-anchor="middle" font-family="Georgia, serif" font-size="12" font-weight="bold" fill="#A0522D">5 tasks rebuilt</text>
  <line x1="420" y1="56" x2="420" y2="320" stroke="#2B2421" stroke-width="1" stroke-dasharray="4,4"/>
  <text x="630" y="64" text-anchor="middle" font-family="Georgia, serif" font-size="13" font-weight="bold" fill="#2B2421">With ABI fingerprint</text>
  <rect x="530" y="82" width="200" height="56" rx="4" fill="#A0522D" stroke="none"/>
  <text x="630" y="104" text-anchor="middle" font-family="monospace" font-size="11" font-weight="bold" fill="#F5F0E8">utils#build</text>
  <text x="630" y="120" text-anchor="middle" font-family="Georgia, serif" font-size="10" fill="#F5F0E8">comment changed -> REBUILD</text>
  <line x1="630" y1="138" x2="630" y2="158" stroke="#2B2421" stroke-width="1.5" marker-end="url(#abiArrow)"/>
  <rect x="540" y="158" width="180" height="36" rx="4" fill="#fff" stroke="#2B2421" stroke-width="1.5"/>
  <text x="630" y="175" text-anchor="middle" font-family="monospace" font-size="10" fill="#2B2421">.d.ts unchanged</text>
  <text x="630" y="188" text-anchor="middle" font-family="Georgia, serif" font-size="10" fill="#2B2421">ABI hash: same</text>
  <line x1="580" y1="194" x2="550" y2="216" stroke="#2B2421" stroke-width="1" stroke-dasharray="4,3"/>
  <line x1="630" y1="194" x2="630" y2="216" stroke="#2B2421" stroke-width="1" stroke-dasharray="4,3"/>
  <line x1="680" y1="194" x2="710" y2="216" stroke="#2B2421" stroke-width="1" stroke-dasharray="4,3"/>
  <rect x="480" y="218" width="140" height="40" rx="4" fill="#2B2421" stroke="none"/>
  <text x="550" y="238" text-anchor="middle" font-family="monospace" font-size="10" font-weight="bold" fill="#F5F0E8">api#build</text>
  <text x="550" y="250" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#F5F0E8">CACHE HIT</text>
  <rect x="640" y="218" width="140" height="40" rx="4" fill="#2B2421" stroke="none"/>
  <text x="710" y="238" text-anchor="middle" font-family="monospace" font-size="10" font-weight="bold" fill="#F5F0E8">web#build</text>
  <text x="710" y="250" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#F5F0E8">CACHE HIT</text>
  <rect x="480" y="270" width="140" height="40" rx="4" fill="#2B2421" stroke="none"/>
  <text x="550" y="290" text-anchor="middle" font-family="monospace" font-size="10" font-weight="bold" fill="#F5F0E8">api#test</text>
  <text x="550" y="302" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#F5F0E8">CACHE HIT</text>
  <rect x="640" y="270" width="140" height="40" rx="4" fill="#2B2421" stroke="none"/>
  <text x="710" y="290" text-anchor="middle" font-family="monospace" font-size="10" font-weight="bold" fill="#F5F0E8">web#test</text>
  <text x="710" y="302" text-anchor="middle" font-family="Georgia, serif" font-size="9" fill="#F5F0E8">CACHE HIT</text>
  <text x="630" y="336" text-anchor="middle" font-family="Georgia, serif" font-size="12" font-weight="bold" fill="#2B2421">1 task rebuilt, 4 cache hits</text>
  <line x1="60" y1="360" x2="780" y2="360" stroke="#2B2421" stroke-width="0.5"/>
  <text x="420" y="388" text-anchor="middle" font-family="Georgia, serif" font-size="13" fill="#2B2421">The .d.ts file is the ABI surface of a TypeScript package.</text>
  <text x="420" y="408" text-anchor="middle" font-family="Georgia, serif" font-size="13" fill="#2B2421">If it didn't change, downstream WF inputs are unchanged.</text>
  <text x="420" y="428" text-anchor="middle" font-family="Georgia, serif" font-size="13" fill="#A0522D">The cascade stops at the boundary where the public surface is stable.</text>
  <defs>
    <marker id="abiArrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
      <polygon points="0 0, 8 3, 0 6" fill="#2B2421"/>
    </marker>
    <marker id="abiArrowR" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
      <polygon points="0 0, 8 3, 0 6" fill="#A0522D"/>
    </marker>
  </defs>
</svg>
</div>

This is the BuildXL early-cutoff mechanism. BuildXL has used it for years to avoid cascading rebuilds across thousands of pips in Microsoft's internal monorepos. Rage ports the same idea to JS monorepos, using `.d.ts` content as the ABI surface for TypeScript packages.

ABI fingerprinting is plugin-defined and optional. Plugins that can't cheaply expose an ABI return `None`. The strong fingerprint still carries correctness alone. You just lose the cutoff optimization. The cascade propagates further, and more tasks re-run than strictly necessary. Correctness is never compromised.

## The two-phase scheme separates discrimination from verification.

The weak fingerprint discriminates: "which stored results *could* be relevant to this task?" It's fast, it narrows the search space, and it's wrong in only one direction (it might include candidates that turn out not to match, but it never excludes valid ones).

The strong fingerprint verifies: "given a specific set of files this task read last time, have any of them changed?" It's exact. Every byte that could affect the output is in the hash.

The pathset is the bridge. It comes from the sandbox, not from human declarations. It makes the strong fingerprint possible without requiring the developer to know everything their tools read. And the ABI fingerprint sits on top, cutting off cascades at the boundary where the public surface is stable.

This is how you build a cache that can't lie.
