> Full SurrealDB documentation index: https://surrealdb.com/docs/llms.txt

# Spoiler-safe narrative memory

Ingest full canon; answer only as far as the user has read or watched.

SurrealDB Agent Memory can hold an entire story - novel, series, franchise, training curriculum released in modules - while an agent answers **only from what the user has reached so far**. The pattern combines three ideas you already have elsewhere in the docs:

1. **`observed_at` / `observedAt` on ingest** - stamp each chapter, page, or episode with a synthetic **known time** on the narrative axis (not wall-clock import time).
2. **`asOf` on recall** - the user's current position ("I'm on chapter 8", "I've finished episode V") selects which stamped facts and **relations** are visible.
3. **`labels` and `lens`** (optional) - navigate by `chapter=3` / `page=5`, or narrow a query to a scope region without changing permissions.

See [Temporal validity](/docs/agent-memory/reasoning/temporal-validity.md#narrative-playback-and-spoiler-safety) for the model; this page is the recipe.

## When to use it

| Scenario | Why bulk ingest breaks | What you gate |
| --- | --- | --- |
| Novel with a late reveal | Hyde/Jekyll-style identity twist | Relation edges and attributes stamped at the reveal |
| TV or film franchise | Viewing order ≠ story chronology | Per-episode stamps in **the order the user chose** |
| Long book series | Reader is on book 3 of 14 | `asOf` at book 3's end - later books ingested but hidden |
| Policy / curriculum modules | User certified on module 2 only | Module-scoped stamps + labels |

## Worked example: *Dr Jekyll and Mr Hyde*

*The Strange Case of Dr Jekyll and Mr Hyde* is a useful mental model: the whole plot is a single-entity reveal. Ingest each chapter (or page) with its own synthetic `observedAt`, monotonically increasing through the book. Stamp the Hyde↔Jekyll `same_as` relation only when you ingest the reveal chapter.

- Query with **`asOf`** at “chapter 8” → Hyde and Jekyll stay separate in the graph; no spoiler link.
- Query with **`asOf`** at “book finished” → the `same_as` edge is visible.

The reader and the permissions stay the same, and only the **`asOf`** instant changes.

## Franchise and non-chronological orders

Release order and story order are different problems with the same mechanism: **stamps follow discovery order, not calendar dates**.

**Star Wars (release order IV → V → VI, then I → II → III)**

Ingest each film with `observedAt` (or per-scene triples with `observed_at`) on a timeline that matches **when a release-order viewer learns each fact**. "Darth Vader is Luke's father" gets a stamp after *Empire* - not after *A New Hope*. A separate reader profile watching I → II → III first would use a different stamp sequence on the same ingested records (or separate scope paths per viewing track), because for this viewer there is no spoiler in Episode V that Anakin Skywalker and Darth Vader are the same person. However, a viewer following this path does have a reveal in Episode III that Anakin is no longer a hero, a fact that a user watching in release order would already be aware of when viewing the prequels.

**Wheel of Time (reader on book 3)**

Ingest all books if you want one substrate, but stamp facts extracted from book *N* with monotonically increasing known times per book (or per chapter). Recall with `asOf` set to "end of book 3" so prophecies, deaths, and alliances from later books stay out of answers.

```http
POST /api/v1/{context_id}/facts
Content-Type: application/json

{
  "text": "… excerpt from The Dragon Reborn …",
  "infer": "full",
  "scopes": [["org/my-app/reader/alice"]],
  "observed_at": "2000-10-15T00:00:00Z",
  "labels": ["book=3", "chapter=12"]
}
```

```http
POST /api/v1/{context_id}/query
Content-Type: application/json

{
  "query": "Who is Rand al'Thor allied with?",
  "k": 10,
  "asOf": "2000-10-15T00:00:00Z",
  "lens": [["org/my-app/reader/alice"]],
  "labels": ["book=3"]
}
```

Adjust the synthetic timeline to your ordering scheme; the important part is that **later books never share an earlier stamp**.

## Document ingest (page-by-page)

For PDFs or markdown split into pages:

```bash
agent-memory documents upload ./chapters/ch08.txt \
  --scope org/my-app/reader/alice \
  --label chapter=8 --label page=5 \
  --as-of 1886-03-01T00:00:00Z
```

Metadata JSON equivalent:

```json
{
  "title": "Dr Jekyll and Mr Hyde - ch. 8",
  "scopes": [["org/my-app/reader/alice"]],
  "labels": ["chapter=8", "page=5"],
  "observedAt": "1886-03-01T00:00:00Z"
}
```

Then query with matching `asOf` and optional `labels` for passage-only navigation.

## Tracking prominence over the timeline

While SurrealDB Agent Memory reconciles repeated mentions into a single state, it does not count them. Re-asserting the same fact does not increment a tally you can read back, and **`importance`** is a retrieval weight, not a mention count.

When you need a per-position measure - how prominent a character is by chapter *N* - compute it during ingest and store it as an ordinary attribute stamped with that chapter's known time. Each chapter's write supersedes the previous value, so the attribute becomes one supersession chain per character, and **`asOf`** returns the count as it stood at that point in the story.

The following example shows how this would be done with characters from the first few chapters of the fantasy series The Wheel of Time in order to track the importance of characters as the book progresses.

Write one batch per chapter. Use **`infer: "triples"`** to store the values verbatim, with no LLM extraction:

```http
POST /api/v1/{context_id}/facts
Content-Type: application/json

{
  "infer": "triples",
  "observed_at": "2000-01-08T00:00:00Z",
  "triples": [
    { "entity": { "type": "person", "name": "Moiraine" }, "key": "mention_count", "value": "40" },
    { "entity": { "type": "person", "name": "Lan" }, "key": "mention_count", "value": "20" }
  ]
}
```

Read the value at any reader position:

```bash
curl "$SPECTRON_HOST/api/v1/$CONTEXT_ID/entities/person/moiraine?asOf=2000-01-08T00:00:00Z" \
  -H "authorization: Bearer $SPECTRON_API_KEY"
```

The character Moiraine shows up in the book after the first few chapters, with a `mention_count` that quickly accelerates once she is first introduced:

| Reader position | `asOf` | `mention_count` |
| --- | --- | --- |
| Chapter 5 | `2000-01-06T00:00:00Z` | `28` |
| Chapter 7 | `2000-01-08T00:00:00Z` | `40` |
| Chapter 8 | `2000-01-09T00:00:00Z` | `85` |

Decide two things before you start:

- **Cumulative or per unit.** Cumulative totals only grow, and read as “prominence so far”. Per-chapter counts rise and fall, and read as “prominence in this chapter”. Store both under different keys if you need both - each key gets its own chain.
- **Counting is yours.** SurrealDB Agent Memory stores the number you supply. Aliases, epithets, and pronouns are a counting problem to solve before the write. See [Role nouns collect unrelated people](#role-nouns-collect-unrelated-people).

Batch every character for one chapter into a single request. One request per character multiplies the write count for no benefit.

> [!WARNING]
> **The entity type is half the identity, not a label.** An entity's record id is the composite **`[type, normalised_name]`**, and an unrecognised type falls back to **`other`** instead of failing. A counter written as `{"type": "creature", "name": "Trolloc"}` lands on `other/trolloc`, while the same character extracted from a document as a `person` is a **separate entity with a separate chain**. Use a type from the closed vocabulary - `person`, `organisation`, `project`, `location`, `topic`, `product`, `policy`, `concept`, `event`, `agent`, `service`, `other` - and use the same one every time you write the counter.

> [!NOTE]
> One write per chapter per character adds one row to that character's chain per chapter. That is the intended shape - history is kept, not overwritten - but keep content volume in mind as well: a 700-chapter series produces a 700-row chain for every character you track.

## Name variants are separate entities

Entity identity is a composite **`[type, normalised_name]`**, and matching on the write path is an exact record-id lookup. There is no fuzzy, phonetic, or embedding-based matching, no alias field, and no merge operation.

A narrative works against this. Characters are introduced in full and then referred to informally, so extraction mints each surface form it meets as its own entity: `Mat` and `Matrim Cauthon` become two people, as do `Egwene` and `Egwene al'Vere`. A misspelling in the source text becomes a third.

`same_as` does not fix this. It is an ordinary relation label - it records the claim that two entities are the same, and nothing reconciles them into one row.

Some ways to work with name variants:

**Detect it.** A hygiene sweep reports near-identical entities above a cosine threshold:

```http
POST /api/v1/{context_id}/fsck
Content-Type: application/json

{ "check": "duplicates", "duplicateThreshold": 0.95, "maxResults": 100 }
```

This reports, but does not merge.

> [!NOTE]
> The `duplicates` check currently returns no candidates, and stays inactive until `fsck` can limit its results to the scopes the caller is allowed to read. Until then, look through `GET /api/v1/{context_id}/entities` for variants of the same name.

**Prevent it.** Extraction is shown the entities the context already holds and told to reuse their exact names, so writing your canonical cast before a bulk ingest makes later mentions attach to the rows you chose. Seed the names you care about first, in the type you intend to keep.

## Role nouns collect unrelated people

A narrative refers to somebody by role before it names them, and often never names them at all: `stranger`, `friar`, `the youth`, `old man`, `landlord`. Extraction mints each of these as an entity, because in the sentence it reads they stand where a name would stand.

This is the mirror image of the problem above. A name variant splits one character into several, which a reader of the entity list can see. A role noun fuses several characters into one, and the fused entity looks ordinary, with a history like any other.

Seeding a canonical cast ahead of time does not help in this case. Extraction is shown the entities the context already holds and told to reuse their exact names, so the first `stranger` entity collects every stranger that follows it.

Some ways to work with them:

- **Look for common nouns in the entity list** after a bulk ingest. A duplicate sweep does not report these, because there is one entity rather than two, and its name is not near-identical to anything.
- **Treat a high relation count on a generic name as the signal.** An entity called `stranger` that is related to eight other people is eight people.
- **Resolve the reference in your own pipeline** where the people must be counted. Replace the role noun with the name the surrounding text supplies, or drop the sentence, before the write.
- **Keep a stoplist** of the role nouns your corpus uses, and check the entity list against it after each ingest.

## Reading a whole timeline back

**`asOf`** answers "what was true at this point". Rebuilding an entire timeline - every character at every chapter, to drive an export or a visualisation - by calling **`asOf`** once per character per position costs one request per cell. Three hundred characters across seven hundred chapters is 210,000 requests for a single attribute.

Ask for the chain instead. A single call returns the full supersession chain for one attribute key:

```bash
curl "$SPECTRON_HOST/api/v1/$CONTEXT_ID/entities/person/moiraine/history/mention_count" \
  -H "authorization: Bearer $SPECTRON_API_KEY"
```

Every row carries its `value`, its `createdAt`, and `supersedes` / `supersededBy` links. Because **`observed_at`** on ingest sets `created_at` as the row's known time - the same field **`asOf`** walks when it resolves a chain - `createdAt` here is the chapter's narrative stamp, not the wall-clock moment you uploaded it. The chain is therefore already the per-position series, in order.

That turns one request per character per position into one request per character per key. For the same three hundred characters and one key, that is three hundred requests.

To enumerate the cast, list entities and page through them. The listing returns 100 entities by default and at most 500 per page, so pass `limit` and follow `page.nextCursor` with `cursor` until it is absent:

```bash
curl "$SPECTRON_HOST/api/v1/$CONTEXT_ID/entities?type=person&limit=500" \
  -H "authorization: Bearer $SPECTRON_API_KEY"
```

## Composing narrowings

On a single full-access reader you can combine three independent slices:

| Narrowing | Question | Mechanism |
| --- | --- | --- |
| Time | How far have I read? | `asOf` - gates facts and relations by known time |
| Scope lens | Limit this query to chapters 1-8 | `lens: [["chapter/1"], …]` - involvement filter within grant |
| Labels | What's on page 3? | `labels: ["page=3"]` - row filter; use with `include: ["passages"]` for text |

Use time for **spoiler boundaries on the graph**; use labels for **locating** content within what time already allows.

## Limits to know

- **Passages** are not reading-time-gated by `asOf` alone - they retain upload-time metadata. Rely on the structured graph for spoiler-safe *answers*, or filter passages with **`labels`**.
- **`asOf` on `/query`** walks known-time on attributes and relations; pair with [Temporal validity](/docs/agent-memory/reasoning/temporal-validity.md) for `valid_from` / `valid_until` on in-world dates.
- Response caching is bypassed when temporal filters are present - correct for narrative playback, slightly higher latency.

## Related reading

- [Storing memories](/docs/agent-memory/ingest/experiential/remember.md) - `observed_at` and `--as-of` on the CLI
- [Uploading documents](/docs/agent-memory/ingest/authoritative/uploading-documents.md) - `observedAt` in upload metadata
- [Recalling memories](/docs/agent-memory/retrieve/recall.md) - `asOf` and `occurredAt` on hits
- [Contexts and scope](/docs/agent-memory/mental-model/contexts-and-scope.md) - `lens` and labels

## Learn more about this topic

- [Agent memory needs three clocks: tri-temporal belief history](https://surrealdb.com/blog/spectron-insights-how-tri-temporal-belief-history-works#using-asof-to-avoid-spoilers) (Blog, section "Using asOf to avoid spoilers")
