Semantic decision-result cache
The result cache reuses evidence from an earlier, side-effect-free decision.
Semantic decision-result cache
The result cache reuses evidence from an earlier, side-effect-free decision. It is separate from exact invocation-receipt replay, compiled-schema caches, and provider prompt-prefix caches. It ships disabled: callers must supply an enabled, versioned cache policy, declare the evaluation side-effect-free, and choose an explicit TTL.
Identity and freshness
`decision-semantic-key/v2` hashes canonical JSON for the definition, ruleset, binding, adapter, prompt, acceptance, calibration, runtime policy, backend/model compatibility, primitive, projected input, subject identity, projection and egress policies, and capability mode. Strings and object keys are NFC-normalized before sorting; colliding keys after normalization are rejected. Numbers use JSON canonical number representation. The evaluator rejects a non-NFC projected input before dispatch when cache mode is enabled; otherwise the normalized key could equate two byte-distinct adapter inputs. Entries keyed with v1 are not migrated into v2: they miss until filled under the new contract. Changing any field produces a miss. The digest is scoped again by tenant, project, and workspace in storage.
Pinned models may be reused only for the exact requested/actual version. A moving alias requires a registry-generated compatibility snapshot, an explicit approved-version set, and an unexpired validity window. Expired or unknown snapshots bypass reuse; the cache never guesses the version an alias currently serves.
Hits preserve the original result, evaluation time, actual model, uncertainty, calibration status, source invocation, and receipt. The new caller receipt says `cache-hit` and `providerAttempted: false`; it must not be rendered as fresh inference. TTL expiry and policy-version changes prevent reuse, but TTL alone never establishes identity.
Integrity and access
Every read revalidates schema version, immutable identity, result digest, entry digest, safe terminal state, scope, policy, and freshness. The filesystem store uses mode-restricted files and atomic no-overwrite publication. Host-authenticated actors must hold per-operation `read`, `write`, `invalidate`, `export`, or `delete` permission, and the actor scope must match the entry. Storage paths hash scope plus key to avoid cross-project substitution. Deletion writes a body-free, mode-0600 audit tombstone (including whether a legal hold applied), then unlinks the live content file. A backup restored over a deleted key remains inaccessible, and the tombstone prevents refill. Unlink is not secure erasure of underlying media or backups. The local legal-hold flag preserves only permitted audit facts, not the original cached content. `purgeExpired(actor, now)` sweeps expired live entries in the authenticated workspace; retention jobs must schedule it explicitly. Every operation rejects an actor whose tenant, project or workspace ID is empty, longer than 256 characters, or contains a control character, with the same denial. The plaintext file store refuses confidential and restricted entries on read and write. Deployments must use a separately qualified encrypted store for those classifications, and remain responsible for encryption/key custody, backup erasure/retention and full legal-hold compliance on the containing volume.
Only successful evidence is cached by default. A policy may opt into short negative caching solely for deterministic `invalid-input`; authentication, authorization, boundary denial, transient failures, and execution uncertainty are never reusable. A rejected or crashed fill publishes nothing. In-process single-flight collapses concurrent fills for one scoped key. The filesystem store also serializes read/check/fill/publication across local processes using a mode-0600 lock per scoped key. A dead same-host owner can be recovered; unknown/remote owner locks fail closed rather than triggering duplicate provider execution. Distributed volumes require a reviewed lease/fencing mechanism before enabling writes.
D10 lifecycle binding
`new FileResultCacheStore(directory, { lifecycle: { policy, sourceErased, now } })` binds the store to the shared D10 `DecisionLifecyclePolicy` (`src/decision/lifecycle.ts`), using its `cache` surface. The constructor validates the complete policy. The `cache` rule is a ceiling:
- Retention. An entry whose TTL is longer than `retentionMs` is refused at write. An entry older than `retentionMs` by the store clock is treated as absent on read, is removed by `purgeExpired`, and never blocks its own refill. This also covers entries written before the policy was tightened.
- Classification. An entry more sensitive than the rule's `classification` is refused. The plaintext store still refuses `confidential` and `restricted` regardless of the rule.
- Export. When the rule says `export: 'denied'`, `export` is refused for present and absent keys alike, so the policy cannot be used as an existence oracle.
- Cascading deletion. `lifecycleReferences(actor, sourceReceiptId)` returns `{ surface: 'cache', opaqueId }` references (random entry IDs, never key material) for entries that reused a source receipt. A host includes them in its `DecisionLifecycleStore.links()` and routes `erase()` for the `cache` surface to `eraseLifecycleReference(actor, reference)`. `eraseDecisionSubject` then applies D10 holds before anything is erased: an active hold that covers `cache` blocks the deletion and leaves the entry in place. `eraseBySourceReceipt(actor, sourceReceiptId)` performs the same cascade directly. Both write the body-free tombstone, so a restored backup stays hidden and the key cannot be refilled. The cascade stays inside the authenticated workspace.
- Independent source authority. The optional `sourceErased(scope, sourceReceiptId)` callback is the host's D10 authority for source receipts. An entry whose source was erased is not served, is purged, and cannot be published again from that source. If the callback throws, the operation is denied (fail closed).
Without a `lifecycle` binding the store keeps its earlier behavior. D10 binding is the supported configuration; the unbound form exists for tests and migration.
Experimental evaluator integration
`evaluateDecisionRuleset` accepts an optional `resultCache` host policy. The caller must supply authenticated scope, explicit side-effect-free freshness policy, durable original receipt store, policy/calibration pins, `identityFor` (complete trusted host pins), and an awaited `recordCallerReceipt` persistence callback. Enabled reuse is restricted to one evaluation, one target, zero retries, no batching or context/scheduler mode; unsupported configurations reject before dispatch. The evaluator validates the definition/ruleset/binding/adapter/primitive/model, acceptance/calibration/runtime-policy pins and actual projected input against the supplied identity before lookup. The host must attest prompt, subject, backend, projection/egress and capability-mode pins: the evaluator cannot derive those from the portable artifact alone. The adapter cannot supply these values. An alias additionally requires `verifyAliasSnapshot` to re-check the registry snapshot on every lookup; a failed check bypasses reuse. With the D09 `CalibrationRegistry`, derive `snapshotId` from the alias head's revision and identity digest, and approve only the head's actual model. A promotion, rollback or retirement then creates a new revision. A caller that still holds the old snapshot bypasses. A fresh snapshot is a different key, so it misses and refills; a rollback never resurrects the earlier entry. `result-cache-runtime.test.ts` exercises this end to end against the real registry. Exact invocation receipt replay takes priority over this cache.
The response's `spec.cache` is the newly issued caller receipt. It is D15 evidence that only the v1alpha2 `RulesetResult` schema defines (`$defs.resultCacheCallerReceipt`), so a cache-enabled evaluation always writes a v1alpha2 result, and the writer-version gate rejects `spec.cache` under a v1alpha1 label. The schema also binds the receipt invariants: a `cache-hit` has `providerAttempted: false` and non-null source, time and entry references, and a `bypass` has `providerAttempted: true` and no entry. A historical entry whose result is v1alpha1 (filled by an earlier runtime) is refused on hit before the caller receipt is persisted. Hosts should purge such entries or change `policyVersion`. A hit retains the historical result and its historical attempts (not new provider usage) and labels the caller receipt `cache-hit`, `providerAttempted: false`; the original completed receipt is revalidated before returning it. Callers MUST persist the caller receipt separately and display historical attempts as source evidence, never as current adapter work. A failure of the persistence callback aborts release of the result.
Telemetry reports only `hit`, `miss`, `bypass`, `stale`, `invalidation`, and `single-flight` with an opaque operation ID. A failing telemetry sink cannot change a cache decision. To feed the D14 metric pipeline, construct the service with `resultCacheMetricsSink(metrics, next?)` (`src/decision/telemetry/metrics.ts`). Each event is recorded as `decision.cache` with the bounded dimensions `aiwg.cache.layer: result` and `aiwg.cache.result: <event>`. Operation IDs and reasons never become labels. Each reused caller (a hit, including a single-flight or cross-process joiner) records `decision.cache_saved_input_tokens`, `_output_tokens`, `_latency_ms` and `_cost_usd` once, labelled `aiwg.usage.cost_provenance: estimate`. Unknown values are skipped, not zeroed. The evaluator's trace metrics count throughput and duration for a hit but never its historical attempts. They also skip result-layer `decision.cache` spans, so the service sink is the single source of cache counts. Semantic keys, input-derived digests, subject identities, and entry IDs are not telemetry fields. Hit-side savings are labeled estimates from the original request, not measured counterfactual provider savings; missing input/output tokens or cost remain `null`, never fabricated as zero. Original provider usage remains attached to original evidence and is never counted again as new usage.
Key construction security review
Scope: `digestResultCacheIdentity` (`src/decision/result-cache/key.ts`), storage keying and authorization (`store.ts`), single-flight keys (`service.ts`), and the evaluator identity checks (`evaluate.ts`). Reviewed 2026-09-24 against `decision-semantic-key/v2`. Each conclusion is enforced by the test of the same ID in `test/unit/decision/result-cache-key-security.test.ts`. Cross-scope oracle behavior is also covered by `result-cache-oracle.test.ts`.
| ID | Risk | Conclusion |
|---|---|---|
| RCK-01 | Collision between distinct identities | The key is the full, untruncated SHA-256 of canonical JSON. Canonical JSON is injective over JSON values: strings are always quoted and escaped, so `"1"` ≠ `1`, a JSON-encoded string ≠ the object, an array ≠ an object with numeric keys, and `null` ≠ an absent field. No field is concatenated without structure. Residual risk is the SHA-256 collision bound. |
| RCK-02 | Canonicalization aliasing | Key order never matters. Strings and object keys are NFC-normalized, and keys that collide after normalization are rejected. Because normalization could otherwise equate byte-distinct adapter inputs, the evaluator refuses non-NFC projected input before lookup. Numbers use the JSON number form, so `1`, `1.0` and `-0`/`0` share a key. This is safe because the adapter receives the same JSON serialization. Non-finite numbers and non-JSON values (`undefined`, functions) are refused, never silently dropped. |
| RCK-03 | Cross-version reuse | `keyVersion` is inside the hashed identity, so a v1 identity can never produce a v2 key and old entries are not migrated. |
| RCK-04 | Scope binding and substitution | The semantic key is deliberately scope-free. Scope is bound at storage: the storage path hashes tenant, project and workspace with the key, each read re-checks the entry's own scope, and the entry digest covers the scope. Scope components are NUL-joined, so a component containing NUL or another control character is refused; otherwise `a\0b`/`c` and `a`/`b\0c` would share a path. Single-flight keys are JSON arrays that include the actor scope, subject, permissions and policy, so a flight never joins across an authorization boundary. |
| RCK-05 | Existence and timing oracles | A lookup from another scope resolves a different storage path. It never reads the victim's entry and returns exactly what an absent key returns. The same holds for every operation, with one denial for a missing permission, wrong scope, invalid scope or denied export. Telemetry carries no key, input-derived digest, subject or entry ID. Residual risk: file names are unkeyed SHA-256 of scope plus key, so someone who can list the directory and can guess the full identity (including a low-entropy input) can confirm the guess offline. The cache directory is mode 0700 and entries 0600. Anyone with that access can read plaintext entries anyway, which is why confidential and restricted classes are refused. Keyed (HMAC) storage names belong with the encrypted store tracked in #2687. |
| RCK-06 | Sensitive material at rest | A stored entry holds the key digest, never the identity. The projected input, prompt, subject and policy digests are not written, so an entry file cannot be inverted to its inputs beyond the guess-confirmation risk in RCK-05. |
| RCK-07 | Forged entries | The entry integrity digest is unkeyed. It detects corruption and truncation, but a writer with file access can recompute it, so it is not authentication. Authenticity comes from the evaluator re-verifying the durable source receipt on every hit, and from requiring the stored identity digest to equal the digest of the caller's identity. A forged entry with a recomputed digest is refused on hit. |