---
title: How it works
description: The signature algorithm, why matching is exact, and what the ledger actually records.
sidebar:
  order: 2
---

## Signatures

Every decision is normalized into a stable signature before it's used as a cache key:

```ts
export function signature(decision: Decision): string {
  const normalized = {
    intent: decision.intent,
    params: sortKeysDeep(decision.params),
  };
  return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}
```

Normalization is deliberately shallow — object keys are sorted recursively so `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` hash identically, but nothing about `intent` or the param *values* is touched. There's no stemming, no case-folding, no semantic canonicalization.

```txt
{ intent: "summarize_pr", params: { repo: "acme/widgets", pr: 482 } }
{ intent: "summarize_pr", params: { pr: 482, repo: "acme/widgets" } }
        │                                       │
        └──────────────── same signature ──────┘

{ intent: "summarize_pr", params: { repo: "acme/widgets", pr: 483 } }
        │
        └── different signature (different pr) ──┘
```

## Why exact match, not semantic match

A semantic/fuzzy cache would catch more repeats — "summarize this PR" and "give me a summary of this pull request" would both hit. It would also mean Runic has to judge whether two differently-phrased requests are close enough to share a cached answer, which is exactly the kind of non-deterministic judgment call Runic is designed to avoid making.

Exact match means a cache hit is provably the same decision, not probably the same decision. The cost is that callers have to be deliberate about what goes into `params` — see [Quickstart](/quickstart#choosing-intent-and-params).

## What the ledger records

The ledger is an append-only log, not a running total that can drift:

```ts
interface LedgerEvent {
  signature: string;
  kind: "hit" | "miss";
  tokensSpent: number;
  timestamp: number;
}
```

- **On a miss**, Runic logs the `tokensSpent` your code reported for producing the artifact.
- **On a hit**, Runic looks up the original miss for that signature and logs the *same* `tokensSpent` value as what was saved — never an estimate, never a guess. If somehow no prior miss exists for a signature that's being hit, it logs `0` rather than inventing a number.

```txt
summary.totalSpent = sum of all miss events
summary.totalSaved = sum of all hit events
tokensWithoutRunic = totalSpent + totalSaved   (what it would've cost with no cache at all)
savingsPercent     = totalSaved / tokensWithoutRunic
```

Runic never measures token cost itself. It only ever repeats back what the calling code told it via `storeResult(decision, artifact, tokensSpent)` — see [Benchmarks](/benchmarks) for how `openrouter-savings` gets that number from a real API response instead of an estimate.

## Storage backends

Both `@runic-labs/cache` and `@runic-labs/ledger` are built against a small storage interface, with two implementations shipped in v1:

| Backend | Lifetime | Use case |
| --- | --- | --- |
| `MemoryCacheStore` / `MemoryLedgerStore` | Process lifetime only | Tests, ephemeral scripts, benchmarks |
| `FileCacheStore` / `FileLedgerStore` | Persisted to disk (`.runic/` by default) | A CLI process and an agent process reading the same state without a running server |

Both are per-session scope in v1 — see [Scope](/scope) for why cross-session sharing is deliberately a later phase rather than something bolted on now.

## Staleness is advisory only

Cache entries carry a `stale` flag, computed from a configurable window (24h by default). Runic never evicts or refuses a stale entry automatically — it's information for the caller to act on if it wants to, not an enforcement mechanism. If your use case needs hard expiry, check `entry.stale` yourself and decide what to do with it.