---
name: arkiv-privacy-patterns
description: Privacy discipline for Arkiv, the queryable database on Ethereum. Every entity — payload AND attributes — is publicly readable, so privacy is application-layer work. Use whenever an Arkiv / @arkiv-network/sdk app touches sensitive data. Covers encrypting payloads client-side (Web Crypto AES-GCM, wallet-signature-derived keys, HKDF envelopes), deciding what is safe to index as plaintext attributes, anonymous-but-verifiable writes (burner wallets, ZK nullifiers), sharing encrypted data via wrapped-key grant entities, hash-anchor notarization (proof of existence without PII), right-to-forget layering (key revocation + tombstone + expiration), and tamper-evident hash-chained audit logs. Trigger words - encrypt, encryption, privacy, private data, PII, secrets, anonymous, whistleblower, nullifier, access grant, wrapped key, notarize, document hash, right to forget, "is Arkiv data public?".
license: MIT
metadata:
  arkiv.skill-version: "1.1.0"
  arkiv.sdk-package: "@arkiv-network/sdk"
  arkiv.sdk-range: ">=0.6.5 <0.7.0"
  arkiv.sdk-verified: "0.6.8"
  arkiv.network-fingerprint: "braga:60138453102"
  arkiv.surface: "createPublicClient, createWalletClient, http, privateKeyToAccount, generatePrivateKey, braga, eq, buildQuery, createEntity, mutateEntities, deleteEntity, getEntity, signMessage, jsonToPayload, ExpirationTime, toHex, hexToBytes, NoEntityFoundError"
  arkiv.last-verified-at: "2026-06-11"
  arkiv.evidence: "6 patterns from 124 hackathon submissions across 5 events"
---

# arkiv-privacy-patterns

Use this skill when an Arkiv app stores anything a user would not post on a public forum. Arkiv is a queryable database on Ethereum and **everything in it is public**: payload bytes, every attribute, owner, creator, block timestamps, expiration. Privacy is not a setting you turn on — it is a discipline you apply before the write. This skill DECIDES the defaults: AES-256-GCM payload encryption under a wallet-signature-derived key, plaintext attributes only for non-sensitive index fields, and one of five named patterns for everything beyond that.

## Ground rules

