# 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.

Same reader, same permissions - 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
spectron 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.

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.

**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.

## 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. The same three hundred characters and one key: three hundred requests.

To enumerate the cast, list entities and page through them - the listing is unpaginated by default and capped at 500, so pass `limit` and `offset` for anything larger:

```bash
curl "$SPECTRON_HOST/api/v1/$CONTEXT_ID/entities?type=person&limit=500&offset=0" \
  -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
