# Memories: direct HTTP contract

Use `https://api.decoys.me` for HTTP requests. The [MCP quickstart](/docs/memory-quickstart.md) provides seven memory tools and level-based access. Inspect `tools/list` for exact inputs.

Use the [memory helper](/examples/agent-memory.mjs) for encrypted HTTP requests, or the [MCP memory tools](/agent-memories.md) to handle encryption inside your trusted runtime.

Saving needs the **Save memories** permission, `{"kind":"memory","actions":["create"]}`. Without it, `POST /api/agent/context/memories` returns `403 INSUFFICIENT_GRANT`. An agent always reads, corrects and deletes what it saved. Other memories need a memory or account grant; see [access levels](/docs/access.md). To know what you may do, read `access.memories` on `GET /api/agent/policies` (`save`, `read`, `write`, `delete`), never `scopes`: no scope covers memories ([how to read it](/docs/access.md#what-you-can-do-now)).

[Memory walkthrough](/docs/memory-quickstart.md) · [Access guide](/docs/access.md)

## Use memory during the task

For ongoing behavior, read the [operating guide](/agent-operating-guide.md); do not assume the initial setup chat will be loaded again. Within the person's adopted saving preference and effective access, capture useful context at substantive findings, decisions, corrections and progress milestones, before moving to the next phase or returning a result. Reuse/update matching records, apply supported categories/topics and independently read/decrypt the saved rows. Do not leave all writes for task completion or substitute a host-local memory store. If another account or credential operation is blocked, continue independent permitted memory work. A custom save-memory script that has never run is untested; use or verify the documented path rather than reporting a plan as a save. No background job is required by this guidance.

Keep credentials out of this memory store. The helper's sealPassword function and /accounts/{id}/credential route are for a login password, not an API key or a general secret field. Follow the [storage contract](/agent-storage.md) for supported account-field operations. A successful encrypted-password write does not prove readable content or correctly typed API-key storage.

<a id="read-first-write-as-you-go"></a>
### Read first, write as you go

The [five habits](/agent-memories.md#five-habits-for-every-task) as one runnable flow: read what is known before starting, save an intermediate result with its categories and topic while the task is still running, then correct that same memory when its status changes. A synthetic example, not a fact about the reader. `get`, `post` and `put` are your authenticated JSON calls to `https://api.decoys.me`; `me` is the key pair from [agent-envelope.mjs](https://www.decoys.me/examples/agent-envelope.mjs).

<!-- example:read-then-write -->
```js
import { summarizeRows, readMemoryRows, listTopics, findTopic, sameTopic, newTopicRows, newMemoryRow, readMemoryRow, editMemoryRow } from './agent-memory.mjs';

// 1. READ BEFORE YOU START. One GET returns every row this connection can open: memories, topics
//    and links in one array. Print what it holds first. The server cannot search ciphertext:
//    decrypt and filter here.
const g = await get('/api/agent/context/memory-grantees');
const keys = { ownerPublicKey: g.owner_public_key, agentPublicKey: me.publicKeyBase64, grantees: g.grantees };
let { memories: served } = await get('/api/agent/context/memories');
console.log(summarizeRows(served, me.privateKeyBase64).text); // e.g. "72 memories, 8 topics, 8 links, 0 unreadable"
const topics = listTopics(served, me.privateKeyBase64);
const subject = 'Lisbon trip';
let topic = findTopic(topics, subject);
const words = ['lisbon', 'flight'];
// readMemoryRows returns the memories and skips topics, links and unreadable rows.
const relevant = readMemoryRows(served, me.privateKeyBase64, topics).filter((m) =>
  (topic && sameTopic(m.topic, topic)) || words.some((w) => m.text.toLowerCase().includes(w)));
// Use `relevant` now, before asking the person anything it already answers.

// 2. WRITE AS YOU GO. An intermediate result, saved mid-task with categories AND a topic.
const rows = [];
if (!topic) {
  // A new topic says how it connects: here the person is planning this trip.
  const made = newTopicRows({ name: subject, type: 'trip', link: { relationType: 'is planning' } }, keys);
  topic = { id: made.topicId, name: subject };
  rows.push(...made.rows);
}
const finding = newMemoryRow({ topic, categories: ['travel'], text: [
  'Lisbon trip: flight search, intermediate result.',
  'Finding: direct morning flights run on Tuesdays and Fridays only (checked 2026-10-05).',
  'Dead end: the Thursday evening option needs two connections; do not propose it again.',
  'Status: no flight chosen yet.',
].join('\n') }, keys);
rows.push(finding);
await post('/api/agent/context/memories', { rows });

// 3. KEEP IT CORRECT. The person chooses a flight: update the same memory by id, do not add another.
({ memories: served } = await get('/api/agent/context/memories'));
const current = served.find((m) => m.id === finding.id);
const text = readMemoryRow(current, me.privateKeyBase64).text
  .replace('Status: no flight chosen yet.', 'Status: chose the Friday 08:10 direct flight; not booked yet.');
await put('/api/agent/context/memories', editMemoryRow(current, { text }, me.privateKeyBase64, keys));
```

Do the read in step 1 at the start of every new task, and a save like step 2 at each checkpoint, not once when the task is over. Keep booking references, passwords and keys out of the text; they belong on the account.

## Direct HTTP contract

These are authenticated agent routes on `https://api.decoys.me`. Keep encryption in the agent/provider runtime. **Use [agent-memory.mjs](https://www.decoys.me/examples/agent-memory.mjs)** (dependency-free, Node 18+), or port it exactly: it builds, reads and edits rows byte-for-byte as the Decoy app and other agents read them. The server never decrypts, so it accepts a wrongly sealed row and the app silently can't read it.

| Operation | Route | Contract |
| --- | --- | --- |
| Discover recipients | `GET /api/agent/context/memory-grantees` | Owner public key and eligible connections' public keys/selectors; not memory text/labels |
| Save | `POST /api/agent/context/memories` | `rows: [{id,kid,ciphertext,owner_wrap,self_wrap,grantee_wraps?}]` (memories, and a new topic's entity and relation rows); needs Save memories. Each row lands together with its wraps or not at all. When its author resends the same rows (same id and key), they come back under `existing` and any missing wrap is filled in, then read back every row and its usable wrap to confirm completion; anyone else's resend of an id adds nothing |
| Read | `GET /api/agent/context/memories` | Visible `memories` with id, kid, rev, ciphertext, wrapped_key, via, mine, author_kind and timestamps |
| Edit | `PUT /api/agent/context/memories` | `{id,expected_rev,ciphertext}`. The author changes text, categories or topic; a matching Read & write grant corrects the text only. With a new `kid` it also carries `owner_wrap`, `self_wrap` and `grantee_wraps?`: a key rotation, author only |
| Delete | `POST /api/agent/context/memories/delete` | `{ids}` → `{deleted,refused}`; author or Full access |
| Share a key | `POST /api/agent/context/memory-wraps` | `{row_id,kid,wraps}`; author only |

### Exact formats

All binary fields are standard padded base64. Public keys are RSA SPKI DER (base64); `owner_public_key` comes from `GET /api/agent/context/memory-grantees` and is null until the owner finishes key setup in the app (complete that setup before saving).

- **Key.** Each memory has its own random 32-byte key. `kid` = lowercase hex of the first 8 bytes of SHA-256(key).
- **`ciphertext`** = VAULT SealedBlob under that key: `[0x01 version][0x01 mode][12-byte random nonce][AES-256-GCM ciphertext][16-byte tag]`, no AAD. Its plaintext is UTF-8 JSON bound to the row:
  - about the user: `{"v":2,"row_id":"<the row id>","observation":{"entityName":"default_user","content":"Prefers short answers."},"about":["user"],"categories":["user_preferences"]}`
  - about a granted account: `{"v":2,"row_id":"<id>","observation":{"content":"Uses squash merges."},"about":["account:<account uuid>"]}` (never the account's name).
  - attached to a topic: `{"v":2,"row_id":"<id>","observation":{"entityName":"Golf club headcovers","content":"..."},"about":["topic:<topic entity row id>"],"categories":["sports","fashion"]}`. Exactly one `about` value per memory: the user, one account or one topic. `entityName` is the topic's current name.
  - Optional: `categories` (any number of mem0's categories, deduped; a category grant on any of them reaches the memory), `metadata` (an object of free notes; it files nothing, so a topic never goes there), and `about_hint` (a place you named that isn't a granted account).
- **Topic rows** use the same key, `kid`, `ciphertext` and wraps as a memory; only the plaintext differs:
  - the topic itself is an entity row: `{"v":2,"row_id":"<id>","entity":{"name":"Golf club headcovers","entityType":"product"},"about":[]}`. Its id is `topic:<that row id>`; memories and grants name it that way. `name` is at most 200 characters and never the user (`default_user`, `you`, `user`, `me`). `entityType` is one of `device`, `document`, `event`, `goal`, `hobby`, `media`, `organization`, `person`, `pet`, `place`, `product`, `project`, `team`, `trip`, or `""` when no type is given.
  - a new topic gets one relation row that places it on the Map: `{"v":2,"row_id":"<id>","relation":{"from":"default_user","to":"Golf club headcovers","relationType":"<the relation>"},"endpoints":["user","topic:<topic entity row id>"],"about":[]}`. It runs from `default_user` (You) by default, or from an existing topic (`from` its name, first endpoint its `topic:<id>`). `relationType` is free text in active voice, at most 100 characters: the relation that is true for this person and this subject ([connections](/agent-memories.md#connections)). Left out, the helper writes "is interested in" from You and "includes" from a topic.
- **`self_wrap`** (and each `grantee_wraps[].wrapped_key`) = headerless envelope to that connection's public key: `[2-byte BE key length][RSA-OAEP-SHA256 wrapped AES-256 key][12-byte IV][AES-256-GCM ciphertext][16-byte tag]`, whose plaintext is the raw 32-byte memory key.
- **`owner_wrap`** = `[0x01][0x02]` followed by the same envelope to `owner_public_key`, whose plaintext is the **ASCII base64 text** (44 characters) of the key, not the raw bytes.
- Both wraps are required. `grantee_wraps: [{token_id,via,wrapped_key}]` is optional: include it so other connections whose grants reach the memory can read it at once.
- Never put `text`, `categories` or account names in the HTTP body outside ciphertext.

**Reading.** `GET /api/agent/context/memories` serves each row's `wrapped_key` (a headerless envelope to you, plaintext the raw key). Check `kid` against it, open `ciphertext`, and check the plaintext's `v` is 2 and `row_id` equals the served `id`. Rows whose plaintext has `entity` or `relation` instead of `observation` are graph rows (topics and connections), not memories: the one `memories` array carries all three kinds. `readMemoryRow` returns null for a graph row, so null never means unreadable. It throws for an unreadable row: one this connection's key does not open, or one that opens and is not bound to its id or has a field of the wrong type. `readMemoryRows(memories, privateKey)` returns the memories and skips every other row. `summarizeRows(memories, privateKey)` returns `{memories, topics, links, unreadable, other, unreadableIds, text}`; print `text` first and report a row as unreadable only from `unreadable` ([example](/agent-recipes.md#read-the-array-then-check-where-a-memory-landed)).

**Editing.** Re-seal the whole existing plaintext with only what changed (`observation.content`, `categories`, or `about` plus `observation.entityName` for a topic change) and send `{id, expected_rev, ciphertext}`. Keep every other field, and the id. When the author removes a label (a category, its topic or account), rotate the key so a connection that only had it through that label stops getting it: a new key and `kid`, with `owner_wrap`, `self_wrap` and `grantee_wraps` for every connection the remaining labels still reach. A rotation removes every wrap of the old key. Adding a label needs no rotation. `editMemoryRow` does all of this.

`via` records the access reason: `self`, `all`, `category:<c>`, `topic:<id>`, `account:<id>`, or an all-of reason such as `and:topic:<a>+account:<b>`. A relation needs access to every non-user end; losing either grant invalidates the all-of reason. An account end in a relation carries endpoint `account:<id>` with an empty name; resolve its name from the authorized account list. Do not leak an ungranted endpoint through a relation.

The server rechecks reasons against live grants on every read. It accepts other-recipient wraps only for reasons that recipient holds; `self` cannot be used for another connection. Only the author or owner can share keys; a read/write grant is not permission to reshare.

Use `expected_rev` for edits. On `409 REV_CONFLICT`, read current content and reconcile rather than overwrite the owner's changes. A rotation includes the new `kid`, `owner_wrap`, `self_wrap` and replacement grantee wraps atomically; agent-initiated rotation is author-only. A stale key in the share endpoint returns `409 KID_CONFLICT`. Returned delete IDs and refusals differ from MCP human-facing result shapes; inspect both.

Approved reconnect with the same key keeps memories and their keys. With a new key the connection keeps authorship, but its earlier memories and topics are not served until the owner's Decoy app shares their keys again: until then `GET /api/agent/context/memories` leaves them out of `memories` and reports them as `preparing` with `waiting_on: "owner_phone"` and `retry_after_seconds`, and `listTopics` finds nothing. Decoy asks the owner's phone in the background; read again after that many seconds. Decoy asks for 7 days from when the newest thing owed to the connection came to be owed; after that they are reported as `failed` with `recovery: "stopped_asking"`: stop retrying and carry on with what you can read (a read later may ask the phone once more, at most once a day). If the task needs them now and they are still `preparing` after 60 to 90 seconds of reading, you may ask the owner once to open Decoy, never in advance. With only a category, topic or account grant the read answers `preparing_unknown: true` with no IDs during the first day of asks, then `failed_unknown: true` with `recovery: "stopped_asking"`; neither says whether matching memories exist. [Field names per read](/agent-recipes.md#keep-your-connection-across-sessions). An empty list right after a reconnect is not an empty store. Keep memory and topic IDs, and do not save a memory or recreate a topic again because it is not listed. A new memory for a topic you cannot see yet [waits for that read](/agent-memories.md#when-your-earlier-memories-are-not-readable-yet), then is saved. Reuse the existing connection through the [recovery flow](/agent-recipes.md#7-reuse-the-connection).

## Topics

A topic is a subject the person and their agents keep coming back to, such as a project, a trip, a purchase decision or a hobby. In the Decoy app it appears under Topics and on the Map, with its memories, joined to You or to the topic it is part of by its connection. It is an entity row plus a relation row (formats above); a memory joins it with `about: ["topic:<id>"]`. Categories or a topic name inside the text do not create one, and neither does a topic named in `metadata`: `newMemoryRow` refuses `metadata.topic`, `metadata.topics` and `metadata.topic_name` and points to its `topic` argument.

- **Which topics you can see** is worked out in your runtime, never asked of the server: every topic entity row you can open, plus every `topic:<id>` named by a memory you can open (its name from that memory's `entityName`, its type unknown). `listTopics(memories, privateKey)` returns them oldest first as `[{id, name, type, memories, ids}]`. Same-name topics (by normalized name) are listed once, as the app shows them: under the oldest id and its name, with `ids` naming every copy and `memories` combining their memories. Treat them as one when filtering (`sameTopic`). Access works as for any memory; there is no topic-name directory. A memory you can read reveals its topic's name.
- **Find or create.** Normalize names (`normalizeTopicName`: NFKC, lowercase, collapse whitespace, trim, strip surrounding quotes). An exact normalized match reuses the oldest topic (`findTopic`, which also takes a `topic:<id>`). Otherwise create the entity, its relation and the first memories in **one** POST (`newTopicRows` + `newMemoryRow({..., topic})`). The type is optional (stored as `""`); the user is never a topic name. If the outcome is unknown (a timeout, a dropped connection, a 5xx), POST the same sealed rows again: the server stores each id once, lists repeats under `existing` and fills in any wrap an earlier attempt missed, then read back every row and its usable wrap to confirm completion. Never build new rows for a retry; that makes a second topic.
- **Say how it connects.** `newTopicRows({ name, type, link: { relationType, topic? } }, keys)` writes the topic's one connection, and it is written only then. Give the relation that is true for this person and this subject; pass `link.topic` (`{id, name}` from `findTopic`) when the new topic is part of an existing one. The defaults are for when nothing more specific is true. [How to choose](/agent-memories.md#connections).
- **Create a topic with its first memory.** Write a new topic only in the same POST as its first memory. To file an existing memory under a topic that does not exist yet, POST the topic rows, then PUT the memory; if that PUT is definitely refused (`403`, `404`, `409 REV_CONFLICT` or `KID_CONFLICT`; `isDefiniteRefusal`), delete the topic rows you just wrote (`POST /api/agent/context/memories/delete`), best effort. After a network error, a timeout or a 5xx the PUT may have committed: keep the topic, re-read the memory to learn the outcome, and report it. See the example below.
- **In an add flow, attach only your own duplicate.** When the memory you were about to save already exists, `attachDuplicate(served, {topic, categories})` files it under the topic in place if this connection saved it (`mine`) and it has no topic or account yet. For someone else's memory (another agent's or the owner's) it returns null: report the duplicate and leave that memory untouched, even when you could edit it.
- **Attach, move or detach** an existing memory in place with `PUT` and the same id (`editMemoryRow(served, {topic})`; `topic: null` returns it to You). Relabelling (topic or categories) is for the author or the owner only: a connection editing someone else's memory with Read & write may correct its text, and must leave its labels byte-identical (`editMemoryRow` refuses otherwise). Only author- or owner-approved labels may determine new sharing. This requires matching server edit provenance and owner-client enforcement; the HTTP helper alone cannot establish that protection for historical edits. Naming the memory's current topic, by any spelling, leaves it where it is rather than moving it to the oldest same-name copy. A memory about an account stays with that account. Do not copy a memory to relabel it. `409 REV_CONFLICT` means it changed since you read it: re-read, re-apply, retry.
- **One topic per memory.** Same-name topics are not merged in storage; reuse the oldest and leave merging to the person.
- **Who reads a topic** follows the existing grants: a topic grant (`about: ["topic:<id>"]`) reaches the entity row, its memories, and a relation whose other ends are also granted. With `grantees` from `memory-grantees`, the helper wraps each new row to them at once (`granteeWraps`); otherwise the owner's phone shares the keys after the save.

A POST batch is not one transaction: a failure can leave earlier rows committed, including a topic without its first memory. Keep the original sealed rows and IDs, reconcile by reading back, and retry the same rows after an unknown outcome. Do not delete a topic that a committed memory may already use. Each row is atomic with its own wraps, and its author's resend of the same row fills in a missing wrap; the batch as a whole is not atomic.

### Example: golf club headcovers

A synthetic conversation, not a fact about the reader: someone compares headcovers for their clubs over several turns. The agent lists topics, reuses or creates **Golf club headcovers**, saves detailed memories attached to it in one POST, reads them back, and then corrects one by id. `get`, `post` and `put` are your authenticated JSON calls to `https://api.decoys.me` (throwing on a non-2xx status with `err.status` set, and with no status when the outcome is unknown); `me` is the key pair from [agent-envelope.mjs](https://www.decoys.me/examples/agent-envelope.mjs).

<!-- example:topic-headcovers -->
```js
import { listTopics, findTopic, sameTopic, newTopicRows, newMemoryRow, readMemoryRow, readMemoryRows, editMemoryRow } from './agent-memory.mjs';

const g = await get('/api/agent/context/memory-grantees');
const keys = { ownerPublicKey: g.owner_public_key, agentPublicKey: me.publicKeyBase64, grantees: g.grantees };
const readAll = async () => {
  const { memories } = await get('/api/agent/context/memories');
  return { served: memories, topics: listTopics(memories, me.privateKeyBase64) };
};

// 1. The topics this connection can see.
let { served, topics } = await readAll();

// 2. Reuse "Golf club headcovers" (exact normalized match, oldest first) or create it and say how it
//    connects: as part of an existing "Golf" topic when there is one, otherwise from You.
let topic = findTopic(topics, 'Golf club headcovers');
const rows = [];
if (!topic) {
  const golf = findTopic(topics, 'Golf');
  const link = golf ? { topic: golf, relationType: 'gear includes' } : { relationType: 'is choosing' };
  const made = newTopicRows({ name: 'Golf club headcovers', type: 'product', link }, keys);
  topic = { id: made.topicId, name: 'Golf club headcovers' };
  rows.push(...made.rows);
}

// 3. Detailed memories with every relevant category, attached to the topic.
const fit = newMemoryRow({ topic, categories: ['sports', 'user_preferences'], text: [
  'Golf club headcovers: fit requirements.',
  'Needs: covers for a 460cc driver, a 3-wood and a 4-hybrid.',
  'Constraint: covers must stay on in a cart bag on bumpy paths.',
  'Source: stated in conversation on 2026-10-02.',
].join('\n') }, keys);
const style = newMemoryRow({ topic, categories: ['fashion', 'user_preferences'], text: [
  'Golf club headcovers: style preference.',
  'Preference: navy or grey, no large logos; numbered covers so clubs are easy to tell apart.',
].join('\n') }, keys);
const decision = newMemoryRow({ topic, categories: ['sports', 'fashion'], text: [
  'Golf club headcovers: decision.',
  'Options compared: a knit pom-pom set (warm, but the hybrid cover slipped off) and a synthetic leather set with magnetic closures (stays on, wipes clean).',
  'Decision and reason: chose the synthetic leather set because the magnetic closure keeps covers on in the cart.',
  'Status: chosen, not yet ordered.',
  'Next step: order the navy set from shop.example.com and check whether a matching putter cover exists.',
].join('\n') }, keys);
rows.push(fit, style, decision);

// 4. One POST. When the outcome is unknown, send the same sealed rows again; the server stores each id once.
for (let attempt = 1; ; attempt++) {
  try { await post('/api/agent/context/memories', { rows }); break; }
  catch (err) { if ((err.status && err.status < 500) || attempt === 3) throw err; }
}

// 5. Read back: the topic, its memories and all their categories.
({ served, topics } = await readAll());
const attached = readMemoryRows(served, me.privateKeyBase64, topics).filter((m) => sameTopic(m.topic, topic));

// 6. The person adds a 5-hybrid. Correct the fit memory by id: same row, topic and categories.
const current = served.find((m) => m.id === fit.id);
const corrected = readMemoryRow(current, me.privateKeyBase64).text.replace('and a 4-hybrid', 'a 4-hybrid and a 5-hybrid');
await put('/api/agent/context/memories', editMemoryRow(current, { text: corrected }, me.privateKeyBase64, keys));
// On 409 REV_CONFLICT: re-read, apply the correction to the current text, and PUT again.
```

Filing an existing memory under a topic that may not exist yet, without leaving an empty topic behind when the edit is refused, and without deleting one an edit with an unknown outcome may already use (`memoryId` is the memory's id):

<!-- example:attach-new-topic -->
```js
import { listTopics, findTopic, sameTopic, newTopicRows, readMemoryRow, editMemoryRow, isDefiniteRefusal } from './agent-memory.mjs';

const g = await get('/api/agent/context/memory-grantees');
const keys = { ownerPublicKey: g.owner_public_key, agentPublicKey: me.publicKeyBase64, grantees: g.grantees };
const { memories } = await get('/api/agent/context/memories');
const served = memories.find((m) => m.id === memoryId);
let topic = findTopic(listTopics(memories, me.privateKeyBase64), 'Golf club headcovers');
let made = null;
if (!topic) {
  made = newTopicRows({ name: 'Golf club headcovers', type: 'product', link: { relationType: 'is choosing' } }, keys);
  topic = { id: made.topicId, name: 'Golf club headcovers' };
  await post('/api/agent/context/memories', { rows: made.rows });
}
try {
  await put('/api/agent/context/memories', editMemoryRow(served, { topic }, me.privateKeyBase64, keys));
} catch (err) {
  if (isDefiniteRefusal(err)) {
    // Refused (403, 404, 409 REV_CONFLICT or KID_CONFLICT): nothing was filed. Remove the topic
    // written for it, best effort, so none is left empty.
    if (made) await post('/api/agent/context/memories/delete', { ids: made.rows.map((r) => r.id) }).catch(() => {});
    throw err;
  }
  // Unknown outcome (network error, timeout, 5xx): the PUT may have committed. Keep the topic and
  // re-read the memory to learn the outcome; report it if it was not filed.
  const { memories: now } = await get('/api/agent/context/memories');
  const after = now.find((m) => m.id === memoryId);
  if (!after || !sameTopic(readMemoryRow(after, me.privateKeyBase64)?.topic, topic)) throw err;
}
```

A later conversation, or another agent the person has granted this topic, runs steps 1 and 2 and finds the same topic. It adds new information as new memories on that topic and folds changes into the existing ones by id. The person sees **Golf club headcovers** under Topics and on the Map, joined by the connection it was created with, with these memories inside.

## Verify the result

1. After saving, retain the returned memory ID and fetch that row through Decoy. Decrypt it and confirm its text, categories and associations match the intended save.
2. After a correction, read the same ID and verify the new content and preserved details. Resolve a revision conflict by reading the current row before editing again.
3. In a later session, restore the connection and key from secure storage, then retrieve the saved memory through Decoy. Use this result for recall.
4. When the person requests deletion, inspect both deleted and refused IDs, then verify the requested deletion. Delete only within that request and your effective authority.

After the first useful save, invite the person to open Decoy to review or edit their memories. Keep the chat confirmation brief and describe the result you verified.