- Network today: **Braga** testnet — chainId `60138453102`, RPC `https://braga.hoodi.arkiv.network/rpc`, faucet `https://braga.hoodi.arkiv.network/faucet/`, explorer `https://explorer.braga.hoodi.arkiv.network`. **Networks rotate. Verify at https://docs.arkiv.network before relying on these. Testnet entities do NOT migrate between networks.**
- SDK: `@arkiv-network/sdk` `>=0.6.5 <0.7.0` (this skill verified against 0.6.8). The SDK re-exports all of viem — import `http`, `toHex`, `hexToBytes`, and accounts from the SDK subpaths; do NOT install viem separately.
- **Pin Node 22 LTS.** Node 24 hangs entity updates forever — the tx lands on-chain but the promise never resolves (sdk issue #14).
- Gas is testnet GLM. Sign with throwaway keys only, never real funds.

## The three iron rules

1. **Every entity is publicly readable.** Anyone with the RPC URL can query your data. There is no private entity, no access control on reads, no "only my app can see this".
2. **Attributes are ALWAYS plaintext and queryable — never put secrets or raw PII in attributes.** Attributes are the index. Index only what is safe to publish to the world. This is the single most-violated rule in the evidence corpus: PayFlow stored employee names, emails, and salaries in public entities while its README claimed "privacy"; even on-message (1st place, ETH Argentina 2025) shipped "fully private" messaging whose message text was a plaintext, publicly queryable attribute — its privacy was expiration-ephemerality only.
3. **Privacy = client-side encryption before the write.** There is no official Arkiv encryption helper — this is the #1 library gap in the corpus (17+ teams rolled their own DIY schemes). Use Web Crypto primitives only, keep the scheme simple, and document it in your README so judges and auditors can follow it.

## Per-field privacy-tier worksheet

Classify EVERY field before writing schema code. Three tiers, no fourth:

| Tier | Where it lives | Who reads it | Put here |
|---|---|---|---|
| public-attribute | plaintext attribute | anyone, queryable | type/status enums, epoch-ms numbers, hex hashes, project namespace |
| public-payload | plaintext payload | anyone who fetches the entity | non-sensitive content you never filter on |
| encrypted-payload | AES-GCM ciphertext payload | key holders only | PII, message bodies, medical/financial/location data, anything regretted later |

Default when unsure: encrypted-payload. You can always re-publish plaintext; you can never un-publish.

## Decide in 30 seconds

| Your situation | Use |
|---|---|
| Sensitive content, the owner reads their own data | encrypt-payload-client-side (the default) |
| The WRITER's identity is the secret | anonymous-but-verifiable |
| Share encrypted data with specific people, auto-revoking | wrapped-key-sharing-grants |
| Prove a document existed/unchanged — no content on-chain | hash-anchor-notarization |
| Users can demand deletion | right-to-forget-layers (design it BEFORE the first write) |
| Prove a log/sequence wasn't rewritten | hash-chained-audit-log |
| Nothing sensitive at all | no crypto — but still run the "what leaks" checklist below |

## What leaks anyway (even with a perfectly encrypted payload)

| Leak | What the public sees | Mitigation |
|---|---|---|
| Writer address | `$owner`/`$creator` link ALL your writes together | burner wallet per context; relayer/gateway signer (the burner pattern below) |
| Timestamps | `$createdAtBlock`, expiration — activity rhythms, timezone | batch writes; flush on a coarse schedule, not per user action |
| Attribute values | every plaintext attribute, forever queryable | hash linking values with a private pepper; coarse buckets instead of exact values |
| Attribute KEYS | schema shape reveals the domain (`hivStatus` leaks even with no value) | neutral key names (`category`, `tier`, `score`) |
| Entity counts/sizes | how many records, how often, how big | usually accept it; pad/dummy entities only if count itself is the secret |
| Expiration choice | the expiration date reveals the business rule (grant length, retention) | usually acceptable — or randomize within a window |

Run this table as a checklist in design review. "Seen in 7 of 124 submissions" projects (OpenArkiv, GhostMesh) exist precisely because the writer address is metadata.

---

## encrypt-payload-client-side-attributes-plaintext

**The default privacy pattern.** Encrypt the payload in the client with AES-256-GCM under a key derived from a wallet signature (the key never leaves the browser, no key server needed); keep a minimal set of non-sensitive attributes plaintext so the data stays queryable. Mark encrypted rows with a numeric `confidential: 1` attribute so readers know to decrypt.

Seen in 25 of 124 submissions — HealthArkiv (encrypted symptoms + plaintext category/severity for `severity > 7` queries), Sidebrain, Susurro, Fhedin (selective encryption, `confidential` 0/1 marker), RecallKit, Rootgraph (NaCl-encrypted salaries + Noir ZK range proofs).

```ts
// crypto.ts — Web Crypto only, runs in browser and Node 22. No extra deps.
import { hexToBytes } from "@arkiv-network/sdk"; // viem re-export

const te = new TextEncoder();

// Derive a stable AES key from a wallet signature over a FIXED message.
// EOA wallets sign deterministically (RFC 6979): same message -> same signature -> same key.
// CAVEAT: some MPC/smart-contract wallets are NOT deterministic — sign twice at startup,
// compare, and fall back to a user passphrase if they differ. Never persist the
// signature or the key; re-derive per session, hold in memory only.
const KEY_MESSAGE = "myapp-v1 encryption key v1"; // changing this string = losing all data
export async function keyFromSignature(signature: `0x${string}`, scope: string) {
  const seed = await crypto.subtle.importKey("raw", hexToBytes(signature), "HKDF", false, ["deriveKey"]);
  return crypto.subtle.deriveKey(
    { name: "HKDF", hash: "SHA-256", salt: te.encode(scope), info: te.encode("arkiv-payload-v1") },
    seed, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
  );
}

export async function seal(key: CryptoKey, data: object): Promise<Uint8Array> {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, te.encode(JSON.stringify(data))));
  const out = new Uint8Array(12 + ct.length);
  out.set(iv); out.set(ct, 12);
  return out;
}

export async function open(key: CryptoKey, sealed: Uint8Array): Promise<any> {
  const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: sealed.slice(0, 12) }, key, sealed.slice(12));
  return JSON.parse(new TextDecoder().decode(pt));
}
```

```ts
// arkiv.ts — two clients always: the wallet client has NO read actions.
import { createPublicClient, createWalletClient, http } from "@arkiv-network/sdk";
import { privateKeyToAccount } from "@arkiv-network/sdk/accounts";
import { braga } from "@arkiv-network/sdk/chains";
import { eq } from "@arkiv-network/sdk/query";
import { ExpirationTime } from "@arkiv-network/sdk/utils";
import { keyFromSignature, seal, open } from "./crypto";

const PROJECT = "myapp-v1"; // namespace — stamp on EVERY write and EVERY query
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); // throwaway testnet key
const publicClient = createPublicClient({ chain: braga, transport: http() });
const walletClient = createWalletClient({ chain: braga, transport: http(), account });

// One signature per session (in the browser: personal_sign via the user's wallet).
const sig = await walletClient.signMessage({ account, message: "myapp-v1 encryption key v1" });
const key = await keyFromSignature(sig, "default");

export async function createSealed(data: object, indexAttrs: { key: string; value: string | number }[]) {
  return walletClient.createEntity({
    payload: await seal(key, data),
    contentType: "application/octet-stream", // ciphertext is binary — NOT application/json
    attributes: [
      { key: "project", value: PROJECT },
      { key: "confidential", value: 1 },     // numeric 1 = encrypted; omit on plaintext rows
      ...indexAttrs,                          // ONLY tier-1 public-attribute fields go here
    ],
    expiresIn: ExpirationTime.fromDays(30),  // expiration date is a design decision — see arkiv-expiration-design
  });
}

// Sealing N records at once (and to flush on a coarse schedule — see the leak table above)?
// Put all the creates in ONE mutateEntities({ creates }) — ≤1000 ops/tx, one signature, one
// nonce — never loop createSealed (looping collides on the nonce; arkiv-write-safety).
export async function createSealedBatch(
  items: { data: object; indexAttrs: { key: string; value: string | number }[] }[],
) {
  const creates = await Promise.all(items.map(async ({ data, indexAttrs }) => ({
    payload: await seal(key, data),
    contentType: "application/octet-stream",
    attributes: [{ key: "project", value: PROJECT }, { key: "confidential", value: 1 }, ...indexAttrs],
    expiresIn: ExpirationTime.fromDays(30),
  })));
  const out = [];
  for (let i = 0; i < creates.length; i += 1000)        // chunk sets larger than the 1000-op cap
    out.push(await walletClient.mutateEntities({ creates: creates.slice(i, i + 1000) }));
  return out;
}

export async function readSealed(extra: ReturnType<typeof eq>[]) {
  const r = await publicClient.buildQuery()           // fresh builder per query — fetch() mutates it
    .where([eq("project", PROJECT), eq("confidential", 1), ...extra])
    .ownedBy(account.address)
    .withPayload(true).withAttributes(true)            // builder defaults are EMPTY — without this, no payload to decrypt
    .limit(50)
    .fetch();
  return Promise.all(r.entities.map((e) => open(key, e.payload!)));
}
```

Rules baked into the default: do NOT write `confidential: 0` on plaintext rows (numeric `0` has a wire quirk — it encodes as an empty string); just omit the attribute. Selective encryption (Fhedin) is fine: encrypt only the entities that need it, mark them, leave the rest plaintext-queryable.

## anonymous-but-verifiable

When the WRITER must be hidden too — whistleblowing, anonymous reporting, voting — encrypting the payload is not enough, because the signing address is metadata. Two blessed variants:

**A. Burner wallet per submission.** Generate a fresh key per report so reports cannot be linked to each other or to you. Honest caveat: the funding transaction links burners — one treasury funding N burners is clusterable on the explorer. On testnet, accept it or fund each from a separate faucet claim; in serious designs, use variant B.

```ts
import { generatePrivateKey, privateKeyToAccount } from "@arkiv-network/sdk/accounts";
const burner = privateKeyToAccount(generatePrivateKey()); // one per report; fund minimally, then discard
```

**B. Relayer + ZK nullifier attribute.** One gateway wallet signs ALL writes (so the address reveals nothing about the author); authorship integrity comes from a nullifier — a hash of a user secret + scope — stored as an attribute, which prevents double-posting without revealing identity. ArkAnon's split: Semaphore ZK proofs verified on Scroll, Arkiv serves the queryable feed (see arkiv-crosschain). RIKUY's simpler version: `nullifier = sha256(userSecret)`.

```ts
import { toHex } from "@arkiv-network/sdk";
const sha256Hex = async (s: string) =>
  toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s))));

const nullifier = await sha256Hex(`${userSecret}:${scope}`); // hex output — safe to interpolate into queries
const dup = await publicClient.buildQuery()
  .where([eq("project", PROJECT), eq("type", "report"), eq("nullifier", nullifier)])
  .withAttributes(true).limit(1).fetch();
if (dup.entities.length > 0) throw new Error("already submitted in this scope");
// then the RELAYER wallet writes the report entity carrying { nullifier, scope, category } attributes
```

Also minimize before you anonymize (RIKUY, 3rd place Arkiv track, ETH Argentina 2025 — used NO encryption, only minimization): strip EXIF from images before upload, round coordinates to ~1 km, replace identity with the nullifier, hash device traits. Minimization is cheaper than crypto and survives key compromise.

Seen in 7 of 124 submissions — OpenArkiv (winner, Best DePIN — metadata-stripping whistleblower mode), ArkAnon (winner, Best Real-Time, ETH Argentina), GhostMesh (mixnet-routed writes), Ark Link (winner, $1.5K), Amigo.ai.

## wrapped-key-sharing-grants

To share encrypted data with a specific person, never re-encrypt the data — encrypt ONCE with a random content key (CEK), then wrap the CEK for each recipient and ship the wrapped key inside a grant entity. The grant's expiration IS the access window (see arkiv-expiration-design: expiry-as-enforcement). For unlinkability, link grants to data via HASHED attributes (Arkivox's `granteeHash`) so observers cannot see who shares what with whom.

