---
title: "Team memory"
---

# Team memory

Shared decisions, conventions, notes and glossary entries a team edits together, merged by the server, and served to AI tools only after a person reviews and accepts the exact text.

Team memory holds what a team has decided and why: decisions, conventions, notes and glossary entries. People edit an entry together from the Editor, the Agents app or the CLI, offline-capable and without conflicts. Once a person accepts an entry, its text feeds retrieval, so the team's AI tools know it.

Team memory has no console page yet; the first-party apps use it through the API below.

## Entries

An entry belongs to a Context space (the space is the team). Entries cannot be created in personal spaces.

| Field | Meaning |
|---|---|
| `kind` | `decision`, `convention`, `note` or `glossary` |
| `title` | One line, 1 to 200 characters |
| `status` | `draft`, `accepted`, `superseded` or `archived` |
| `links` | Up to 50 links to a work item (https URL), a source or another entry of the same space |
| `supersedes`, `supersededBy` | The entry this one replaces, and the one that replaced it |
| `classification` | The entry's own value, which may only tighten the space's |

Status moves `draft` to `accepted`, then to `superseded` or `archived`; a draft or superseded entry can be archived. An accepted entry can be accepted again as a new revision. Superseded and archived entries are read-only. **Only accepted entries are retrievable.**

## How editing works

The text is a Yjs document (one `Y.Text` named `content`, plain strings), which editors such as CodeMirror, Monaco and ProseMirror bind to. Clients send Yjs updates; **the server merges them itself** and derives the plain text. No client ever supplies the text that retrieval sees.

- Updates must be causally complete: a client that is behind sends its changes against the server's state vector. An update that refers to something the server lacks is `409 missing_dependency` with the server's state vector.
- Each update has an `updateId` (8 to 64 characters of `A-Za-z0-9_-`), so a retry is harmless: the same bytes answer `200` with `duplicate: true`, other bytes `409 update_id_reused`.
- Only plain text is supported. Formatting, embeds, maps, nested types and subdocuments are refused as `422 unsupported_update`.
- Each Yjs client id is bound to the first author who uses it in an entry; another author using it gets `403 client_id_owned`.
- The edit log is append-only and keeps every update's author and time. Old update bytes are compacted away after `KEYSTONE_CONTEXT_MEMORY_COMPACT_AFTER` updates; the merged state keeps everything.

### Client protocol

1. `GET /api/v1/context/memory/entries/{id}` and apply `snapshot` to a `Y.Doc` (root `getText("content")`).
2. On every local update, post it with a fresh `updateId`.
3. On `409 missing_dependency`, post the diff against `details.stateVector`.
4. To catch up, poll `GET .../updates?sinceSeq=<last seen>` (apply `snapshot` when it is returned instead) or send your state vector.
5. Use a fresh Yjs client id per editing session.

## Review and accept

Edits to an accepted entry do not change what retrieval serves: they are pending changes until a person accepts again.

```http
POST /api/v1/context/memory/entries/{id}/accept
{"documentSha256": "<the hash the reviewer was shown>"}
```

- `documentSha256` is the SHA-256 of the document that would be indexed (title, kind, supersedes, links and text), returned by `GET` on the entry. If the entry changed since, the answer is `409 document_changed` with the current hash.
- **Separation of duties.** The creator of the entry, and anyone who changed it since its last accepted revision, cannot accept it (`403 context.memory.self_accept`). Another member accepts.
- Accept needs a signed-in person; an API key gets `403 context.person_required`.
- DLP runs at accept at the entry's level: findings are redacted at `redact`, and `drop` or `block_source` refuses the accept (`422 dlp_blocked`).
- Accepting an entry that has `supersedes` pointing at an accepted entry supersedes that entry in the same transaction.

Whether the reviewer actually reads the text is a process question; the hash ensures the text accepted is the text reviewed.

## Retrieval

Accepting creates (or refreshes) a source of kind `memory` for the entry, named `Memory: <title>`, which indexes the accepted revision only. Drafts are never retrievable. Superseded and archived entries leave retrieval in the transaction that retires them. Chunks carry `metadata.memory` (`entryId`, `revision`, `kind`, `title`, `acceptedAt`), and attribution records list the memory entries and revisions served. Memory sources cannot be created, deleted or reclassified through the source API (`409 memory_managed`).

## Routes

Base path `/api/v1/context/memory`. Bytes are base64.

| Method | Path | What |
|---|---|---|
| `GET` | `/entries?spaceId=&status=&kind=&q=&limit=&cursor=` | List or search a space's entries; `spaceId` required; snippets pass DLP |
| `POST` | `/entries` | Create an empty draft: `{spaceId, kind, title, links?, supersedes?, classification?}` |
| `GET` | `/entries/{id}` | The entry, its DLP-gated text, `projectionSha256`, `documentSha256`, `stateVector`, `snapshot` and `limits` |
| `PATCH` | `/entries/{id}` | Change title, kind, links, supersedes or classification, with optional `ifMetaVersion` |
| `POST` | `/entries/{id}/updates` | `{updateId, update}`: one Yjs update |
| `GET` | `/entries/{id}/updates` | `?sinceSeq=N` or `?stateVector=`: updates, a snapshot or a diff |
| `POST` | `/entries/{id}/accept` | `{documentSha256}` |
| `POST` | `/entries/{id}/supersede` | `{supersededBy}`: an accepted entry of the same space |
| `POST` | `/entries/{id}/archive` | Retire an entry |
| `GET` | `/entries/{id}/history` | Status events and edits, with authors and times |

Reads need `context:space:read`. Every change needs `context:space:write` **and** read access to the space: a write grant alone never lets someone edit what they cannot read. Organisation deny rules apply. Every change is audited (`context.memory.create`, `patch`, `update`, `accept`, `supersede`, `archive`); audit entries never contain the text.

The collaboration policy's `team_memory` switch gates create, patch, update and accept (`403 context.collab_policy.capability_disabled`). Reading, superseding and archiving stay possible when it is off, so an organisation can always clean up.

## Limits

| Variable | Default | Limit |
|---|---|---|
| `KEYSTONE_CONTEXT_MEMORY_MAX_UPDATE_BYTES` | 65536 | One update |
| `KEYSTONE_CONTEXT_MEMORY_MAX_TEXT_BYTES` | 65536 | An entry's text (UTF-8) |
| `KEYSTONE_CONTEXT_MEMORY_MAX_UNITS` | 262144 | UTF-16 units of a document, live and deleted |
| `KEYSTONE_CONTEXT_MEMORY_MAX_UPDATES_PER_ENTRY` | 50000 | Rows of an entry's edit log |
| `KEYSTONE_CONTEXT_MEMORY_UPDATES_PER_MINUTE` | 240 | Updates one author may add to one entry per minute (five times that across entries) |
| `KEYSTONE_CONTEXT_MEMORY_ENTRIES_PER_SPACE` | 5000 | Entries (not archived) in a space |
| `KEYSTONE_CONTEXT_MEMORY_COMPACT_AFTER` | 256 | Retained update payloads that trigger compaction |

An entry that reaches its unit cap accepts deletions only; the team continues in a new entry that supersedes it. An invalid value is a configuration error.

## What a malicious editor cannot do

An editor with write access can write anything within the caps, including misleading text, and can delete other people's text. They cannot make their text reach AI tools without another person accepting the exact document, skip DLP, lower the classification below the space's, move an entry to another space, take over another author's editing session, or read or write another organisation's or space's entries. Deletions and edits stay in the history with their authors. The raw edit log is readable by everyone who may read the space; space membership, not DLP, is the control for that.
