> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sovseal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verified Semantic Recall: Tamper Detection on Restore

> A two-check verification pattern for every state restore — AES-GCM auth tags plus a re-derived SHA-256 hash — that fails closed on any mismatch.

<style>
  {`
      main, article, .prose {
        margin-left: 2.5cm !important;
        margin-right: 2.5cm !important;
      }
      `}
</style>

Verified Semantic Recall (VSR) is a verification **pattern** applied on every restore from the replication endpoint. It catches two distinct classes of attack that a single cryptographic primitive cannot cover alone: bit-level ciphertext tampering, and wholesale record substitution.

<Warning>
  **VSR is not automatic.** Neither `@sovseal/sdk` nor `sovseal-sdk` runs this check for you or throws a dedicated error class — there is no `VsrFailureError`. The SDK gives you the primitives (`decryptJson`, `canonicalize`, `CryptoService.sha256Hex`) and the anchor (`receipt.client_payload_hash`); **you** perform the comparison and decide what "fail closed" means for your agent. This is deliberate: your incident response is yours to own, not the library's to guess at.
</Warning>

***

## What the pattern checks — and why you need both

Two independent verifications on every record restored from the server:

1. **AES-256-GCM authentication tag.** `decryptJson` verifies the ciphertext bytes are exactly what was written — any bit flip, truncation, or extension causes it to throw.
2. **Re-derived hash comparison.** Re-run `sha256(canonicalize(payload))` on the decrypted result and compare it to `receipt.client_payload_hash`, the anchor the client computed **at write time** over the full payload object (which itself includes `agent_id`, `sequence_number`, `parent_snapshot`, `policy_hash`, `active_context`, and `timestamp`). This catches what the auth tag alone cannot: a malicious server returning a *different* record you wrote earlier — one that decrypts perfectly but belongs to the wrong point in your lineage.

<Note>
  The auth tag proves the bytes weren't altered. It does **not** prove those bytes are the record you asked for. A server could swap in an older, still-validly-encrypted snapshot and the tag would pass. The hash comparison is what catches that — because `client_payload_hash` is computed over the *entire* payload, including `parent_snapshot`, so a substituted record's re-derived hash will not match the one issued at its original write.
</Note>

***

## The checks, end to end

```text theme={null}
┌─────────────────────── At write time (client.snapshot) ─────────────┐
│                                                                     │
│  payload = { agent_id, sequence_number, parent_snapshot,          │
│              policy_hash, active_context, timestamp }              │
│                          │                                          │
│                          ▼                                          │
│  client_payload_hash = sha256(canonicalize(payload))               │
│  ciphertext = AES-256-GCM(payload.active_context, key, nonce)      │
│                                                                     │
│  SENT TO SERVER:  { ...envelope fields, client_payload_hash,       │
│                      ciphertext_b64 }                               │
└─────────────────────────────────────────────────────────────────────┘

                            ▼ (time passes — crash, new device, restore)

┌────────────────────── At restore time (you implement) ──────────────┐
│                                                                     │
│  { receipt, ciphertextUrl } = await client.restore({ agentId })    │
│  ciphertext = await fetch(ciphertextUrl)                            │
│                                                                     │
│  STEP 1:  decryptJson(ciphertext, key)                              │
│           ─► throws on auth-tag failure  (tamper)                  │
│                                                                     │
│  STEP 2:  expected = sha256(canonicalize(decrypted_payload))        │
│           ─► compare to receipt.client_payload_hash                │
│           ─► mismatch = FAIL CLOSED  (substitution)                │
│                                                                     │
│  STEP 3:  only now hand the payload to your agent                  │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
```

***

## Attack and caught-by table

| Attack                                           | Caught by                  | How                                                                                  |
| ------------------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------ |
| Single bit flipped in ciphertext                 | AES-GCM auth tag           | `decryptJson` throws                                                                 |
| Ciphertext truncated or extended                 | AES-GCM auth tag           | `decryptJson` throws                                                                 |
| Two valid records swapped at the server          | Re-derived hash comparison | `sha256(canonicalize(payload))` ≠ `client_payload_hash`                              |
| Old snapshot replayed in place of a new one      | Re-derived hash comparison | `parent_snapshot` is inside the hashed payload, so a stale record's hash won't match |
| Server returns a record from a different project | AES-GCM key isolation      | Wrong key → `decryptJson` throws                                                     |
| Server returns nothing / 404                     | Your request code          | Distinguishable from a verification failure — handle separately                      |