```ts
import { jsonToPayload } from "@arkiv-network/sdk/utils";

// 0. Each user publishes a wrapping public key ONCE (e.g. in their profile entity).
//    Boring and documented: RSA-OAEP, exactly what Casebook shipped (RSA-OAEP + AES-GCM).
export async function makeWrappingKeys() {
  return crypto.subtle.generateKey(
    { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
    true, ["wrapKey", "unwrapKey"]); // export publicKey as JWK -> profile; keep privateKey local
}
export async function wrapKeyFor(recipientPubJwk: JsonWebKey, cek: CryptoKey) {
  const pub = await crypto.subtle.importKey("jwk", recipientPubJwk,
    { name: "RSA-OAEP", hash: "SHA-256" }, false, ["wrapKey"]);
  return toHex(new Uint8Array(await crypto.subtle.wrapKey("raw", cek, pub, { name: "RSA-OAEP" })));
}
export async function unwrapMyKey(myPrivateKey: CryptoKey, wrappedHex: `0x${string}`) {
  return crypto.subtle.unwrapKey("raw", hexToBytes(wrappedHex), myPrivateKey, { name: "RSA-OAEP" },
    { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
}

// 1. Encrypt the report once with a random CEK (extractable so it can be wrapped).
const cek = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
// 2. Wrap the CEK for this recipient.
const wrappedKey = await wrapKeyFor(recipientPubJwk, cek);
// 3. The grant entity: wrapped key in the payload, hashed linkage in the attributes.
const PEPPER = appSecret; // private pepper so hashes aren't dictionary-reversible
await walletClient.createEntity({
  payload: jsonToPayload({ wrappedKey, reportKey }),
  contentType: "application/json",
  attributes: [
    { key: "project", value: PROJECT },
    { key: "type", value: "grant" },
    { key: "granteeHash", value: await sha256Hex(granteeAddress + PEPPER) }, // queryable by the grantee, opaque to everyone else
    { key: "reportHash", value: await sha256Hex(reportKey + PEPPER) },
  ],
  expiresIn: ExpirationTime.fromDays(7), // access auto-revokes when the grant expires
});
// Grantee finds their grants: eq("granteeHash", sha256Hex(myAddress + PEPPER)), unwraps the CEK, decrypts.
```

Early revocation = `deleteEntity` on the grant (instant, unrecoverable) — and pair it with a longer-lived plaintext `grant_record` audit entity if you need history to outlive access (SealVault's two-tier trick, in arkiv-expiration-design).

Seen in 5 of 124 submissions — Redline (wrapped key per AccessGrant), Casebook (per-agent RSA-OAEP clues), Arkivox (TEE-wrapped DEK + hashed-attribute linking), holomem (ECIES cross-agent shares).

## hash-anchor-notarization

Proof of existence/integrity with ZERO sensitive bytes on-chain: store only a SHA-256 fingerprint plus queryable metadata; the document and all PII stay off-chain. Verification is one `eq()` query. Set `expiresIn` to the validity window — an expired anchor IS an expired certificate (Certik's framing).

```ts
async function anchorDocument(file: Uint8Array, docType: string) {
  const fingerprint = toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", file)));
  return walletClient.createEntity({
    payload: jsonToPayload({ docType, anchoredAt: Date.now() }), // metadata ONLY — never the document
    contentType: "application/json",
    attributes: [
      { key: "project", value: PROJECT },
      { key: "type", value: "anchor" },
      { key: "sha256", value: fingerprint },     // the fingerprint is the index
      { key: "anchoredAt", value: Date.now() },  // numeric epoch-ms for range queries
    ],
    expiresIn: ExpirationTime.fromYears(1),      // validity period, not "forever"
  });
}

async function verifyDocument(file: Uint8Array): Promise<boolean> {
  const fingerprint = toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", file)));
  const r = await publicClient.buildQuery()
    .where([eq("project", PROJECT), eq("type", "anchor"), eq("sha256", fingerprint)])
    .createdBy(TRUSTED_ISSUER) // CRITICAL: the namespace is organization, NOT auth — anyone can stamp
                               // your project attribute on a forged anchor. $creator is the trust boundary.
    .withAttributes(true).limit(1).fetch();
  return r.entities.length > 0; // not found OR expired => INVALID. Absence is the verdict.
}
```

If you hold an entityKey directly, `getEntity(key)` THROWS `NoEntityFoundError` for missing AND expired entities — catch it and return "invalid/expired", don't crash. TruthStamp adds a perceptual `phash` attribute beside `sha256` to catch lightly-edited media.

Seen in 5 of 124 submissions — NotarIA (medical hashes, "zero PII"), VendorPass (QR-verified compliance docs), TruthStamp (journalist photo/video anchors), ArkivLog (payloadHash per tool call), PAWSI (location_hash geo anonymization).

## right-to-forget-layers

"Delete" on a public chain has three independent layers — design all three, in this order:

1. **Expiration by default** (the floor): every entity gets a finite `expiresIn` sized to its real retention need, so data dies by default with no delete code. This is Arkiv's native forgetting.
2. **Key revocation** (the real eraser for encrypted data): derive one key per scope — per topic, per session — via the HKDF `scope` parameter above. Forgetting a scope = destroying/never-re-deriving that scope's key; ciphertext copies anyone fetched become unreadable.
3. **Tombstone**: `await walletClient.deleteEntity({ entityKey })` removes the live entity. If you need an auditable "this was removed" marker instead, full-replace-update the entity with a marker payload — but remember `updateEntity` is FULL REPLACE: re-send payload, ALL attributes, contentType, and expiresIn (see arkiv-write-safety).

```ts
// scope-granular keys make revocation surgical:
const topicKey = await keyFromSignature(sig, `scope:${topicId}`); // revoke topic = drop this key only
```

Be honest in your UI and README about what cannot be undone: plaintext that was ever published and fetched is out; expired/deleted entities may persist in third-party archives and RPC caches. Layer 2 is why the encrypt-by-default rule matters — revocation only works on ciphertext.

Seen in 1 of 124 submissions — Amigo.ai (user-facing memory management: tombstone + key revocation + expiration as three exposed controls).

## hash-chained-audit-log

Tamper-EVIDENCE (not secrecy): chain each log/snapshot entity to the previous one so any rewrite breaks every later hash, and anyone can replay-verify the whole session from public queries alone. Pairs with encrypted payloads — the chain proves integrity even when content is sealed.

```ts
// write side: commitHash = sha256(prevHash + canonical payload JSON)
const body = JSON.stringify(snapshot);                  // use ONE canonical serialization everywhere
const commitHash = await sha256Hex(prevHash + body);
await walletClient.createEntity({
  payload: jsonToPayload({ ...snapshot }),
  contentType: "application/json",
  attributes: [
    { key: "project", value: PROJECT },
    { key: "type", value: "snapshot" },
    { key: "sessionId", value: sessionId },
    { key: "seq", value: seq },                          // numeric — you will sort on it IN JS
    { key: "commitHash", value: commitHash },
    { key: "prevHash", value: prevHash },
  ],
  expiresIn: ExpirationTime.fromDays(90),
});

// verify side: fetch all, sort in JS (NEVER .orderBy() — the server silently ignores it
// and it is removed in SDK 0.7.0), then re-hash the chain.
const r = await publicClient.buildQuery()
  .where([eq("project", PROJECT), eq("type", "snapshot"), eq("sessionId", sessionId)])
  .withPayload(true).withAttributes(true).limit(200).fetch();
const att = (e: any, k: string) => e.attributes.find((a: any) => a.key === k)?.value;
const ordered = [...r.entities].sort((a, b) => Number(att(a, "seq")) - Number(att(b, "seq")));
let expect = GENESIS_HASH;
for (const e of ordered) {
  if (att(e, "prevHash") !== expect) throw new Error(`chain broken at seq ${att(e, "seq")}`);
  expect = await sha256Hex(expect + e.toText()); // toText() throws if payload wasn't fetched
}
```

Seen in 5 of 124 submissions — Ark Hive (replay-verifiable game sessions), Cortex (MMR state_root + per-decision citations), ProofForge (deterministic verifier scripts), espresso.

---

## Anti-patterns (each one shipped by a real team — don't repeat them)

- **Secrets or PII in attributes/plaintext payloads.** PayFlow: names, emails, salaries in public entities under a README claiming "privacy". on-message (a 1st-place winner): "fully private" with plaintext message attributes. Anyone can read either with one query. Encrypt or don't write it.
- **Keys derived from PUBLIC data.** Ramen Protocol derived AES keys via PBKDF2 from public wallet ADDRESSES with a hardcoded salt — anyone can derive any recipient's key, so "successor-only decryption" was false. Derive from a SIGNATURE (gated by the private key), never from an address.
- **Indexing on secrets "so I can query them".** An attribute named after, or containing, sensitive data leaks by existing. Hash with a private pepper (granteeHash) or restructure the query.
- **Inventing clever crypto at 3am.** No nested custom schemes, no homemade key stretching. AES-256-GCM + HKDF + (RSA-OAEP or ECIES for wrapping), all from Web Crypto, documented in the README. Complexity is where the Ramen-class bugs live.
- **Claiming "permanent storage" or "fully private".** There is no permanence primitive — huge expiration values (Pulse's 100-year entities) are a workaround, and copies already fetched survive any deletion. Say "queryable database on Ethereum" with "tamper-proof, expiration-scoped records"; never "decentralized database", "trustless", or "permanent".
- **`.orderBy()` anywhere.** Silently ignored by the server today, deleted in SDK 0.7.0. Sort in JS after fetching.

## SDK gotchas encoded here (numbers = SDK survey)

- **#10 — query-string injection.** Predicate string values are quoted but NOT escaped: a `"` inside user data breaks or alters the query (sdk issue #67). Only interpolate values YOU generated (hex hashes, slugs, enums). For anything user-supplied, sanitize first: `v.replace(/["\\]/g, "")` or reject. Hex hashes are the privacy bonus: they're injection-safe by construction.
- **#7 — builder data defaults are EMPTY.** Without `.withPayload(true).withAttributes(true)` you get keys only, and `entity.toText()`/`toJson()` THROW — your decrypt step dies. (Asymmetry: raw `client.query()` defaults payload to true.)
- **#9 — `getEntity` throws `NoEntityFoundError`** for missing AND expired entities. In verification flows, catch it and return "expired/invalid" — absence is a verdict, not an error.
- **#15 — attribute values are `string | number` only.** Booleans and bigints are SILENTLY DROPPED at encode time; numeric `0` encodes as an empty string. Hence `confidential: 1` present/absent, never `true`/`false`/`0`.
- **#20 — `contentType` is required** on every create/update: `application/octet-stream` for ciphertext, `application/json` + `jsonToPayload()` for plaintext JSON.
- **#1 — `updateEntity` is FULL REPLACE.** Rotating ciphertext or tombstoning via update means re-sending payload + ALL attributes + contentType + expiresIn. Read → merge → write. Details in arkiv-write-safety.

## Compose with

- **arkiv-data-modeling** — attribute design, namespaces, the worksheet that decides what becomes an attribute at all.
- **arkiv-expiration-design** — grant windows, expiry-as-enforcement, "expired anchor = expired certificate", retention sizing.
- **arkiv-query-patterns** — sanitization deep-dive, pagination, the full query syntax behind the snippets here.
- **arkiv-write-safety** — full-replace mechanics, batching grants atomically with `mutateEntities`, write queues for relayer wallets.
- **arkiv-agent-state** — the private-state/public-log split for AI agents (Fhedin's encrypted memory + plaintext decision log).
- **arkiv-crosschain** — the ArkAnon split: ZK verification on another chain, Arkiv as the queryable public feed.