***

## Implementing the check

```typescript theme={null}
import { AgentStateClient, decryptJson, canonicalize, CryptoService } from "@sovseal/sdk";

const client = new AgentStateClient({
  endpoint: process.env.SOVSEAL_ENDPOINT!,
  apiKey: process.env.SOVSEAL_API_KEY!,
});

async function verifiedRestore(agentId: string, key: CryptoKey) {
  const { receipt, ciphertextUrl } = await client.restore({ agentId });
  const ciphertext = await fetch(ciphertextUrl).then((r) => r.text());

  let payload: unknown;
  try {
    payload = await decryptJson(ciphertext, key);
  } catch {
    throw new Error("VSR: auth-tag verification failed — ciphertext was tampered with");
  }

  const expected = await CryptoService.sha256Hex(
    new TextEncoder().encode(canonicalize(payload)),
  );

  if (expected !== receipt.client_payload_hash) {
    // Fail closed. Do not hand this payload to your agent.
    throw new Error(
      `VSR: hash mismatch — expected ${expected}, receipt says ${receipt.client_payload_hash}`,
    );
  }

  return payload;
}
```

Wrap every call site that restores state with this pattern — or a shared helper like the one above — rather than calling `client.restore()` directly and trusting the result.

***

## Where this applies

| Path                                               | Needs the check? | Why                                                                                                                                                               |
| -------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `recall_memory` (MCP tool) against the local index | **No**           | Runs entirely against local LanceDB — the filesystem is inside your trust boundary, not the network. See [recall\_memory](/platform/core-concepts/recall-memory). |
| `client.restore()` / `restoreAt()`                 | **Yes**          | Every byte came from the network. Apply the pattern above before trusting it.                                                                                     |
| `client.lineage()`                                 | **Recommended**  | Each entry you act on should be verified the same way before use.                                                                                                 |

***

## Limits — what this does not protect against

<Note>
  This is a network-layer defense. It cannot protect you from the following, which need separate mitigations:

  * **Compromised device.** If your AES-256 key is exfiltrated, the checks still pass — an attacker with the key can forge valid records. Protect key material via the OS keychain and restrict physical access.
  * **Weak or guessable passphrase.** Key derivation is only as strong as the secret it derives from.
  * **Freshness.** This confirms a record is exactly what was written — it does not confirm it is the *most recent* write for that agent. Use `sequence_number` and lineage walking for freshness guarantees.
  * **Denial of service.** A malicious server can simply refuse to return your records. The check doesn't prevent that; it only ensures that if you *do* get records back, they are authentic.
</Note>

***

## Operational guidance

<Warning>
  **Treat a verification failure as a security incident, not a transient error.** It means either the replication endpoint is compromised or your local state disagrees with what the server holds. Do not silently retry.

  * **Quarantine, don't retry.** Retrying rarely helps and may amplify damage if the server is actively malicious.
  * **Log both hashes.** When the comparison fails, log the expected hash and the one on the receipt — that delta is your forensic evidence.
  * **Wire it into your own observability.** There is no built-in event to subscribe to; raise your own alert from the `catch` block above.
</Warning>

***

## Next steps

<CardGroup cols={2}>
  <Card title="Zero-Knowledge Guarantees" icon="lock" href="/platform/core-concepts/zero-knowledge">
    The broader cryptographic contract this pattern operates inside, including the formal threat model.
  </Card>

  <Card title="Deterministic Lineage" icon="timeline" href="/platform/core-concepts/deterministic-lineage">
    Why parent-pointers make substitution structurally detectable.
  </Card>

  <Card title="Cryptographic Trust Center" icon="shield-halved" href="/platform/trust">
    Consolidated threat model, key custody boundaries, and honest compliance posture.
  </Card>

  <Card title="AES-256-GCM" icon="key" href="/components/encryption/aes-256-gcm">
    Primitive-level detail on the authentication tag and nonce generation.
  </Card>
</CardGroup>
